Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
57 commits
Select commit Hold shift + click to select a range
712eed3
Merge pull request #1174 from ChronoAIProject/sync/post-release-v0.15.0
chronoai-shining Jun 30, 2026
9246005
docs(skill): make ornn-agent-manual-cli description trigger-oriented …
chronoai-shining Jul 1, 2026
8baaeeb
docs(skill): remove phantom skillsets permissions endpoint (#1180)
chronoai-shining Jul 1, 2026
0c12e8a
docs(skill): refresh api-reference §5a skillsets against current cont…
chronoai-shining Jul 1, 2026
8e57712
docs(skill): rewrite + relocate SKILL.md §2.16 skillset workflow (#1180)
chronoai-shining Jul 1, 2026
861a37f
docs(skill): bump ornn-agent-manual-cli to v1.4 + changeset (#1180)
chronoai-shining Jul 1, 2026
dbf1946
refactor(api): extract shared cronSchedule settings validator (#1175)
chronoai-shining Jul 1, 2026
d21559d
feat(api): add sourceSync settings section (#1175)
chronoai-shining Jul 1, 2026
f5b50c5
feat(api): authenticate + ETag-condition GitHub source reads (#1175)
chronoai-shining Jul 1, 2026
4683f38
feat(api): extend SkillSource with drift-detection fields (#1175)
chronoai-shining Jul 1, 2026
26b2f00
feat(api): read-only checkSourceDrift + authenticated pull wiring (#1…
chronoai-shining Jul 1, 2026
12093f4
docs: changeset for #1175 (GitHub source auto-sync foundation)
chronoai-shining Jul 1, 2026
e7c3127
docs(skill): document skill transfer-ownership + dist-tags (#1180)
chronoai-shining Jul 1, 2026
b1ecac3
docs(skill): document assistant SSE + manifest JSON Schema endpoints …
chronoai-shining Jul 1, 2026
30fc2d0
docs(skill): document github/repo mirror coords + launch-promo (#1180)
chronoai-shining Jul 1, 2026
aab11df
Merge pull request #1181 from ChronoAIProject/feature/1175-authentica…
chronoai-shining Jul 1, 2026
c6080f7
docs(skill): fix per-model assistant surface + phantom quota scope (#…
chronoai-shining Jul 1, 2026
a805f25
docs: expand changeset to cover the full manual refresh (#1180)
chronoai-shining Jul 1, 2026
df687de
Merge branch 'develop' into feat/ornn-skill-skillset-refresh
chronoai-shining Jul 1, 2026
9e06159
Merge pull request #1183 from ChronoAIProject/feat/ornn-skill-skillse…
chronoai-shining Jul 1, 2026
3a54e05
feat(api): typed GitHubRateLimitError for 403/429 signals (#1176)
chronoai-shining Jul 1, 2026
17c8ff7
refactor(api): share classifyProbeResult + pickSourceSyncToken (#1176)
chronoai-shining Jul 1, 2026
e9f6d08
feat(api): source-drift repository query, setter, and index (#1176)
chronoai-shining Jul 1, 2026
441145a
feat(api): skill.source_broken owner notification (#1176)
chronoai-shining Jul 1, 2026
9352cc6
feat(api): source-drift batch job (coalesce + rate-limit backoff) (#1…
chronoai-shining Jul 1, 2026
3f18604
feat(api): source-sync Agenda scheduler (#1176)
chronoai-shining Jul 1, 2026
c23d8ba
feat(api): wire source-sync scheduler into bootstrap (#1176)
chronoai-shining Jul 1, 2026
f4d7f09
docs: changeset for #1176 (scheduled source drift-check job)
chronoai-shining Jul 1, 2026
415a3bf
Merge remote-tracking branch 'origin/develop' into feature/1176-sourc…
chronoai-shining Jul 1, 2026
daba5e9
Merge pull request #1184 from ChronoAIProject/feature/1176-source-dri…
chronoai-shining Jul 1, 2026
9ff5e8c
fix(skill): valid YAML + ≤1024 description for manual-cli frontmatter…
chronoai-shining Jul 1, 2026
579a436
Merge pull request #1186 from ChronoAIProject/fix/skill-frontmatter-yaml
chronoai-shining Jul 1, 2026
a615f92
feat(api): auto-sync analytics events + owner notifications (#1177)
chronoai-shining Jul 1, 2026
9c25492
feat(api): autoPublishFromSource + system source-sync actor (#1177)
chronoai-shining Jul 1, 2026
7900f4a
feat(api): trigger auto-publish from the drift job (#1177)
chronoai-shining Jul 1, 2026
06fdd6f
feat(api): enable auto-publish in the source-sync scheduler wiring (#…
chronoai-shining Jul 1, 2026
f2cbf55
docs: changeset + analytics events for #1177 (auto-publish)
chronoai-shining Jul 1, 2026
a9eb72a
Merge remote-tracking branch 'origin/develop' into feature/1177-auto-…
chronoai-shining Jul 1, 2026
a213f29
Merge pull request #1187 from ChronoAIProject/feature/1177-auto-publi…
chronoai-shining Jul 1, 2026
6678339
feat(api): surface source driftState on the skill GET response (#1178)
chronoai-shining Jul 1, 2026
b0cc17c
feat(web): mirror drift fields + auto-sync notification categories (#…
chronoai-shining Jul 1, 2026
013ee1a
feat(web): passive auto-sync drift badge on the skill detail surfaces…
chronoai-shining Jul 1, 2026
cb88084
feat(web): lazy on-view drift refetch + auto-sync notification labels…
chronoai-shining Jul 1, 2026
3193fca
docs: changeset for #1178 (auto-sync status UI)
chronoai-shining Jul 1, 2026
69fc652
feat(api): raise skill description cap 1024 → 1536 (align with Claude…
chronoai-shining Jul 1, 2026
bd9980e
docs(skill): richer ~1500-char trigger description, v1.5 (#1180)
chronoai-shining Jul 1, 2026
898a26b
Merge pull request #1188 from ChronoAIProject/feature/1178-auto-sync-ui
chronoai-shining Jul 1, 2026
5ac56fa
Merge branch 'develop' into feat/raise-skill-description-cap
chronoai-shining Jul 1, 2026
181e8ae
Merge pull request #1190 from ChronoAIProject/feat/raise-skill-descri…
chronoai-shining Jul 1, 2026
1097275
feat(api): proxy skill downloads via chrono-bucket streaming endpoint…
chronoai-shining Jul 7, 2026
4998806
feat(web): download skill packages via the ornn-api proxy (#1196)
chronoai-shining Jul 7, 2026
5d30e40
docs: changeset for chrono-bucket download proxy (#1196)
chronoai-shining Jul 7, 2026
421ba37
test(api): guard getPackageBytes visibility gate on the download rout…
chronoai-shining Jul 7, 2026
86a05ea
fix(web): add /api/v1 prefix to the skill-package download path (#1196)
chronoai-shining Jul 7, 2026
7a9b743
Merge pull request #1197 from ChronoAIProject/feature/1196-chrono-buc…
chronoai-shining Jul 7, 2026
ec84c51
docs: release notes for the next release (GitHub source auto-sync)
chronoai-shining Jul 7, 2026
6415ef1
Merge pull request #1199 from ChronoAIProject/chore/release-notes-202…
chronoai-shining Jul 7, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/chrono-bucket-download-proxy.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"ornn-api": minor
"ornn-web": minor
---

Migrate skill package downloads to chrono-bucket's streaming endpoint (#1196). chrono-bucket (replacing chrono-storage) removed presigned URLs and the object-copy endpoint and now serves downloads via a streaming `GET /objects/download` behind the NyxID proxy. ornn-api's storage client gains a streaming `downloadObject()` (replacing the removed `getPresignedUrl`/`copy`); package reads for diff/json/audit go through it, and a new `GET /skills/:idOrName/versions/:version/download` route streams the ZIP through ornn-api (the route the TS SDK's `downloadPackage()` already targets). The client-facing `presignedPackageUrl` field is dropped from the skill detail response, and the web viewer now pulls packages through that authenticated ornn-api route instead of fetching chrono-bucket / MinIO directly. Also removes the wasted presigned round-trip and dead code in the audit pipeline (#995).
4 changes: 4 additions & 0 deletions .changeset/fix-manual-cli-frontmatter-yaml.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
---
---

docs(skill): fix `ornn-agent-manual-cli` v1.4 frontmatter — the `description` contained an unquoted `: ` (colon-space) that broke YAML parsing on ingest/refresh, and exceeded the 1024-char cap. Reworded to remove the colon and trimmed to 993 chars; verified against the API's `yaml.parse` + `validateSkillFrontmatter`. Docs-only.
5 changes: 5 additions & 0 deletions .changeset/gh-source-auth-drift-foundation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"ornn-api": minor
---

Add the efficient-read foundation for automatic GitHub source sync (#1175): GitHub source reads can now authenticate with a service-account token (settings section `sourceSync` or `ORNN_SOURCE_SYNC_GITHUB_TOKEN`) and use ETag-conditional requests, a cheap `git/ref` HEAD-SHA drift probe, and a read-only `checkSourceDrift` that records drift state on the skill. No behavior change to existing flows — the manual refresh path simply sends an `Authorization` header when a token is configured.
5 changes: 5 additions & 0 deletions .changeset/gh-source-auto-publish.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"ornn-api": minor
---

Add unattended auto-publish for drifted GitHub-sourced skills (#1177): when the `sourceSync.autoPublish` switch is on, the scheduled drift check now automatically re-pulls and publishes a new version of any skill whose upstream moved — no manual trigger. The auto path runs the same validation as a manual refresh, refuses to publish when the `SKILL.md` version wasn't bumped (surfacing "changed but not versioned" instead of clobbering the immutable version), records the new version under a dedicated system actor, and notifies the owner on both success and refusal. Ships off by default.
5 changes: 5 additions & 0 deletions .changeset/gh-source-auto-sync-ui.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"ornn-web": minor
---

Surface automatic GitHub source-sync status in the UI (#1178): the skill detail page now shows a passive badge driven by the source's drift state — "Auto-synced", "Update in progress", "Upstream changed — version not bumped" (warning), or "Source unavailable" (error) — next to the existing "Synced from GitHub" chip and in the advanced GitHub-link panel, so owners see what auto-sync did without polling. The `skill.auto_synced` / `skill.auto_sync_failed` / `skill.source_broken` notifications render with proper labels, and opening a skill opportunistically refreshes a stale drift state once (no polling loop). The GET skill response now includes the drift fields that drive this (ornn-api).
5 changes: 5 additions & 0 deletions .changeset/gh-source-drift-scheduler.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"ornn-api": minor
---

Add the scheduled GitHub source drift-check job (#1176): a multi-pod-safe Agenda scheduler periodically probes every GitHub-sourced skill's upstream (coalescing skills that share a repo/ref into one request), records whether it has drifted, and notifies the owner when a source repo becomes unreachable. Cadence is driven by the `sourceSync.pollSchedule` setting; the job honors GitHub rate limits and never lets one skill's failure abort the run. It only records drift state — automatic re-publish is a later change.
10 changes: 10 additions & 0 deletions .changeset/ornn-manual-cli-skillset-refresh.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
---

docs(skill): refresh the `ornn-agent-manual-cli` skill to v1.4.

- Trigger-oriented frontmatter `description` so Claude Code reliably auto-invokes the skill (covers skills **and** skillsets, plugin export, ownership transfer, etc.).
- Full skillset lifecycle across `SKILL.md` §2.16 and `api-reference.md` §5a: remove the phantom `PUT /skillsets/:id/permissions`, add plugin-export + transfer-ownership, document derived visibility, auto-revision, the real response/closure shapes, and the error-code table.
- Broader api-reference drift fixed against current `ornn-api`: skill transfer-ownership + dist-tags, assistant SSE, manifest JSON Schema, public/admin `github/repo` mirror coords, launch-promo, the per-model `assistant` surface flags, and the phantom `ornn:quota:admin` scope (real scope is `ornn:admin:skill`).

Docs-only: no `ornn-api` / `ornn-web` runtime change (the skill is registry-sourced, not bundled into either package image). The skill's own registry version bumps 1.3 → 1.4; publishing the refreshed content to the live registry is a separate operational step.
6 changes: 6 additions & 0 deletions .changeset/raise-skill-description-cap.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"ornn-api": patch
"ornn-web": patch
---

Raise the skill frontmatter `description` cap from 1024 to 1536 characters, aligned with Claude Code's skill-listing truncation limit (`skillListingMaxDescChars`, default 1536) — so an author can write a description as rich as the runtime actually uses for auto-invocation routing, instead of relying on `skip_validation` to smuggle a longer one past the schema. Skillset descriptions are unchanged (still 1024).
2 changes: 2 additions & 0 deletions .changeset/release-notes-20260707.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
---
---
14 changes: 14 additions & 0 deletions .github/release-notes-20260707.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
## Fixed

- Few technical bugs fixed.

## New Feature

- GitHub-sourced skills detect upstream changes and can auto-publish.
- Skill detail page shows GitHub source sync status.
- Technical enhancement.

## Changed

- Skill package downloads move to a streaming API route.
- Skill description limit raised from 1024 to 1536 characters.
2 changes: 2 additions & 0 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,8 @@ so dashboards can disambiguate from frontend events of the same name):
| `skill.visibility_changed` / `.permissions_changed` | visibility + sharing flips | `skillId`, `isPrivate`, `sharedWithUsers`, `sharedWithOrgs`, `writeGrants` (count of `write` grants, #1123) |
| `skill.ownership_transferred` | ownership handed to another user (#1123) | `skillId`, `skillName`, `priorOwnerId`, `newOwnerId` |
| `skill.refresh` / `.source_linked` / `.source_unlinked` | source-pointer ops | `skillId`, `repo`, `ref`, `commit` |
| `skill.source_drift_detected` | scheduled drift check found upstream moved (#1176/#1177) | `skillId`, `repo`, `ref`, `upstreamHeadSha` |
| `skill.auto_synced` / `.auto_sync_failed` | unattended auto-publish outcome (#1177) | `skillId`, `fromVersion`, `toVersion` / `reason` |
| `skill.nyxid_service_tied` / `.agentseal_rescanned` | tie + admin-rescan | `skillId`, `isSystemSkill`, `score` |
| `settings.exported` / `.imported` | settings IO | `schemaVersion`, `aggregateStatus`, `dryRun`, `sections` |

Expand Down
62 changes: 56 additions & 6 deletions ornn-api/src/bootstrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,11 @@ import {
createMirrorScheduler,
type MirrorScheduler,
} from "./domains/skills/mirror/scheduler";
import {
createSourceSyncScheduler,
type SourceSyncScheduler,
} from "./domains/skills/crud/sourceSyncScheduler";
import { runSourceDriftJob } from "./domains/skills/crud/sourceDriftJob";

// Domain: Me (caller-scoped endpoints)
import { createMeRoutes } from "./domains/me/routes";
Expand Down Expand Up @@ -487,6 +492,10 @@ export async function bootstrap(
maxEntryUncompressedBytes: config.maxEntryUncompressedBytes,
maxPackageFileCount: config.maxPackageFileCount,
maxCompressionRatio: config.maxCompressionRatio,
// Source-sync (#1175): authenticate GitHub source reads. The settings
// token wins; the env token is the fallback.
sourceSyncSettings: settingsService,
sourceSyncGithubTokenFallback: config.sourceSyncGithubToken,
});

// ---- Domain: Notifications + Broadcasts ----
Expand Down Expand Up @@ -544,9 +553,6 @@ export async function bootstrap(
const auditService = new AuditService({
auditRepo,
skillService,
storageClient,
storageBucketResolver: async () =>
(await settingsService.getNyxid()).chronoStorageBucket,
llmClient: nyxLlmClient,
defaultsResolver: async () => resolveAuditDefaults(),
// Audits are re-run automatically when the cached record ages past
Expand Down Expand Up @@ -780,6 +786,43 @@ export async function bootstrap(
mirrorScheduler,
});

// In-process source-drift scheduler (#1176/#1177). Same multi-pod-safe
// Agenda pattern as the mirror scheduler; cadence driven by
// `settings.sourceSync.pollSchedule`. Detects when a GitHub-sourced skill's
// upstream moved, records drift state, and — when `sourceSync.autoPublish`
// is on — auto-publishes the new version. A start failure logs and leaves
// this pod without the scheduled scan rather than crashing boot.
let sourceSyncScheduler: SourceSyncScheduler | null = null;
try {
sourceSyncScheduler = createSourceSyncScheduler({
db,
logger,
settingsService,
runDriftJob: () =>
runSourceDriftJob({
skillRepo,
settingsService,
envTokenFallback: config.sourceSyncGithubToken,
notifier: notificationService,
analyticsEmitter,
// #1177 — unattended auto-publish of drifted skills when the
// sourceSync.autoPublish switch is on.
autoPublish: (guid) => skillService.autoPublishFromSource(guid),
logger,
// Small per-group jitter so a large catalogue spreads its probes
// across the tick instead of bursting one egress IP.
jitterMs: 250,
}),
});
await sourceSyncScheduler.start();
} catch (err) {
logger.error(
{ err: err instanceof Error ? err.message : String(err) },
"source-sync scheduler failed to start — scheduled drift checks will not run on this pod",
);
sourceSyncScheduler = null;
}

// Skill routes — sharing is now a direct PUT /permissions write; the
// audit signal is surfaced as a per-version label, not a gate.
// #1136 — forward reference: the skill routes fire a reactive skillset
Expand Down Expand Up @@ -1130,9 +1173,16 @@ export async function bootstrap(
// ---- Shutdown ----
async function shutdown(): Promise<void> {
logger.info("Shutting down ornn-api...");
// Stop the scheduler first so no new mirror reconciles start while
// we're tearing the Mongo connection down. `stop()` is idempotent +
// already swallows its own errors.
// Stop the schedulers first so no new reconciles / drift checks start
// while we're tearing the Mongo connection down. `stop()` is idempotent +
// already swallows its own errors. Source-sync first, then mirror.
if (sourceSyncScheduler) {
try {
await sourceSyncScheduler.stop();
} catch (err) {
logger.warn({ err }, "Source-sync scheduler stop failed — continuing");
}
}
if (mirrorScheduler) {
try {
await mirrorScheduler.stop();
Expand Down
31 changes: 24 additions & 7 deletions ornn-api/src/clients/storageClient.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,23 +75,40 @@ describe("StorageClient SSRF preflight (#811)", () => {
expect(fetchCalls).toHaveLength(0);
});

it("getPresignedUrl() refuses a rebound host before issuing the request", async () => {
await expect(makeClient().getPresignedUrl("b", "k")).rejects.toBeInstanceOf(
it("downloadObject() refuses a rebound host before issuing the request", async () => {
await expect(makeClient().downloadObject("b", "k")).rejects.toBeInstanceOf(
SsrfRefusalError,
);
expect(fetchCalls).toHaveLength(0);
});

it("copy() refuses a rebound host before issuing the request", async () => {
await expect(makeClient().copy("b", "s", "d")).rejects.toBeInstanceOf(SsrfRefusalError);
expect(fetchCalls).toHaveLength(0);
});

it("allowlisted host passes the preflight and reaches fetch", async () => {
process.env[ALLOWLIST_ENV] = "rebind.test";
const out = await makeClient().upload("b", "k", new Uint8Array([1]), "text/plain");
expect(out).toEqual({ url: "x" });
expect(fetchCalls).toHaveLength(1);
expect(fetchCalls[0]).toContain("http://rebind.test/api/buckets/b/objects");
});

it("downloadObject() streams raw bytes from the /objects/download endpoint", async () => {
process.env[ALLOWLIST_ENV] = "rebind.test";
// The default beforeEach stub returns a JSON envelope; downloadObject reads
// the body as raw bytes, so this test serves a binary Response instead.
globalThis.fetch = (async (input: RequestInfo | URL) => {
const url =
typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
fetchCalls.push(url);
return new Response(new Uint8Array([1, 2, 3]), {
status: 200,
headers: { "Content-Type": "application/zip", "Content-Length": "3" },
});
}) as typeof fetch;

const out = await makeClient().downloadObject("b", "skills/g/1.0.zip");
expect(Array.from(out.bytes)).toEqual([1, 2, 3]);
expect(out.contentType).toBe("application/zip");
expect(out.contentLength).toBe(3);
expect(fetchCalls).toHaveLength(1);
expect(fetchCalls[0]).toContain("/api/buckets/b/objects/download?key=");
});
});
60 changes: 27 additions & 33 deletions ornn-api/src/clients/storageClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,17 @@ const logger = createLogger("storageClient");
export interface IStorageClient {
upload(bucket: string, key: string, data: Uint8Array, contentType: string): Promise<{ url: string }>;
delete(bucket: string, key: string): Promise<void>;
getPresignedUrl(bucket: string, key: string, expiresIn?: number): Promise<{ presignedUrl: string; expiresAt: string }>;
copy(bucket: string, sourceKey: string, destKey: string): Promise<void>;
/**
* Stream an object's raw bytes from chrono-bucket's
* `GET /api/buckets/:bucket/objects/download?key=` endpoint. Replaces the
* removed presigned-URL flow (#1196): chrono-bucket no longer hands out
* direct S3 URLs, so every read is proxied through this client instead of a
* client fetching MinIO directly.
*/
downloadObject(
bucket: string,
key: string,
): Promise<{ bytes: Uint8Array; contentType: string; contentLength?: number }>;
}

/**
Expand Down Expand Up @@ -102,48 +111,33 @@ export class StorageClient implements IStorageClient {
logger.debug({ bucket, key }, "File deleted from storage");
}

async getPresignedUrl(
async downloadObject(
bucket: string,
key: string,
expiresIn?: number,
): Promise<{ presignedUrl: string; expiresAt: string }> {
): Promise<{ bytes: Uint8Array; contentType: string; contentLength?: number }> {
const baseUrl = await this.resolveBaseUrl();
const params = new URLSearchParams({ key });
if (expiresIn !== undefined) {
params.set("expiresIn", String(expiresIn));
}
const url = `${baseUrl}/api/buckets/${bucket}/presigned-url?${params.toString()}`;
const url = `${baseUrl}/api/buckets/${bucket}/objects/download?${params.toString()}`;

const auth = await this.authHeaders();
const res = await safeFetch(url, { method: "GET", headers: auth });

if (!res.ok) {
const text = await res.text().catch(() => "");
logger.error({ status: res.status, bucket, key }, "Storage presigned URL failed");
throw new Error(`Storage presigned URL failed (${res.status}): ${text}`);
}

const json = (await res.json()) as { data: { presignedUrl: string; expiresAt: string } };
return json.data;
}

async copy(bucket: string, sourceKey: string, destKey: string): Promise<void> {
const baseUrl = await this.resolveBaseUrl();
const url = `${baseUrl}/api/buckets/${bucket}/objects/copy`;

const auth = await this.authHeaders();
const res = await safeFetch(url, {
method: "POST",
headers: { "Content-Type": "application/json", ...auth },
body: JSON.stringify({ sourceKey, destKey }),
});

if (!res.ok) {
const text = await res.text().catch(() => "");
logger.error({ status: res.status, bucket, sourceKey, destKey }, "Storage copy failed");
throw new Error(`Storage copy failed (${res.status}): ${text}`);
logger.error({ status: res.status, bucket, key }, "Storage download failed");
throw new Error(`Storage download failed (${res.status}): ${text}`);
}

logger.debug({ bucket, sourceKey, destKey }, "File copied in storage");
const bytes = new Uint8Array(await res.arrayBuffer());
const contentType = res.headers.get("content-type") ?? "application/octet-stream";
const lenHeader = res.headers.get("content-length");
const contentLength = lenHeader !== null ? Number(lenHeader) : NaN;
logger.debug({ bucket, key, bytes: bytes.byteLength }, "File downloaded from storage");

// exactOptionalPropertyTypes (#657): only attach contentLength when the
// header was present and numeric — never assign an explicit `undefined`.
return Number.isFinite(contentLength)
? { bytes, contentType, contentLength }
: { bytes, contentType };
}
}
1 change: 0 additions & 1 deletion ornn-api/src/domains/analytics/routes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,6 @@ function skill(overrides: Partial<SkillDetailResponse> = {}): SkillDetailRespons
metadata: {},
tags: [],
skillHash: "hash-1",
presignedPackageUrl: "https://storage.test/skill.zip",
isPrivate: false,
createdBy: OWNER_ID,
createdOn: "2026-01-01T00:00:00Z",
Expand Down
73 changes: 73 additions & 0 deletions ornn-api/src/domains/notifications/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -442,6 +442,79 @@ export class NotificationService {
});
}

/**
* Owner-side notification (#1176) fired when an automatic drift check finds
* a GitHub-sourced skill's upstream repo/ref can no longer be resolved
* (deleted, made private, or the branch/tag was removed). Lets the owner
* re-link or fix the source. Deep-links to the skill detail page.
*/
async notifySourceBroken(params: {
ownerId: string;
skillGuid: string;
repo: string;
ref: string;
}): Promise<void> {
const title = `The GitHub source for one of your skills is unavailable`;
const body =
`Ornn could not reach the upstream source (${params.repo}@${params.ref}) during an ` +
`automatic sync check — it may have been deleted, made private, or the branch/tag ` +
`was removed. Re-link the skill to a valid GitHub folder to resume automatic syncing.`;
await this.emit(params.ownerId, {
category: "skill.source_broken",
title,
body,
link: `/skills/${encodeURIComponent(params.skillGuid)}`,
data: { skillGuid: params.skillGuid, repo: params.repo, ref: params.ref },
});
}

/**
* Owner notification (#1177) fired when a GitHub-sourced skill was
* automatically re-published from upstream — so the owner knows a new
* version shipped without their action, and to what.
*/
async notifyAutoSynced(params: {
ownerId: string;
skillGuid: string;
fromVersion: string;
toVersion: string;
}): Promise<void> {
await this.emit(params.ownerId, {
category: "skill.auto_synced",
title: `A skill of yours auto-synced from GitHub to v${params.toVersion}`,
body:
`Ornn detected an upstream change and automatically published a new version ` +
`(v${params.fromVersion} → v${params.toVersion}) from the linked GitHub source.`,
link: `/skills/${encodeURIComponent(params.skillGuid)}`,
data: {
skillGuid: params.skillGuid,
fromVersion: params.fromVersion,
toVersion: params.toVersion,
},
});
}

/**
* Owner notification (#1177) fired when an automatic re-publish was
* refused — the upstream changed but the SKILL.md version wasn't bumped,
* or the pulled package failed validation. The owner must fix upstream.
*/
async notifyAutoSyncFailed(params: {
ownerId: string;
skillGuid: string;
reason: string;
}): Promise<void> {
await this.emit(params.ownerId, {
category: "skill.auto_sync_failed",
title: `Auto-sync could not publish a new version of one of your skills`,
body:
`Ornn detected an upstream change but did not publish it: ${params.reason}. ` +
`Your current published version is unchanged — fix the issue upstream to resume auto-syncing.`,
link: `/skills/${encodeURIComponent(params.skillGuid)}`,
data: { skillGuid: params.skillGuid, reason: params.reason },
});
}

private async emit(
userId: string,
payload: {
Expand Down
Loading