Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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/skillset-auto-update-members.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"ornn-api": minor
"ornn-web": minor
---

Add a per-skillset "always keep skills in this skillset up to date" toggle (#1191). When enabled, every member — pinned or not — resolves to its skill's latest version everywhere the set is delivered (closure, plugin export, snapshot, derived visibility), so a member's new version flows into the skillset (and its exported Claude Code plugin) automatically with no per-member ref changes. The override is resolution-time only — the authored member refs are never rewritten, so turning it off restores the pinned behavior. Enabling immediately re-cuts the revision when a member was behind. Exposed API-first as `PUT /api/v1/skillsets/:id/auto-update` and `autoUpdateMembers` on the skillset resource, with an owner-only toggle card on the skillset detail page.
8 changes: 6 additions & 2 deletions ornn-api/src/domains/skills/crud/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1709,12 +1709,16 @@ export class SkillService {
* stays single-sourced; both surfaces resolve refs (and apply the
* per-node `canReadSkill` visibility gate) identically.
*/
createVersionLoader(actor: ActorContext): LoadVersion {
createVersionLoader(actor: ActorContext, forceLatest = false): LoadVersion {
return async (ref: string): Promise<ResolvedVersion | null> => {
const at = ref.lastIndexOf("@");
if (at <= 0 || at === ref.length - 1) return null;
const idOrName = ref.slice(0, at);
const versionOrTag = ref.slice(at + 1);
// #1191 — when the caller resolves the members of a skillset with
// "always keep skills up to date" ON, every member resolves to its
// skill's latest version, overriding the authored pin / dist-tag /
// literal version. Non-destructive: the stored ref is untouched.
const versionOrTag = forceLatest ? "latest" : ref.slice(at + 1);

const skill =
(await this.skillRepo.findByGuid(idOrName)) ??
Expand Down
12 changes: 10 additions & 2 deletions ornn-api/src/domains/skills/mirror/mirrorService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -919,7 +919,11 @@ export class MirrorService {
// (bundled) vs private/unresolvable (excluded → README note only). Only the
// public subset's files are ever fetched, so a private member's content can
// never leak into the public mirror (#1161).
const load = this.deps.skillService.createVersionLoader(SYSTEM_ACTOR);
// #1191 — an auto-update skillset exports each member at its latest version.
const load = this.deps.skillService.createVersionLoader(
SYSTEM_ACTOR,
ss.autoUpdateMembers ?? false,
);
const members: SkillsetPluginMember[] = [];
const includedNames = new Set<string>();
const excludedMembers: string[] = [];
Expand Down Expand Up @@ -995,7 +999,11 @@ export class MirrorService {
if (!this.deps.skillsetService) return 0;
const latest = await this.deps.skillsetService.getLatestForMirror(ss.guid);
if (!latest) return 0;
const load = this.deps.skillService.createVersionLoader(SYSTEM_ACTOR);
// #1191 — count public members at their latest when auto-update is on.
const load = this.deps.skillService.createVersionLoader(
SYSTEM_ACTOR,
ss.autoUpdateMembers ?? false,
);
const publicNames = new Set<string>();
for (const ref of latest.members) {
const node = await load(ref);
Expand Down
60 changes: 60 additions & 0 deletions ornn-api/src/domains/skillsets/recompute.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,62 @@ function makeResolver(refs: Record<string, Verdict>): MemberVisibilityResolver {
};
}

/**
* Version-aware resolver fake (#1191) — models each skill's version set + a
* `latest`, and honors the loader's `forceLatest` flag so a pinned ref resolves
* to the skill's latest when the skillset opts into auto-update.
*/
function makeVersionedResolver(
skills: Record<string, { latest: string; versions: string[]; isPrivate?: boolean }>,
): MemberVisibilityResolver {
return {
createVersionLoader(_actor: ActorContext, forceLatest = false): LoadVersion {
return async (ref: string): Promise<ResolvedVersion | null> => {
const at = ref.lastIndexOf("@");
const name = ref.slice(0, at);
const skill = skills[name];
if (!skill) return null;
const version = forceLatest ? skill.latest : ref.slice(at + 1);
if (!skill.versions.includes(version)) return null;
return { ref: `${name}@${version}`, name, version, isPrivate: skill.isPrivate ?? false, dependsOn: [] };
};
},
};
}

describe("forceLatest override (#1191)", () => {
const skills = { pdf: { latest: "2.0", versions: ["1.0", "2.0"] } };

test("computePublicResolvedMembers honors the pin when forceLatest is off (default)", async () => {
const snap = await computePublicResolvedMembers(["pdf@1.0"], makeVersionedResolver(skills));
expect(snap).toEqual(["pdf@1.0"]);
});

test("computePublicResolvedMembers resolves a pin to latest when forceLatest is on", async () => {
const snap = await computePublicResolvedMembers(["pdf@1.0"], makeVersionedResolver(skills), true);
expect(snap).toEqual(["pdf@2.0"]);
});

test("forceLatest resolves a member whose PINNED version was deleted (unresolvable→resolved)", async () => {
// pdf@1.0 is gone; only 2.0 survives. The pin is unresolvable, but
// forceLatest jumps to the surviving latest, so the member snapshots.
const resolver = makeVersionedResolver({ pdf: { latest: "2.0", versions: ["2.0"] } });
expect(await computePublicResolvedMembers(["pdf@1.0"], resolver, false)).toEqual([]);
expect(await computePublicResolvedMembers(["pdf@1.0"], resolver, true)).toEqual(["pdf@2.0"]);
});

test("computeDerivedVisibility: a dead pin is unresolvable, but forceLatest recovers it", async () => {
const resolver = makeVersionedResolver({
pdf: { latest: "2.0", versions: ["2.0"] },
csv: { latest: "1.0", versions: ["1.0"] },
});
const off = await computeDerivedVisibility(["pdf@1.0", "csv@1.0"], resolver, false);
expect(off.memberVisibilityState).toBe("unresolvable");
const on = await computeDerivedVisibility(["pdf@1.0", "csv@1.0"], resolver, true);
expect(on).toEqual({ membersAllPublic: true, memberVisibilityState: "all-public" });
});
});

describe("computeDerivedVisibility", () => {
test("all members public → all-public / true", async () => {
const resolver = makeResolver({ "a@1.0": "public", "b@1.0": "public" });
Expand Down Expand Up @@ -126,6 +182,10 @@ function makeDeps(opts: {
writes[guid] = derived;
},
listAllGuids: async (): Promise<string[]> => Object.keys(opts.latestMembers),
// #1191 — recompute reads the identity doc for the auto-update flag. These
// fixtures don't opt in, so resolution uses the authored member refs.
findByGuid: async (guid: string) =>
guid in opts.latestMembers ? ({ autoUpdateMembers: false } as never) : null,
},
skillsetVersionRepo: {
findLatestBySkillset: async (guid: string) => {
Expand Down
14 changes: 10 additions & 4 deletions ornn-api/src/domains/skillsets/recompute.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ const logger = createLogger("skillsetRecompute");
* resolver without standing up the whole skill service.
*/
export interface MemberVisibilityResolver {
createVersionLoader(actor: ActorContext): LoadVersion;
createVersionLoader(actor: ActorContext, forceLatest?: boolean): LoadVersion;
}

export interface SkillsetRecomputeDeps {
Expand All @@ -60,8 +60,9 @@ export interface DerivedVisibility {
export async function computeDerivedVisibility(
members: string[],
skillService: MemberVisibilityResolver,
forceLatest = false,
): Promise<DerivedVisibility> {
const load = skillService.createVersionLoader(SYSTEM_ACTOR);
const load = skillService.createVersionLoader(SYSTEM_ACTOR, forceLatest);
let anyPrivate = false;
let anyUnresolvable = false;

Expand Down Expand Up @@ -108,8 +109,9 @@ export async function computeDerivedVisibility(
export async function computePublicResolvedMembers(
members: string[],
skillService: MemberVisibilityResolver,
forceLatest = false,
): Promise<string[]> {
const load = skillService.createVersionLoader(SYSTEM_ACTOR);
const load = skillService.createVersionLoader(SYSTEM_ACTOR, forceLatest);
const snapshot = new Set<string>();
for (const ref of members) {
const node = await load(ref);
Expand Down Expand Up @@ -172,7 +174,11 @@ export async function recomputeSkillsetVisibility(
logger.debug({ guid }, "Skillset has no version; skipping visibility recompute");
return null;
}
const derived = await computeDerivedVisibility(latest.members, deps.skillService);
// #1191 — when auto-update is on, classify visibility over the members'
// LATEST versions (the delivered set), matching how closure/export resolve.
const identity = await deps.skillsetRepo.findByGuid(guid);
const forceLatest = identity?.autoUpdateMembers ?? false;
const derived = await computeDerivedVisibility(latest.members, deps.skillService, forceLatest);
await deps.skillsetRepo.setDerivedVisibility(guid, derived);
return derived;
}
Expand Down
12 changes: 12 additions & 0 deletions ornn-api/src/domains/skillsets/repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,8 @@ export interface CreateSkillsetData {
latestVersion: string;
/** Owner opt-in (#1155) to export as a multi-skill plugin. Default OFF. */
exportAsPlugin?: boolean | undefined;
/** Owner opt-in (#1191) — always keep members at latest. Default OFF. */
autoUpdateMembers?: boolean | undefined;
/** Owner plugin listing overrides (#1157). Omitted ⇒ no overrides stored. */
pluginConfig?: SkillsetPluginOverrides | undefined;
}
Expand All @@ -82,6 +84,11 @@ export interface UpdateSkillsetData {
* doesn't mention the flag must not silently reset it).
*/
exportAsPlugin?: boolean;
/**
* Owner opt-in (#1191) — "always keep skills up to date". Set ONLY when an
* explicit boolean is provided; omitting it preserves the current setting.
*/
autoUpdateMembers?: boolean;
/**
* Owner plugin listing overrides (#1157). Three-state:
* - `undefined` — leave the stored overrides untouched (publish path).
Expand Down Expand Up @@ -235,6 +242,8 @@ export class SkillsetRepository {
memberVisibilityState: "all-public",
// Plugin-export opt-in (#1155) — default OFF.
exportAsPlugin: data.exportAsPlugin ?? false,
// Auto-update-members opt-in (#1191) — default OFF.
autoUpdateMembers: data.autoUpdateMembers ?? false,
latestVersion: data.latestVersion,
};
// Plugin listing overrides (#1157) — only written when supplied so a fresh
Expand Down Expand Up @@ -276,6 +285,7 @@ export class SkillsetRepository {
if (data.latestVersion !== undefined) setFields.latestVersion = data.latestVersion;
// #1155 — only an explicit boolean flips the opt-in; omission preserves it.
if (data.exportAsPlugin !== undefined) setFields.exportAsPlugin = data.exportAsPlugin;
if (data.autoUpdateMembers !== undefined) setFields.autoUpdateMembers = data.autoUpdateMembers;
// #1157 — three-state plugin overrides: object replaces, null clears
// ($unset so the mirror falls back to skillset fields), undefined no-ops.
const unsetFields: Record<string, unknown> = {};
Expand Down Expand Up @@ -521,6 +531,8 @@ function mapDoc(doc: Document | null): SkillsetDocument | null {
(doc.memberVisibilityState as SkillsetMemberVisibilityState | undefined) ?? "all-public",
// Plugin-export opt-in (#1155) — absent on pre-feature docs ⇒ false.
exportAsPlugin: doc.exportAsPlugin === true,
// Auto-update-members opt-in (#1191) — absent on pre-feature docs ⇒ false.
autoUpdateMembers: doc.autoUpdateMembers === true,
// Plugin listing overrides (#1157) — undefined when unset/legacy.
pluginConfig: coercePluginConfig(doc.pluginConfig),
latestVersion: doc.latestVersion ?? "1.0",
Expand Down
46 changes: 46 additions & 0 deletions ornn-api/src/domains/skillsets/routes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -399,6 +399,52 @@ describe("PUT/DELETE /skillsets/:id — scope gating", () => {
expect(((await res.json()) as { code: string }).code).toBe("skillset_too_few_public_members");
});

test("PUT /auto-update 403 without ornn:skill:update (#1191)", async () => {
const app = buildApp({ permissions: [CREATE] });
const res = await app.request("/api/v1/skillsets/ss-1/auto-update", {
method: "PUT",
headers: { "content-type": "application/json" },
body: JSON.stringify({ enabled: true }),
});
expect(res.status).toBe(403);
});

test("PUT /auto-update 400 on an invalid body (missing enabled) (#1191)", async () => {
const app = buildApp({ permissions: [UPDATE] });
const res = await app.request("/api/v1/skillsets/ss-1/auto-update", {
method: "PUT",
headers: { "content-type": "application/json" },
body: JSON.stringify({}),
});
expect(res.status).toBe(400);
});

test("PUT /auto-update 200 updates + fires the mirror reconcile (#1191)", async () => {
let fired = 0;
const captured: unknown[] = [];
const app = buildApp({
permissions: [UPDATE],
service: {
setAutoUpdateMembers: async (...args: unknown[]) => {
captured.push(args[1]);
return detail({ autoUpdateMembers: true });
},
},
fireMirrorReconcile: () => {
fired += 1;
},
});
const res = await app.request("/api/v1/skillsets/ss-1/auto-update", {
method: "PUT",
headers: { "content-type": "application/json" },
body: JSON.stringify({ enabled: true }),
});
expect(res.status).toBe(200);
expect(fired).toBe(1);
expect(((await res.json()) as { data: { autoUpdateMembers: boolean } }).data.autoUpdateMembers).toBe(true);
expect(captured[0]).toEqual({ enabled: true });
});

test("PUT + DELETE fire the mirror reconcile on success (#1155)", async () => {
let fired = 0;
const fireMirrorReconcile = () => {
Expand Down
32 changes: 31 additions & 1 deletion ornn-api/src/domains/skillsets/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,12 @@ import { validateBody, getValidatedBody } from "../../middleware/validate";
import { buildActorContext, type ActorContext } from "../skills/crud/authorize";
import { createLogger } from "../../shared/logger";
import type { SkillsetService } from "./service";
import { createSkillsetSchema, publishSkillsetSchema, pluginExportSchema } from "./types";
import {
createSkillsetSchema,
publishSkillsetSchema,
pluginExportSchema,
autoUpdateSchema,
} from "./types";

const logger = createLogger("skillsetRoutes");

Expand Down Expand Up @@ -203,6 +208,31 @@ export function createSkillsetRoutes(
},
);

/**
* PUT /skillsets/:id/auto-update — enable/disable "always keep skills in this
* skillset up to date" (#1191). When ON, every member resolves to its latest
* version wherever the set is delivered. Requires: ornn:skill:update +
* author/admin. Registered as a literal sub-segment so it never collides with
* the `PUT /skillsets/:id` publish.
*/
app.put(
"/skillsets/:id/auto-update",
auth,
requirePermission("ornn:skill:update"),
validateBody(autoUpdateSchema, "invalid_auto_update"),
async (c) => {
const id = c.req.param("id");
const body = getValidatedBody<z.infer<typeof autoUpdateSchema>>(c);
const actor = await buildActorContext(c);
const updated = await skillsetService.setAutoUpdateMembers(id, body, actor);
logger.info({ guid: id, enabled: body.enabled }, "Skillset auto-update updated via API");
// #1191 — flipping the flag can move the resolved-member set + revision;
// reconcile re-exports the plugin at the freshly-bumped revision.
fireMirrorReconcile?.();
return c.json({ data: updated, error: null });
},
);

// NOTE (#1136): there is deliberately NO PUT /skillsets/:id/permissions.
// A skillset has no owner-set visibility — its reach is derived from its
// member skills' visibility. To widen a skillset's reach, expose the
Expand Down
Loading