@hasna/skills 0.1.63 → 0.1.64

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/README.md +28 -6
  2. package/bin/index.js +1593 -228
  3. package/bin/mcp.js +290 -24
  4. package/bin/migrate.js +229 -33
  5. package/bin/server.js +774 -116
  6. package/bin/worker.js +558 -79
  7. package/dist/cli/commands/registry-reconcile.d.ts +2 -0
  8. package/dist/index.d.ts +1 -1
  9. package/dist/index.js +559 -251
  10. package/dist/lib/agent-sync.d.ts +26 -6
  11. package/dist/lib/auth-store.d.ts +37 -0
  12. package/dist/lib/config.d.ts +51 -0
  13. package/dist/lib/home-census.d.ts +4 -0
  14. package/dist/lib/home-migration.d.ts +8 -9
  15. package/dist/lib/native-storage.d.ts +1 -1
  16. package/dist/lib/portable-skills.d.ts +35 -6
  17. package/dist/lib/pull.d.ts +31 -0
  18. package/dist/lib/registry-reconcile.d.ts +114 -0
  19. package/dist/lib/registry.d.ts +7 -4
  20. package/dist/lib/remote-client.d.ts +118 -1
  21. package/dist/lib/remote-registry.d.ts +26 -0
  22. package/dist/lib/revision.d.ts +29 -0
  23. package/dist/sdk/index.js +1351 -353
  24. package/dist/server/app.d.ts +1 -1
  25. package/dist/server/config.d.ts +12 -2
  26. package/dist/server/rows.d.ts +2 -1
  27. package/dist/server/skills-api.d.ts +108 -6
  28. package/dist/server/sqlite-store.d.ts +20 -3
  29. package/dist/server/store.d.ts +31 -5
  30. package/dist/server/types.d.ts +98 -3
  31. package/dist/storage.js +7 -2
  32. package/migrations/postgres/0004_hosted_pins.sql +28 -0
  33. package/migrations/postgres/0005_revision_tombstone_registry.sql +37 -0
  34. package/migrations/postgres/0005_tag_projection.sql +36 -0
  35. package/migrations/sqlite/0004_hosted_pins.sql +21 -0
  36. package/migrations/sqlite/0005_revision_tombstone_registry.sql +18 -0
  37. package/migrations/sqlite/0005_tag_projection.sql +25 -0
  38. package/package.json +3 -2
@@ -1,6 +1,6 @@
1
1
  import { type SkillsServerConfig } from "./config.js";
2
2
  import { type MemorySkillsStore } from "./store.js";
3
- import type { SkillsProductStore } from "./types.js";
3
+ import { type SkillsProductStore } from "./types.js";
4
4
  export interface SkillsServerOptions {
5
5
  config?: Partial<SkillsServerConfig>;
6
6
  store?: SkillsProductStore;
@@ -9,8 +9,12 @@ export interface SkillsServerConfig {
9
9
  /**
10
10
  * HMAC key for signing served skill bundles. When set, the bundle endpoint adds
11
11
  * X-Skill-Bundle-Signature so clients holding the same key can verify that the bytes
12
- * they pulled are the bytes this server decided to serve. Read from
13
- * HASNA_SKILLS_SIGNING_KEY; never logged or printed.
12
+ * they pulled are the bytes this server decided to serve. Never logged or printed.
13
+ *
14
+ * Canonical env name: HASNA_SKILLS_API_SIGNING_KEY — the name the production deploy
15
+ * mounts (infra-live hasna-app module, infra/apps/skills/prod/main.tf). The legacy
16
+ * HASNA_SKILLS_SIGNING_KEY is retained as a backwards-compatible alias; the canonical
17
+ * name wins when both are set. Decision record: todos ee1904ca, plan 8022d27f.
14
18
  */
15
19
  bundleSigningKey?: string;
16
20
  requestBodyLimitBytes: number;
@@ -25,6 +29,12 @@ export interface SkillsServerConfig {
25
29
  * body is buffered, and the JSON cap is untouched.
26
30
  */
27
31
  skillBundleLimitBytes: number;
32
+ /**
33
+ * How long a deleted skill answers 410 with a tombstone marker before it is purged
34
+ * (todos d061fcda). Within the window a client's pull can reconcile (remove its local
35
+ * copy); after it, the row and its bundle are gone and the slug is an ordinary 404.
36
+ */
37
+ tombstoneWindowMs: number;
28
38
  publicBaseUrl: string;
29
39
  nodeEnv: string;
30
40
  /**
@@ -1,4 +1,4 @@
1
- import type { ServerArtifact, ServerRunLog, ServerRunRecord, ServerSkillBundle, ServerSkillRecord } from "./types.js";
1
+ import type { ServerArtifact, ServerPin, ServerRunLog, ServerRunRecord, ServerSkillBundle, ServerSkillRecord } from "./types.js";
2
2
  export declare function nowIso(): string;
3
3
  /**
4
4
  * Coerce a caller-supplied row limit to a non-negative integer.
@@ -17,6 +17,7 @@ export declare function rowToRun(row: Record<string, unknown>): ServerRunRecord;
17
17
  export declare function rowToLog(row: Record<string, unknown>): ServerRunLog;
18
18
  export declare function rowToArtifact(row: Record<string, unknown>): ServerArtifact;
19
19
  export declare function rowToSkill(row: Record<string, unknown>): ServerSkillRecord;
20
+ export declare function rowToPin(row: Record<string, unknown>): ServerPin;
20
21
  export declare function rowToSkillBundle(row: Record<string, unknown>): ServerSkillBundle;
21
22
  export declare function parseJsonObject(value: unknown): Record<string, unknown>;
22
23
  export declare function parseJsonArray(value: unknown): string[];
@@ -2,7 +2,19 @@ import { type OwnedBytes } from "../lib/skill-bundle.js";
2
2
  import type { SkillMeta } from "../lib/registry-types.js";
3
3
  import type { ArtifactStorage } from "./artifact-storage.js";
4
4
  import type { SkillsServerConfig } from "./config.js";
5
- import type { ApiPrincipal, PublishSkillInput, ServerSkillRecord, SkillsProductStore } from "./types.js";
5
+ import { type ApiPrincipal, type PublishSkillInput, type ServerPin, type ServerSkillRecord, type SkillsProductStore } from "./types.js";
6
+ /** Wire shape of a pin: the client-facing facts, without the storage columns. */
7
+ export declare function pinPayload(pin: ServerPin): Record<string, unknown>;
8
+ /**
9
+ * The metadata field of a pin body.
10
+ *
11
+ * Absent means an empty object (matching the schema default), present-and-not-an-
12
+ * object is refused rather than coerced: a client that sends `metadata: "team"`
13
+ * sent a bug, and storing it as `{}` would make the round-trip silently lose
14
+ * what the caller wrote. The route calls this on the parsed JSON body before
15
+ * touching the store, so a malformed body is a 400 and never a row.
16
+ */
17
+ export declare function pinMetadataField(body: Record<string, unknown>): Record<string, unknown>;
6
18
  export declare class SkillRequestError extends Error {
7
19
  readonly status: number;
8
20
  readonly code: string;
@@ -11,6 +23,34 @@ export declare class SkillRequestError extends Error {
11
23
  /** SkillMeta shape for a published row, so a client can treat both kinds alike. */
12
24
  export declare function publishedSkillMeta(record: ServerSkillRecord): SkillMeta;
13
25
  export declare function publishedPayload(record: ServerSkillRecord): Record<string, unknown>;
26
+ /**
27
+ * The HTTP ETag for a published row: the revision id, quoted exactly as RFC 9110 wants.
28
+ * The quotes are load-bearing — a client that echoes the whole header value back as
29
+ * If-Match must send the quotes too.
30
+ */
31
+ export declare function revisionEtag(revisionId: string): string;
32
+ /**
33
+ * Parse an If-Match header value into the revision id it names.
34
+ *
35
+ * Accepts the quoted form the server itself issues (RFC 9110) and a bare id for
36
+ * tolerance. `*` is refused: "any revision" would license exactly the silent overwrite
37
+ * the optimistic-concurrency guard exists to refuse. Malformed values are a 400
38
+ * statement about the request, never a fallback to "no guard".
39
+ */
40
+ export declare function parseIfMatch(value: string | null): string | undefined;
41
+ /**
42
+ * Resolve a stored row that may be a tombstone (todos d061fcda).
43
+ *
44
+ * A tombstoned row answers 410 with the marker while its window is open, so a client's
45
+ * pull can reconcile (remove the local copy). Once the window has passed the tombstone
46
+ * is purged and the slug is simply gone (404). Returns "purged" when the caller should
47
+ * answer 404, a tombstone payload when it should answer 410, or "live" for a live row.
48
+ *
49
+ * Purging drops the store's bundle row and, for every purged record, the S3 object
50
+ * behind it — the same getSkillBundle-then-delete dance the publish/delete paths use,
51
+ * so an object whose digest another row still references survives.
52
+ */
53
+ export declare function tombstoneStatus(store: SkillsProductStore, artifactStorage: ArtifactStorage, principal: ApiPrincipal, record: ServerSkillRecord): Promise<"live" | "purged" | Record<string, unknown>>;
14
54
  /**
15
55
  * Bundled corpus plus this org's published skills, published winning on a slug collision.
16
56
  *
@@ -19,8 +59,66 @@ export declare function publishedPayload(record: ServerSkillRecord): Record<stri
19
59
  * bundled `deploy-notes` there.
20
60
  */
21
61
  export declare function listMergedSkills(store: SkillsProductStore, principal: ApiPrincipal): Promise<Record<string, unknown>[]>;
22
- export declare function getMergedSkill(store: SkillsProductStore, principal: ApiPrincipal, slug: string): Promise<Record<string, unknown> | null>;
23
- export declare function getMergedSkillMd(store: SkillsProductStore, principal: ApiPrincipal, slug: string): Promise<string | null>;
62
+ /**
63
+ * What a read of one slug resolves to: the org's published row (live or tombstoned) or
64
+ * nothing. The bundled-corpus fallback is the callers' decision, not this resolver's —
65
+ * a tombstoned slug must answer 410 even when a bundled skill of the same name exists,
66
+ * because the caller asked for the skill this instance serves under that slug.
67
+ */
68
+ export type SkillReadResolution = {
69
+ kind: "published";
70
+ record: ServerSkillRecord;
71
+ } | {
72
+ kind: "tombstone";
73
+ payload: Record<string, unknown>;
74
+ } | {
75
+ kind: "absent";
76
+ };
77
+ export declare function resolvePublishedSkill(store: SkillsProductStore, artifactStorage: ArtifactStorage, principal: ApiPrincipal, slug: string): Promise<SkillReadResolution>;
78
+ /**
79
+ * Distinct tags across the org's registry view: the org's published tags (the
80
+ * indexed store query over the skills_tags projection, in both backends) plus
81
+ * the bundled corpus's, which is a fixed in-process set. The route serves the
82
+ * same universe GET /api/v1/skills serves, so a client can take any tag this
83
+ * list returns and filter with it. Sorted, de-duplicated, and emptied of blank
84
+ * entries to match the client contract (non-empty tag names).
85
+ */
86
+ export declare function listOrgTags(store: SkillsProductStore, principal: ApiPrincipal): Promise<string[]>;
87
+ /**
88
+ * The merged registry view (bundled + published) filtered to skills carrying an
89
+ * exact tag. Same merge rules and payloads as listMergedSkills - the tag filter
90
+ * narrows what GET /api/v1/skills would have returned, it does not switch to a
91
+ * different universe. The published half comes from the indexed store query;
92
+ * only the static bundled corpus is filtered in-process, and a bundled skill
93
+ * whose slug a published row occupies is excluded so published-wins precedence
94
+ * holds for tag reads exactly as it does for the unfiltered view.
95
+ */
96
+ export declare function listMergedSkillsByTag(store: SkillsProductStore, principal: ApiPrincipal, tag: string): Promise<Record<string, unknown>[]>;
97
+ /**
98
+ * The minimal per-skill wire row the tag/sync summary routes serve: the client's
99
+ * RemoteSkillSummary contract ({ slug, name?, version?, updatedAt? }).
100
+ */
101
+ export declare function skillSummary(skill: Record<string, unknown>): Record<string, unknown>;
102
+ /**
103
+ * The principal's pins filtered to slugs whose skill (bundled or published, in
104
+ * this org's merged view) carries the exact tag. A pin carries no tag of its
105
+ * own - the filter resolves each pinned slug against the registry view, the
106
+ * same way the rest of the surface treats a pin as a fact about a skill.
107
+ *
108
+ * Published pins come from the indexed store query (skills_tags projection);
109
+ * the bundled-corpus half is resolved in-process against the static corpus and
110
+ * excludes slugs a published row occupies, so every pinned slug resolves
111
+ * through exactly one path (published wins) and no pin can appear twice.
112
+ */
113
+ export declare function listPinsByTag(store: SkillsProductStore, principal: ApiPrincipal, tag: string): Promise<Record<string, unknown>[]>;
114
+ export declare function getMergedSkill(store: SkillsProductStore, artifactStorage: ArtifactStorage, principal: ApiPrincipal, slug: string): Promise<Record<string, unknown> | null>;
115
+ /**
116
+ * The SKILL.md document for one slug, or null when nothing is served under it.
117
+ *
118
+ * A tombstoned slug returns null here; the caller that needs the 410 marker resolves
119
+ * the tombstone itself via resolvePublishedSkill before calling.
120
+ */
121
+ export declare function getMergedSkillMd(store: SkillsProductStore, artifactStorage: ArtifactStorage, principal: ApiPrincipal, slug: string): Promise<string | null>;
24
122
  interface ParsedPublish {
25
123
  input: Omit<PublishSkillInput, "principal">;
26
124
  bundleBytes?: OwnedBytes;
@@ -45,11 +143,15 @@ interface ParsedPublish {
45
143
  * measured.
46
144
  */
47
145
  export declare function parsePublishRequest(request: Request, config: SkillsServerConfig): Promise<ParsedPublish>;
48
- export declare function storePublishedSkill(store: SkillsProductStore, artifactStorage: ArtifactStorage, principal: ApiPrincipal, parsed: ParsedPublish): Promise<ServerSkillRecord>;
146
+ export declare function storePublishedSkill(store: SkillsProductStore, artifactStorage: ArtifactStorage, principal: ApiPrincipal, parsed: ParsedPublish, expectedRevisionId?: string): Promise<ServerSkillRecord>;
49
147
  /**
50
- * Delete a published skill, and the stored object behind it when nothing else needs it.
148
+ * Tombstone a published skill (todos d061fcda): the row survives with a tombstone
149
+ * marker for `tombstoneWindowMs`, so reads answer 410 and a pulling client can
150
+ * reconcile, and the bundle is retained until the purge. The stored object is NOT
151
+ * discarded here — the tombstoned row still references it; the purge discards it.
152
+ * Returns the tombstoned record, or null when the org has no row by that slug.
51
153
  */
52
- export declare function deletePublishedSkill(store: SkillsProductStore, artifactStorage: ArtifactStorage, principal: ApiPrincipal, slug: string): Promise<boolean>;
154
+ export declare function deletePublishedSkill(store: SkillsProductStore, artifactStorage: ArtifactStorage, principal: ApiPrincipal, slug: string, tombstoneWindowMs: number): Promise<ServerSkillRecord | null>;
53
155
  /**
54
156
  * Read a published bundle back, refusing to serve bytes that no longer hash to the digest
55
157
  * they were stored under.
@@ -13,7 +13,7 @@
13
13
  * requirement; changing Postgres behaviour is not this module's job.
14
14
  */
15
15
  import { Database } from "bun:sqlite";
16
- import type { ApiPrincipal, ClaimRunInput, CreateRunInput, PublishSkillInput, RunTransitionPatch, ServerArtifact, ServerRunLog, ServerRunRecord, ServerSkillBundle, ServerSkillRecord, SkillsProductStore, StoreBackendInfo, UpdateSkillPatch } from "./types.js";
16
+ import type { ApiPrincipal, ClaimRunInput, CreateRunInput, PublishSkillInput, RunTransitionPatch, ServerArtifact, ServerPin, ServerRunLog, ServerRunRecord, ServerSkillBundle, ServerSkillRecord, SkillsProductStore, StoreBackendInfo, UpdateSkillPatch } from "./types.js";
17
17
  export interface SqliteStoreOptions {
18
18
  /** Apply pending migrations on open. Default true - it is what makes zero-config work. */
19
19
  migrate?: boolean;
@@ -34,6 +34,15 @@ export declare class SqliteSkillsStore implements SkillsProductStore {
34
34
  private db;
35
35
  private closed;
36
36
  constructor(path?: string, options?: SqliteStoreOptions);
37
+ /**
38
+ * Give rows written before migration 0004 a real content revision id.
39
+ *
40
+ * The migration adds revision_id with DEFAULT '', which would make If-Match vacuous
41
+ * for legacy rows (every stale client matches the same empty string). This replaces
42
+ * the marker with a content sha, idempotently: new code always writes a full id, so
43
+ * the marker never reappears. Mirrors PostgresSkillsStore.backfillLegacyRevisions.
44
+ */
45
+ private backfillLegacyRevisions;
37
46
  /** Escape hatch for tests and for tooling that needs raw SQL against the same handle. */
38
47
  get database(): Database;
39
48
  close(): Promise<void>;
@@ -89,9 +98,17 @@ export declare class SqliteSkillsStore implements SkillsProductStore {
89
98
  publishSkill(input: PublishSkillInput): Promise<ServerSkillRecord>;
90
99
  listSkills(principal: ApiPrincipal): Promise<ServerSkillRecord[]>;
91
100
  getSkill(principal: ApiPrincipal, slug: string): Promise<ServerSkillRecord | null>;
92
- updateSkill(principal: ApiPrincipal, slug: string, patch: UpdateSkillPatch): Promise<ServerSkillRecord | null>;
93
- deleteSkill(principal: ApiPrincipal, slug: string): Promise<boolean>;
101
+ updateSkill(principal: ApiPrincipal, slug: string, patch: UpdateSkillPatch, expectedRevisionId?: string): Promise<ServerSkillRecord | null>;
102
+ deleteSkill(principal: ApiPrincipal, slug: string, tombstoneWindowMs: number): Promise<ServerSkillRecord | null>;
103
+ purgeExpiredTombstones(principal: ApiPrincipal): Promise<ServerSkillRecord[]>;
94
104
  getSkillBundle(principal: ApiPrincipal, sha256: string): Promise<ServerSkillBundle | null>;
105
+ pinSkill(principal: ApiPrincipal, slug: string, metadata?: Record<string, unknown>): Promise<ServerPin>;
106
+ unpinSkill(principal: ApiPrincipal, slug: string): Promise<boolean>;
107
+ listPins(principal: ApiPrincipal): Promise<ServerPin[]>;
108
+ listTags(principal: ApiPrincipal): Promise<string[]>;
109
+ listSkillsByTag(principal: ApiPrincipal, tag: string): Promise<ServerSkillRecord[]>;
110
+ listPinsByTag(principal: ApiPrincipal, tag: string): Promise<ServerPin[]>;
111
+ listPublishedSlugs(principal: ApiPrincipal): Promise<string[]>;
95
112
  /**
96
113
  * Drop a bundle no remaining skill in the org points at.
97
114
  *
@@ -1,4 +1,4 @@
1
- import type { ApiPrincipal, ClaimRunInput, CreateRunInput, PublishSkillInput, RunTransitionPatch, ServerArtifact, ServerRunLog, ServerRunRecord, ServerSkillBundle, ServerSkillRecord, SkillsProductStore, StoreBackendInfo, UpdateSkillPatch } from "./types.js";
1
+ import type { ApiPrincipal, ClaimRunInput, CreateRunInput, PublishSkillInput, RunTransitionPatch, ServerArtifact, ServerPin, ServerRunLog, ServerRunRecord, ServerSkillBundle, ServerSkillRecord, SkillsProductStore, StoreBackendInfo, UpdateSkillPatch } from "./types.js";
2
2
  import { type SqliteStoreOptions } from "./sqlite-store.js";
3
3
  export declare function createArtifactId(): string;
4
4
  export interface StoreOptions {
@@ -31,6 +31,7 @@ export declare class MemorySkillsStore implements SkillsProductStore {
31
31
  private idempotency;
32
32
  private skills;
33
33
  private bundles;
34
+ private pins;
34
35
  constructor(apiKeys?: Array<{
35
36
  token: string;
36
37
  principal?: Partial<ApiPrincipal>;
@@ -68,9 +69,17 @@ export declare class MemorySkillsStore implements SkillsProductStore {
68
69
  publishSkill(input: PublishSkillInput): Promise<ServerSkillRecord>;
69
70
  listSkills(principal: ApiPrincipal): Promise<ServerSkillRecord[]>;
70
71
  getSkill(principal: ApiPrincipal, slug: string): Promise<ServerSkillRecord | null>;
71
- updateSkill(principal: ApiPrincipal, slug: string, patch: UpdateSkillPatch): Promise<ServerSkillRecord | null>;
72
- deleteSkill(principal: ApiPrincipal, slug: string): Promise<boolean>;
72
+ updateSkill(principal: ApiPrincipal, slug: string, patch: UpdateSkillPatch, expectedRevisionId?: string): Promise<ServerSkillRecord | null>;
73
+ deleteSkill(principal: ApiPrincipal, slug: string, tombstoneWindowMs: number): Promise<ServerSkillRecord | null>;
74
+ purgeExpiredTombstones(principal: ApiPrincipal): Promise<ServerSkillRecord[]>;
73
75
  getSkillBundle(principal: ApiPrincipal, sha256: string): Promise<ServerSkillBundle | null>;
76
+ pinSkill(principal: ApiPrincipal, slug: string, metadata?: Record<string, unknown>): Promise<ServerPin>;
77
+ unpinSkill(principal: ApiPrincipal, slug: string): Promise<boolean>;
78
+ listPins(principal: ApiPrincipal): Promise<ServerPin[]>;
79
+ listTags(principal: ApiPrincipal): Promise<string[]>;
80
+ listSkillsByTag(principal: ApiPrincipal, tag: string): Promise<ServerSkillRecord[]>;
81
+ listPinsByTag(principal: ApiPrincipal, tag: string): Promise<ServerPin[]>;
82
+ listPublishedSlugs(principal: ApiPrincipal): Promise<string[]>;
74
83
  private collectOrphanBundle;
75
84
  private patchRun;
76
85
  }
@@ -88,6 +97,15 @@ export declare class PostgresSkillsStore implements SkillsProductStore {
88
97
  * own messages sometimes echo it, and this string ends up in logs.
89
98
  */
90
99
  verifyConnectivity(): Promise<void>;
100
+ /**
101
+ * Give rows written before migration 0004 a real content revision id.
102
+ *
103
+ * The migration adds revision_id with DEFAULT '', which would make If-Match vacuous
104
+ * for legacy rows (every stale client matches the same empty string). This replaces
105
+ * the marker with a content sha, idempotently: new code always writes a full id, so
106
+ * the marker never reappears and the sweep costs one index scan on later opens.
107
+ */
108
+ private backfillLegacyRevisions;
91
109
  close(): Promise<void>;
92
110
  ensureBootstrapApiKey(token: string, principal?: Partial<ApiPrincipal>): Promise<void>;
93
111
  authenticateApiKeyHash(hash: string): Promise<ApiPrincipal | null>;
@@ -136,9 +154,17 @@ export declare class PostgresSkillsStore implements SkillsProductStore {
136
154
  publishSkill(input: PublishSkillInput): Promise<ServerSkillRecord>;
137
155
  listSkills(principal: ApiPrincipal): Promise<ServerSkillRecord[]>;
138
156
  getSkill(principal: ApiPrincipal, slug: string): Promise<ServerSkillRecord | null>;
139
- updateSkill(principal: ApiPrincipal, slug: string, patch: UpdateSkillPatch): Promise<ServerSkillRecord | null>;
140
- deleteSkill(principal: ApiPrincipal, slug: string): Promise<boolean>;
157
+ updateSkill(principal: ApiPrincipal, slug: string, patch: UpdateSkillPatch, expectedRevisionId?: string): Promise<ServerSkillRecord | null>;
158
+ deleteSkill(principal: ApiPrincipal, slug: string, tombstoneWindowMs: number): Promise<ServerSkillRecord | null>;
159
+ purgeExpiredTombstones(principal: ApiPrincipal): Promise<ServerSkillRecord[]>;
141
160
  getSkillBundle(principal: ApiPrincipal, sha256: string): Promise<ServerSkillBundle | null>;
161
+ pinSkill(principal: ApiPrincipal, slug: string, metadata?: Record<string, unknown>): Promise<ServerPin>;
162
+ unpinSkill(principal: ApiPrincipal, slug: string): Promise<boolean>;
163
+ listPins(principal: ApiPrincipal): Promise<ServerPin[]>;
164
+ listTags(principal: ApiPrincipal): Promise<string[]>;
165
+ listSkillsByTag(principal: ApiPrincipal, tag: string): Promise<ServerSkillRecord[]>;
166
+ listPinsByTag(principal: ApiPrincipal, tag: string): Promise<ServerPin[]>;
167
+ listPublishedSlugs(principal: ApiPrincipal): Promise<string[]>;
142
168
  /** Drop a bundle no remaining skill in the org points at. See the SQLite twin. */
143
169
  private collectOrphanBundle;
144
170
  }
@@ -15,6 +15,22 @@ export declare class StaleLeaseGenerationError extends Error {
15
15
  readonly status: ServerRunStatus;
16
16
  constructor(runId: string, expectedGeneration: number, currentGeneration: number, status: ServerRunStatus);
17
17
  }
18
+ /**
19
+ * A publish or update was refused because it would silently overwrite a newer revision.
20
+ *
21
+ * The optimistic-concurrency guard (todos d061fcda): a write to an existing, live row
22
+ * must carry If-Match naming the current revision_id (the ETag the server issued). A
23
+ * missing guard, or one naming a different revision, is refused with this error instead
24
+ * of being applied. `currentRevisionId` is null when the row is absent or tombstoned —
25
+ * those are the two cases where the guard does not apply — and carries the live
26
+ * revision otherwise, so the caller can name what the writer was racing against.
27
+ */
28
+ export declare class SkillRevisionConflictError extends Error {
29
+ readonly slug: string;
30
+ readonly expectedRevisionId: string | null | undefined;
31
+ readonly currentRevisionId: string | null;
32
+ constructor(slug: string, expectedRevisionId: string | null | undefined, currentRevisionId: string | null);
33
+ }
18
34
  export interface ApiPrincipal {
19
35
  apiKeyId: string;
20
36
  orgId: string;
@@ -112,6 +128,26 @@ export interface ServerSkillRecord {
112
128
  publishedByUserId?: string;
113
129
  createdAt: string;
114
130
  updatedAt: string;
131
+ /**
132
+ * Immutable content-addressed revision identity (sha-256 over the published content,
133
+ * computed in server/revision.ts). Same content -> same id; any content change mints
134
+ * a new one. This is the ETag every read issues and every guarded write must match.
135
+ */
136
+ revisionId: string;
137
+ /**
138
+ * Monotonic per-slug write counter. Every publish and every metadata update bumps it,
139
+ * even when the content hash is unchanged, so "how many writes happened to this slug"
140
+ * is a number, not a digest comparison.
141
+ */
142
+ revisionNumber: number;
143
+ /**
144
+ * Present when the slug was deleted within the tombstone window (todos d061fcda).
145
+ * Reads must answer 410 with the marker so a client's pull can reconcile; the row and
146
+ * its bundle are purged once tombstonePurgeAfter passes. A re-publish clears both
147
+ * fields and revives the slug as a fresh revision.
148
+ */
149
+ tombstonedAt?: string;
150
+ tombstonePurgeAfter?: string;
115
151
  }
116
152
  /**
117
153
  * Bundle bytes, addressed by their own digest.
@@ -121,6 +157,21 @@ export interface ServerSkillRecord {
121
157
  * split ServerArtifact makes between `bodyText` and `storageKey`, except that a bundle is
122
158
  * a gzipped tar and so cannot be a string at any layer.
123
159
  */
160
+ /**
161
+ * A skill the principal pinned on the hosted instance.
162
+ *
163
+ * The cloud-side twin of the local `.skills/project.json` pin: metadata-only,
164
+ * never content. `principal` is the api_keys.id of the API key that pinned
165
+ * (ApiPrincipal.apiKeyId) - a pin is a fact about a specific principal's
166
+ * selection, and two API keys in one org each have their own pin set.
167
+ */
168
+ export interface ServerPin {
169
+ orgId: string;
170
+ principal: string;
171
+ slug: string;
172
+ pinnedAt: string;
173
+ metadata: Record<string, unknown>;
174
+ }
124
175
  export interface ServerSkillBundle {
125
176
  orgId: string;
126
177
  sha256: string;
@@ -144,6 +195,15 @@ export interface PublishSkillInput {
144
195
  skillMd?: string;
145
196
  /** Omitted for a metadata-only publish or update. */
146
197
  bundle?: Omit<ServerSkillBundle, "orgId" | "createdAt">;
198
+ /**
199
+ * Optimistic-concurrency guard (todos d061fcda): the revision_id (ETag) the writer
200
+ * read. A publish against an existing, LIVE row requires the guard to name that row's
201
+ * current revision_id; missing or mismatched is a SkillRevisionConflictError, never a
202
+ * silent overwrite. The guard is not required for a first publish (no row exists) or
203
+ * against a tombstoned row (nothing live to overwrite - the publish revives the slug
204
+ * as a fresh revision).
205
+ */
206
+ expectedRevisionId?: string;
147
207
  }
148
208
  /** Metadata-only patch. Never touches the bundle; republish to change bytes. */
149
209
  export type UpdateSkillPatch = Partial<Pick<ServerSkillRecord, "displayName" | "description" | "category" | "tags" | "version" | "kind" | "skillMd">>;
@@ -235,8 +295,43 @@ export interface SkillsProductStore {
235
295
  publishSkill(input: PublishSkillInput): Promise<ServerSkillRecord>;
236
296
  listSkills(principal: ApiPrincipal): Promise<ServerSkillRecord[]>;
237
297
  getSkill(principal: ApiPrincipal, slug: string): Promise<ServerSkillRecord | null>;
238
- updateSkill(principal: ApiPrincipal, slug: string, patch: UpdateSkillPatch): Promise<ServerSkillRecord | null>;
239
- /** False when the org has no skill by that slug. Also drops a bundle nothing else references. */
240
- deleteSkill(principal: ApiPrincipal, slug: string): Promise<boolean>;
298
+ /**
299
+ * Metadata-only patch, guarded by the same optimistic concurrency as publish: the
300
+ * row's current revision_id must match `expectedRevisionId` or
301
+ * SkillRevisionConflictError is thrown (409), never a silent overwrite. The revision
302
+ * advances on every successful update (new content sha, number + 1). Returns null
303
+ * when the org has no skill by that slug, or when the row is tombstoned.
304
+ */
305
+ updateSkill(principal: ApiPrincipal, slug: string, patch: UpdateSkillPatch, expectedRevisionId?: string): Promise<ServerSkillRecord | null>;
306
+ /**
307
+ * Tombstone a skill instead of hard-deleting it (todos d061fcda): the row is stamped
308
+ * tombstoned_at + tombstone_purge_after (now + tombstoneWindowMs) and kept, so reads
309
+ * can answer 410 with the marker and a pulling client can reconcile, and the bundle
310
+ * survives until the purge. Returns the tombstoned record, or null when the org has
311
+ * no row by that slug. A second delete of an already-tombstoned slug returns the
312
+ * existing tombstone (idempotent; the window is not extended).
313
+ */
314
+ deleteSkill(principal: ApiPrincipal, slug: string, tombstoneWindowMs: number): Promise<ServerSkillRecord | null>;
315
+ /**
316
+ * Drop every tombstoned row whose window has expired, collecting the bundles nothing
317
+ * (live or tombstoned) references. Called on the read paths and by listSkills so the
318
+ * purge is lazy but does not wait for a slug to be read. Returns the purged records so
319
+ * the caller can discard their stored objects (S3).
320
+ */
321
+ purgeExpiredTombstones(principal: ApiPrincipal): Promise<ServerSkillRecord[]>;
241
322
  getSkillBundle(principal: ApiPrincipal, sha256: string): Promise<ServerSkillBundle | null>;
323
+ pinSkill(principal: ApiPrincipal, slug: string, metadata?: Record<string, unknown>): Promise<ServerPin>;
324
+ /** False when this principal has no pin by that slug. */
325
+ unpinSkill(principal: ApiPrincipal, slug: string): Promise<boolean>;
326
+ listPins(principal: ApiPrincipal): Promise<ServerPin[]>;
327
+ listTags(principal: ApiPrincipal): Promise<string[]>;
328
+ listSkillsByTag(principal: ApiPrincipal, tag: string): Promise<ServerSkillRecord[]>;
329
+ listPinsByTag(principal: ApiPrincipal, tag: string): Promise<ServerPin[]>;
330
+ /**
331
+ * The slugs of the org's live (non-tombstoned) published skills. The tag
332
+ * routes use this to resolve merged-view precedence: a bundled skill whose
333
+ * slug a published row occupies must not resurface under a tag filter or in
334
+ * the tag list. A single-column scan of the (org_id, slug) primary key.
335
+ */
336
+ listPublishedSlugs(principal: ApiPrincipal): Promise<string[]>;
242
337
  }
package/dist/storage.js CHANGED
@@ -162,6 +162,11 @@ function normalizeConfigValue(key, value) {
162
162
  }
163
163
  var DATA_DIR_ENV = "HASNA_SKILLS_DIR";
164
164
  var INSTALLED_SKILLS_DIRNAME = "installed";
165
+ var SKILLS_CACHE_DIRNAME = "skills";
166
+ var LAYOUT_MIGRATION_RECORD = ".layout-migration.json";
167
+ function isOwnerLayoutMigrated(appDir) {
168
+ return existsSync(join(appDir, SKILLS_CACHE_DIRNAME, LAYOUT_MIGRATION_RECORD));
169
+ }
165
170
  function getDataDir() {
166
171
  const override = process.env[DATA_DIR_ENV];
167
172
  if (override) {
@@ -498,7 +503,7 @@ function getSkillsNativeStorageStatus(options = {}) {
498
503
  const s3BucketEnv = readStorageEnv(env, "s3Bucket");
499
504
  const targetDir = options.targetDir ?? process.cwd();
500
505
  return {
501
- package: "open-skills",
506
+ package: "skills",
502
507
  tables: [...SKILLS_STORAGE_TABLES],
503
508
  env: {
504
509
  databaseUrl: SKILLS_NATIVE_STORAGE_ENV.databaseUrl,
@@ -670,7 +675,7 @@ function createSkillsSnapshotSyncRecord(snapshot, options = {}) {
670
675
  kind: "local-snapshot",
671
676
  id: options.id ?? "project-state",
672
677
  updatedAt: snapshot.exportedAt,
673
- source: options.source ?? "open-skills",
678
+ source: options.source ?? "skills",
674
679
  payload: snapshot
675
680
  };
676
681
  }
@@ -0,0 +1,28 @@
1
+ -- Hosted pins: the cloud becomes a source of truth for what the user selected.
2
+ --
3
+ -- Today a pin lives only in local project state (.skills/project.json); an
4
+ -- instance has no pins surface, so nothing a user selected is ever visible to
5
+ -- the server, to a second machine, or to another operator of the same org.
6
+ -- This table is that surface.
7
+ --
8
+ -- Row identity is (org_id, principal, slug): one org, one API key, one slug -
9
+ -- a pin is a fact about a specific principal's selection. `principal` stores
10
+ -- the api_keys.id of the API key that pinned (ApiPrincipal.apiKeyId), so two
11
+ -- API keys in the same org each have their own pin set, and two organizations
12
+ -- can pin the same slug without colliding. The task's UNIQUE(org,principal,slug)
13
+ -- is expressed as the composite primary key, which implies the same uniqueness
14
+ -- and is the row's natural identity - there is no separate id to mint.
15
+ --
16
+ -- Pins are metadata-only: slug plus a free-form metadata object. Nothing here
17
+ -- references skills_registry, so a pin may name a skill the org has not
18
+ -- published (a bundled skill, or one that will exist later) without a
19
+ -- foreign-key constraint choosing which of those futures is allowed.
20
+
21
+ CREATE TABLE IF NOT EXISTS skills_pins (
22
+ org_id text NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
23
+ principal text NOT NULL,
24
+ slug text NOT NULL,
25
+ pinned_at timestamptz NOT NULL DEFAULT now(),
26
+ metadata_json jsonb NOT NULL DEFAULT '{}'::jsonb,
27
+ PRIMARY KEY (org_id, principal, slug)
28
+ );
@@ -0,0 +1,37 @@
1
+ -- Revision identity, optimistic concurrency, and the tombstone contract for the hosted
2
+ -- registry (todos d061fcda, plan 8022d27f).
3
+ --
4
+ -- Three behaviours this migration makes possible, all in one table because they are
5
+ -- three properties of the same row:
6
+ --
7
+ -- 1. IMMUTABLE REVISION IDENTITY. revision_id is a sha-256 over the row's published
8
+ -- content (computed in TypeScript, src/lib/revision.ts) and revision_number is a
9
+ -- monotonic per-slug write counter. Together they let a client prove WHICH revision
10
+ -- it installed and let the server refuse a stale overwrite.
11
+ --
12
+ -- 2. OPTIMISTIC CONCURRENCY. publish/update require an If-Match carrying the current
13
+ -- revision_id; a mismatch (or a missing guard against an existing row) returns 409
14
+ -- instead of silently overwriting a newer revision. The guard is enforced in the
15
+ -- store's SQL (WHERE ... revision_id = <expected> on the upsert), never in
16
+ -- read-then-write JS, so two concurrent writers cannot both pass a stale check.
17
+ --
18
+ -- 3. TOMBSTONES. delete no longer drops the row: it stamps tombstoned_at +
19
+ -- tombstone_purge_after (delete time + the configured window). Reads within the
20
+ -- window answer 410 with the tombstone marker so a client's pull can reconcile
21
+ -- (remove the local copy); after the window a read or list purges the row and its
22
+ -- bundle. Re-publish over a tombstoned slug revives it as a fresh revision.
23
+ --
24
+ -- The columns are added by ALTER rather than by the 0002 drop-and-recreate pattern:
25
+ -- the registry can hold tenant rows now (publishing shipped in the 0002 change), so a
26
+ -- rebuild would be data loss. Rows that predate this migration get revision_id '' and
27
+ -- revision_number 0; the store backfills a content sha for them on first open, because
28
+ -- an empty revision id would make If-Match vacuous for legacy rows (every stale client
29
+ -- would "match" the same empty string and two concurrent writers could both land).
30
+ --
31
+ -- Hand-written parallel of migrations/sqlite/0005_revision_tombstone_registry.sql; the
32
+ -- dialect differences are the same documented set as 0001-0003 (timestamptz -> text).
33
+
34
+ ALTER TABLE skills_registry ADD COLUMN revision_id text NOT NULL DEFAULT '';
35
+ ALTER TABLE skills_registry ADD COLUMN revision_number integer NOT NULL DEFAULT 0;
36
+ ALTER TABLE skills_registry ADD COLUMN tombstoned_at timestamptz;
37
+ ALTER TABLE skills_registry ADD COLUMN tombstone_purge_after timestamptz;
@@ -0,0 +1,36 @@
1
+ -- The hosted tag surface (T7) queries tag membership from this relational
2
+ -- projection of skills_registry.tags_json, in both dialects with one identical
3
+ -- shape. SQLite cannot index json_each() over the JSON column, and Postgres
4
+ -- rows written by the current path hold the array text inside a jsonb scalar
5
+ -- (bun's driver binds a JS string against a ::jsonb cast as a JSON string), so
6
+ -- neither dialect can serve an indexed array-expansion query directly. The
7
+ -- projection turns tag membership into a plain indexed lookup: the PRIMARY KEY
8
+ -- serves (org_id, slug) maintenance and the org_tag index serves (org_id, tag)
9
+ -- filters. The write paths keep it in step; the backfill below covers rows
10
+ -- that predate it.
11
+ -- No composite FK to skills_registry: the schema-parity parser cannot
12
+ -- represent a multi-column FK identically in both dialects, and every write
13
+ -- path here maintains the projection explicitly (publish/update replace its
14
+ -- rows, the tombstone purge removes them with the row), so the constraint
15
+ -- would be redundancy.
16
+ CREATE TABLE skills_tags (
17
+ org_id text NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
18
+ slug text NOT NULL,
19
+ tag text NOT NULL,
20
+ PRIMARY KEY (org_id, slug, tag)
21
+ );
22
+
23
+ CREATE INDEX skills_tags_org_tag_idx ON skills_tags (org_id, tag);
24
+
25
+ -- Backfill every existing row's tags. tags_json may be a real array or,
26
+ -- because of the jsonb-scalar write path, the array text inside a jsonb
27
+ -- string; both shapes are expanded here.
28
+ INSERT INTO skills_tags (org_id, slug, tag)
29
+ SELECT DISTINCT r.org_id, r.slug, t.tag
30
+ FROM skills_registry r
31
+ CROSS JOIN LATERAL jsonb_array_elements_text(
32
+ CASE WHEN jsonb_typeof(r.tags_json) = 'array' THEN r.tags_json
33
+ WHEN jsonb_typeof(r.tags_json) = 'string' THEN (r.tags_json #>> '{}')::jsonb
34
+ ELSE '[]'::jsonb END
35
+ ) AS t(tag)
36
+ WHERE t.tag <> '';
@@ -0,0 +1,21 @@
1
+ -- Hosted pins: the cloud becomes a source of truth for what the user selected.
2
+ --
3
+ -- SQLite dialect of 0004_hosted_pins. Read the Postgres file for the why; the
4
+ -- only differences here are the documented dialect mapping:
5
+ -- * timestamptz -> text holding a UTC ISO-8601 instant (pinned_at).
6
+ -- * jsonb -> text holding JSON (metadata_json).
7
+ --
8
+ -- No RLS: the tenant fence on this table is the org-scoped predicate in the
9
+ -- store, exactly as it is for skills_registry and skills_bundles. RLS was
10
+ -- armed in 0003 only for the run-output tables, where a worker path writes
11
+ -- outside a tenant context; pins are written and read only by the API under
12
+ -- the requesting principal's org, so there is no context-less writer to fence.
13
+
14
+ CREATE TABLE IF NOT EXISTS skills_pins (
15
+ org_id text NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
16
+ principal text NOT NULL,
17
+ slug text NOT NULL,
18
+ pinned_at text NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
19
+ metadata_json text NOT NULL DEFAULT '{}',
20
+ PRIMARY KEY (org_id, principal, slug)
21
+ );
@@ -0,0 +1,18 @@
1
+ -- SQLite dialect of 0005_revision_tombstone_registry.
2
+ --
3
+ -- Hand-written parallel of migrations/postgres/0005_revision_tombstone_registry.sql. Read
4
+ -- that file for why the registry gains revision_id/revision_number (immutable revision
5
+ -- identity), the optimistic-concurrency guard, and the tombstone contract; the rationale
6
+ -- is not repeated here, only the dialect differences are.
7
+ --
8
+ -- Deliberate, shape-preserving dialect differences (same set as 0001):
9
+ -- * timestamptz -> text holding a UTC ISO-8601 instant.
10
+ --
11
+ -- SQLite requires a non-null literal DEFAULT for a NOT NULL ADD COLUMN; the '' default is
12
+ -- the legacy-row marker the store's backfill (src/lib/revision.ts) replaces with a
13
+ -- content sha on first open, exactly as on Postgres.
14
+
15
+ ALTER TABLE skills_registry ADD COLUMN revision_id text NOT NULL DEFAULT '';
16
+ ALTER TABLE skills_registry ADD COLUMN revision_number integer NOT NULL DEFAULT 0;
17
+ ALTER TABLE skills_registry ADD COLUMN tombstoned_at text;
18
+ ALTER TABLE skills_registry ADD COLUMN tombstone_purge_after text;