@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.
- package/README.md +28 -6
- package/bin/index.js +1593 -228
- package/bin/mcp.js +290 -24
- package/bin/migrate.js +229 -33
- package/bin/server.js +774 -116
- package/bin/worker.js +558 -79
- package/dist/cli/commands/registry-reconcile.d.ts +2 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +559 -251
- package/dist/lib/agent-sync.d.ts +26 -6
- package/dist/lib/auth-store.d.ts +37 -0
- package/dist/lib/config.d.ts +51 -0
- package/dist/lib/home-census.d.ts +4 -0
- package/dist/lib/home-migration.d.ts +8 -9
- package/dist/lib/native-storage.d.ts +1 -1
- package/dist/lib/portable-skills.d.ts +35 -6
- package/dist/lib/pull.d.ts +31 -0
- package/dist/lib/registry-reconcile.d.ts +114 -0
- package/dist/lib/registry.d.ts +7 -4
- package/dist/lib/remote-client.d.ts +118 -1
- package/dist/lib/remote-registry.d.ts +26 -0
- package/dist/lib/revision.d.ts +29 -0
- package/dist/sdk/index.js +1351 -353
- package/dist/server/app.d.ts +1 -1
- package/dist/server/config.d.ts +12 -2
- package/dist/server/rows.d.ts +2 -1
- package/dist/server/skills-api.d.ts +108 -6
- package/dist/server/sqlite-store.d.ts +20 -3
- package/dist/server/store.d.ts +31 -5
- package/dist/server/types.d.ts +98 -3
- package/dist/storage.js +7 -2
- package/migrations/postgres/0004_hosted_pins.sql +28 -0
- package/migrations/postgres/0005_revision_tombstone_registry.sql +37 -0
- package/migrations/postgres/0005_tag_projection.sql +36 -0
- package/migrations/sqlite/0004_hosted_pins.sql +21 -0
- package/migrations/sqlite/0005_revision_tombstone_registry.sql +18 -0
- package/migrations/sqlite/0005_tag_projection.sql +25 -0
- package/package.json +3 -2
package/bin/server.js
CHANGED
|
@@ -22955,6 +22955,7 @@ var ANY_SEGMENT_EXCLUDES = new Set([
|
|
|
22955
22955
|
".docker"
|
|
22956
22956
|
]);
|
|
22957
22957
|
var ROOT_EXCLUDES = new Set(["dist", "build", ".turbo"]);
|
|
22958
|
+
var TOOL_SIDECAR_FILENAMES = new Set([".hasna-skills.json"]);
|
|
22958
22959
|
var CREDENTIAL_FILENAMES = new Set([
|
|
22959
22960
|
".npmrc",
|
|
22960
22961
|
".pypirc",
|
|
@@ -32951,9 +32952,10 @@ function resolveServerConfig(env = process.env) {
|
|
|
32951
32952
|
artifactBucket: env.HASNA_SKILLS_S3_BUCKET || env.SKILLS_S3_BUCKET || undefined,
|
|
32952
32953
|
artifactPrefix: normalizePrefix(env.HASNA_SKILLS_S3_PREFIX || env.SKILLS_S3_PREFIX || "skills/artifacts"),
|
|
32953
32954
|
inlineWorker: env.HASNA_SKILLS_INLINE_WORKER === "1",
|
|
32954
|
-
bundleSigningKey: env.HASNA_SKILLS_SIGNING_KEY || undefined,
|
|
32955
|
+
bundleSigningKey: env.HASNA_SKILLS_API_SIGNING_KEY || env.HASNA_SKILLS_SIGNING_KEY || undefined,
|
|
32955
32956
|
requestBodyLimitBytes: parsePositiveInt(env.HASNA_SKILLS_REQUEST_BODY_LIMIT_BYTES, 1e6),
|
|
32956
32957
|
skillBundleLimitBytes: parsePositiveInt(env.HASNA_SKILLS_BUNDLE_LIMIT_BYTES, 25000000),
|
|
32958
|
+
tombstoneWindowMs: parsePositiveInt(env.HASNA_SKILLS_TOMBSTONE_WINDOW_MS, 7 * 24 * 60 * 60 * 1000),
|
|
32957
32959
|
publicBaseUrl: (env.SKILLS_PUBLIC_BASE_URL || localOrigin(host, port)).replace(/\/+$/, ""),
|
|
32958
32960
|
nodeEnv,
|
|
32959
32961
|
allowEphemeralStore: env.HASNA_SKILLS_ALLOW_EPHEMERAL_STORE === "1"
|
|
@@ -33031,6 +33033,11 @@ function normalizeConfigValue(key, value) {
|
|
|
33031
33033
|
}
|
|
33032
33034
|
var DATA_DIR_ENV = "HASNA_SKILLS_DIR";
|
|
33033
33035
|
var INSTALLED_SKILLS_DIRNAME = "installed";
|
|
33036
|
+
var SKILLS_CACHE_DIRNAME = "skills";
|
|
33037
|
+
var LAYOUT_MIGRATION_RECORD = ".layout-migration.json";
|
|
33038
|
+
function isOwnerLayoutMigrated(appDir) {
|
|
33039
|
+
return existsSync(join2(appDir, SKILLS_CACHE_DIRNAME, LAYOUT_MIGRATION_RECORD));
|
|
33040
|
+
}
|
|
33034
33041
|
function getDataDir() {
|
|
33035
33042
|
const override = process.env[DATA_DIR_ENV];
|
|
33036
33043
|
if (override) {
|
|
@@ -33154,7 +33161,7 @@ function looksLikeSqlitePath(value) {
|
|
|
33154
33161
|
}
|
|
33155
33162
|
|
|
33156
33163
|
// src/server/handlers.ts
|
|
33157
|
-
import { createHash as
|
|
33164
|
+
import { createHash as createHash5 } from "crypto";
|
|
33158
33165
|
|
|
33159
33166
|
// src/server/store.ts
|
|
33160
33167
|
import { randomUUID as randomUUID3 } from "crypto";
|
|
@@ -33175,6 +33182,19 @@ class StaleLeaseGenerationError extends Error {
|
|
|
33175
33182
|
}
|
|
33176
33183
|
}
|
|
33177
33184
|
|
|
33185
|
+
class SkillRevisionConflictError extends Error {
|
|
33186
|
+
slug;
|
|
33187
|
+
expectedRevisionId;
|
|
33188
|
+
currentRevisionId;
|
|
33189
|
+
constructor(slug, expectedRevisionId, currentRevisionId) {
|
|
33190
|
+
super(`revision conflict for '${slug}': expected revision ${expectedRevisionId ?? "(none)"}, ` + `current is ${currentRevisionId ?? "(none)"}. Refused rather than silently overwriting a newer revision.`);
|
|
33191
|
+
this.name = "SkillRevisionConflictError";
|
|
33192
|
+
this.slug = slug;
|
|
33193
|
+
this.expectedRevisionId = expectedRevisionId;
|
|
33194
|
+
this.currentRevisionId = currentRevisionId;
|
|
33195
|
+
}
|
|
33196
|
+
}
|
|
33197
|
+
|
|
33178
33198
|
// src/server/rows.ts
|
|
33179
33199
|
import { randomUUID } from "crypto";
|
|
33180
33200
|
function nowIso() {
|
|
@@ -33255,7 +33275,20 @@ function rowToSkill(row) {
|
|
|
33255
33275
|
...row.bundle_byte_size === null || row.bundle_byte_size === undefined ? {} : { bundleByteSize: Number(row.bundle_byte_size) },
|
|
33256
33276
|
...typeof row.published_by_user_id === "string" ? { publishedByUserId: row.published_by_user_id } : {},
|
|
33257
33277
|
createdAt: dateString(row.created_at),
|
|
33258
|
-
updatedAt: dateString(row.updated_at)
|
|
33278
|
+
updatedAt: dateString(row.updated_at),
|
|
33279
|
+
revisionId: String(row.revision_id ?? ""),
|
|
33280
|
+
revisionNumber: Number(row.revision_number ?? 0),
|
|
33281
|
+
...row.tombstoned_at ? { tombstonedAt: dateString(row.tombstoned_at) } : {},
|
|
33282
|
+
...row.tombstone_purge_after ? { tombstonePurgeAfter: dateString(row.tombstone_purge_after) } : {}
|
|
33283
|
+
};
|
|
33284
|
+
}
|
|
33285
|
+
function rowToPin(row) {
|
|
33286
|
+
return {
|
|
33287
|
+
orgId: String(row.org_id),
|
|
33288
|
+
principal: String(row.principal),
|
|
33289
|
+
slug: String(row.slug),
|
|
33290
|
+
pinnedAt: dateString(row.pinned_at),
|
|
33291
|
+
metadata: parseJsonObject(row.metadata_json)
|
|
33259
33292
|
};
|
|
33260
33293
|
}
|
|
33261
33294
|
function rowToSkillBundle(row) {
|
|
@@ -33307,6 +33340,29 @@ function dateString(value) {
|
|
|
33307
33340
|
return String(value);
|
|
33308
33341
|
}
|
|
33309
33342
|
|
|
33343
|
+
// src/lib/revision.ts
|
|
33344
|
+
import { createHash as createHash4 } from "crypto";
|
|
33345
|
+
var REVISION_ID_PATTERN = /^[0-9a-f]{64}$/;
|
|
33346
|
+
function revisionIdOf(content) {
|
|
33347
|
+
const canonical = JSON.stringify({
|
|
33348
|
+
slug: content.slug,
|
|
33349
|
+
displayName: content.displayName,
|
|
33350
|
+
description: content.description,
|
|
33351
|
+
category: content.category,
|
|
33352
|
+
tags: content.tags,
|
|
33353
|
+
source: content.source,
|
|
33354
|
+
kind: content.kind,
|
|
33355
|
+
version: content.version ?? null,
|
|
33356
|
+
skillMd: content.skillMd ?? null,
|
|
33357
|
+
bundleSha256: content.bundleSha256 ?? null,
|
|
33358
|
+
bundleByteSize: content.bundleByteSize ?? null
|
|
33359
|
+
});
|
|
33360
|
+
return createHash4("sha256").update(canonical).digest("hex");
|
|
33361
|
+
}
|
|
33362
|
+
function revisionIdOfRecord(record) {
|
|
33363
|
+
return revisionIdOf(record);
|
|
33364
|
+
}
|
|
33365
|
+
|
|
33310
33366
|
// src/server/sqlite-store.ts
|
|
33311
33367
|
import { Database } from "bun:sqlite";
|
|
33312
33368
|
import { randomUUID as randomUUID2 } from "crypto";
|
|
@@ -33354,6 +33410,7 @@ function defaultStartDirs() {
|
|
|
33354
33410
|
// src/server/sqlite-store.ts
|
|
33355
33411
|
var CLAIM_ATTEMPTS = 8;
|
|
33356
33412
|
var CLAIMABLE_STATUSES = ["queued", "retrying"];
|
|
33413
|
+
var NO_REVISION_SENTINEL = "0000000000000000000000000000000000000000000000000000000000000000";
|
|
33357
33414
|
var LAST_USED_RESOLUTION_MS = 60000;
|
|
33358
33415
|
|
|
33359
33416
|
class SqliteSkillsStore {
|
|
@@ -33373,12 +33430,24 @@ class SqliteSkillsStore {
|
|
|
33373
33430
|
if (options.migrate !== false) {
|
|
33374
33431
|
applySqliteMigrations(this.db, options.migrationsDir);
|
|
33375
33432
|
}
|
|
33433
|
+
this.backfillLegacyRevisions();
|
|
33376
33434
|
this.backend = {
|
|
33377
33435
|
kind: "sqlite",
|
|
33378
33436
|
durable: !inMemory,
|
|
33379
33437
|
label: inMemory ? "sqlite (in-memory)" : `sqlite (${path})`
|
|
33380
33438
|
};
|
|
33381
33439
|
}
|
|
33440
|
+
backfillLegacyRevisions() {
|
|
33441
|
+
const rows = this.all("SELECT * FROM skills_registry WHERE revision_id = ''", []);
|
|
33442
|
+
for (const row of rows) {
|
|
33443
|
+
const record = rowToSkill(row);
|
|
33444
|
+
this.db.run("UPDATE skills_registry SET revision_id = ? WHERE org_id = ? AND slug = ?", [
|
|
33445
|
+
revisionIdOfRecord(record),
|
|
33446
|
+
record.orgId,
|
|
33447
|
+
record.slug
|
|
33448
|
+
]);
|
|
33449
|
+
}
|
|
33450
|
+
}
|
|
33382
33451
|
get database() {
|
|
33383
33452
|
return this.db;
|
|
33384
33453
|
}
|
|
@@ -33602,8 +33671,29 @@ class SqliteSkillsStore {
|
|
|
33602
33671
|
const orgId = input.principal.orgId;
|
|
33603
33672
|
const now = nowIso();
|
|
33604
33673
|
return this.db.transaction(() => {
|
|
33605
|
-
const previous = this.get("SELECT bundle_sha256 FROM skills_registry WHERE org_id = ? AND slug = ?", [orgId, input.slug]);
|
|
33674
|
+
const previous = this.get("SELECT revision_id, revision_number, bundle_sha256, bundle_byte_size, skill_md, tombstoned_at FROM skills_registry WHERE org_id = ? AND slug = ?", [orgId, input.slug]);
|
|
33606
33675
|
const previousSha = typeof previous?.bundle_sha256 === "string" ? previous.bundle_sha256 : null;
|
|
33676
|
+
const previousRevisionId = typeof previous?.revision_id === "string" && previous.revision_id ? previous.revision_id : null;
|
|
33677
|
+
const tombstoned = previous?.tombstoned_at != null;
|
|
33678
|
+
const carriedSkillMd = typeof input.skillMd === "string" ? input.skillMd : typeof previous?.skill_md === "string" ? previous.skill_md : null;
|
|
33679
|
+
if (previous && !tombstoned && input.expectedRevisionId !== previousRevisionId) {
|
|
33680
|
+
throw new SkillRevisionConflictError(input.slug, input.expectedRevisionId, previousRevisionId);
|
|
33681
|
+
}
|
|
33682
|
+
const carriedSha = input.bundle?.sha256 ?? previousSha;
|
|
33683
|
+
const carriedSize = input.bundle?.byteSize ?? (previous?.bundle_byte_size == null ? null : Number(previous.bundle_byte_size));
|
|
33684
|
+
const revisionId = revisionIdOfRecord({
|
|
33685
|
+
slug: input.slug,
|
|
33686
|
+
displayName: input.displayName,
|
|
33687
|
+
description: input.description,
|
|
33688
|
+
category: input.category,
|
|
33689
|
+
tags: input.tags,
|
|
33690
|
+
source: input.source,
|
|
33691
|
+
kind: input.kind,
|
|
33692
|
+
...input.version ? { version: input.version } : {},
|
|
33693
|
+
...carriedSkillMd ? { skillMd: carriedSkillMd } : {},
|
|
33694
|
+
...carriedSha ? { bundleSha256: carriedSha } : {},
|
|
33695
|
+
...carriedSize === null || carriedSize === undefined ? {} : { bundleByteSize: carriedSize }
|
|
33696
|
+
});
|
|
33607
33697
|
if (input.bundle) {
|
|
33608
33698
|
this.db.run(`INSERT INTO skills_bundles (org_id, sha256, byte_size, content_type, storage_kind, storage_key, body_blob, created_at)
|
|
33609
33699
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
@@ -33624,8 +33714,8 @@ class SqliteSkillsStore {
|
|
|
33624
33714
|
]);
|
|
33625
33715
|
}
|
|
33626
33716
|
const row = this.get(`INSERT INTO skills_registry (org_id, slug, display_name, description, category, tags_json, source, kind, version, skill_md,
|
|
33627
|
-
bundle_sha256, bundle_byte_size, published_by_user_id, created_at, updated_at)
|
|
33628
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
33717
|
+
bundle_sha256, bundle_byte_size, published_by_user_id, revision_id, revision_number, created_at, updated_at)
|
|
33718
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?)
|
|
33629
33719
|
ON CONFLICT (org_id, slug) DO UPDATE SET
|
|
33630
33720
|
display_name = excluded.display_name,
|
|
33631
33721
|
description = excluded.description,
|
|
@@ -33643,7 +33733,16 @@ class SqliteSkillsStore {
|
|
|
33643
33733
|
bundle_sha256 = COALESCE(excluded.bundle_sha256, skills_registry.bundle_sha256),
|
|
33644
33734
|
bundle_byte_size = COALESCE(excluded.bundle_byte_size, skills_registry.bundle_byte_size),
|
|
33645
33735
|
published_by_user_id = excluded.published_by_user_id,
|
|
33736
|
+
revision_id = excluded.revision_id,
|
|
33737
|
+
-- Current + 1, not the inserted 1: on the update path the ACTUAL row's
|
|
33738
|
+
-- counter is the truth (it may have advanced since the pre-read, which is
|
|
33739
|
+
-- exactly what the WHERE guard below detects).
|
|
33740
|
+
revision_number = skills_registry.revision_number + 1,
|
|
33741
|
+
tombstoned_at = NULL,
|
|
33742
|
+
tombstone_purge_after = NULL,
|
|
33646
33743
|
updated_at = excluded.updated_at
|
|
33744
|
+
WHERE skills_registry.tombstoned_at IS NOT NULL
|
|
33745
|
+
OR skills_registry.revision_id = ?
|
|
33647
33746
|
RETURNING *`, [
|
|
33648
33747
|
orgId,
|
|
33649
33748
|
input.slug,
|
|
@@ -33654,62 +33753,165 @@ class SqliteSkillsStore {
|
|
|
33654
33753
|
input.source,
|
|
33655
33754
|
input.kind,
|
|
33656
33755
|
input.version ?? null,
|
|
33657
|
-
|
|
33756
|
+
carriedSkillMd,
|
|
33658
33757
|
input.bundle?.sha256 ?? null,
|
|
33659
33758
|
input.bundle?.byteSize ?? null,
|
|
33660
33759
|
input.principal.userId,
|
|
33760
|
+
revisionId,
|
|
33661
33761
|
now,
|
|
33662
|
-
now
|
|
33762
|
+
now,
|
|
33763
|
+
input.expectedRevisionId ?? NO_REVISION_SENTINEL
|
|
33663
33764
|
]);
|
|
33765
|
+
if (!row) {
|
|
33766
|
+
const current = this.get("SELECT revision_id FROM skills_registry WHERE org_id = ? AND slug = ?", [orgId, input.slug]);
|
|
33767
|
+
const currentId = typeof current?.revision_id === "string" ? current.revision_id : null;
|
|
33768
|
+
throw new SkillRevisionConflictError(input.slug, input.expectedRevisionId, currentId);
|
|
33769
|
+
}
|
|
33664
33770
|
if (previousSha && input.bundle && previousSha !== input.bundle.sha256)
|
|
33665
33771
|
this.collectOrphanBundle(orgId, previousSha);
|
|
33772
|
+
this.db.run("DELETE FROM skills_tags WHERE org_id = ? AND slug = ?", [orgId, input.slug]);
|
|
33773
|
+
const insertTag = this.db.prepare("INSERT OR IGNORE INTO skills_tags (org_id, slug, tag) VALUES (?, ?, ?)");
|
|
33774
|
+
for (const tag of input.tags) {
|
|
33775
|
+
if (!tag.trim())
|
|
33776
|
+
continue;
|
|
33777
|
+
insertTag.run(orgId, input.slug, tag);
|
|
33778
|
+
}
|
|
33666
33779
|
return rowToSkill(row);
|
|
33667
33780
|
})();
|
|
33668
33781
|
}
|
|
33669
33782
|
async listSkills(principal) {
|
|
33670
|
-
|
|
33783
|
+
await this.purgeExpiredTombstones(principal);
|
|
33784
|
+
return this.all("SELECT * FROM skills_registry WHERE org_id = ? AND tombstoned_at IS NULL ORDER BY slug ASC", [principal.orgId]).map(rowToSkill);
|
|
33671
33785
|
}
|
|
33672
33786
|
async getSkill(principal, slug) {
|
|
33673
33787
|
const row = this.get("SELECT * FROM skills_registry WHERE org_id = ? AND slug = ? LIMIT 1", [principal.orgId, slug]);
|
|
33674
33788
|
return row ? rowToSkill(row) : null;
|
|
33675
33789
|
}
|
|
33676
|
-
async updateSkill(principal, slug, patch) {
|
|
33790
|
+
async updateSkill(principal, slug, patch, expectedRevisionId) {
|
|
33677
33791
|
const current = await this.getSkill(principal, slug);
|
|
33678
|
-
if (!current)
|
|
33792
|
+
if (!current || current.tombstonedAt)
|
|
33679
33793
|
return null;
|
|
33794
|
+
if (expectedRevisionId !== current.revisionId) {
|
|
33795
|
+
throw new SkillRevisionConflictError(slug, expectedRevisionId, current.revisionId);
|
|
33796
|
+
}
|
|
33680
33797
|
const next = { ...current, ...patch };
|
|
33681
|
-
|
|
33682
|
-
|
|
33683
|
-
|
|
33684
|
-
|
|
33685
|
-
|
|
33686
|
-
|
|
33687
|
-
|
|
33688
|
-
|
|
33689
|
-
|
|
33690
|
-
|
|
33691
|
-
|
|
33692
|
-
|
|
33693
|
-
|
|
33694
|
-
|
|
33695
|
-
|
|
33696
|
-
|
|
33798
|
+
return this.db.transaction(() => {
|
|
33799
|
+
const revisionId = revisionIdOfRecord(next);
|
|
33800
|
+
const row = this.get(`UPDATE skills_registry
|
|
33801
|
+
SET display_name = ?, description = ?, category = ?, tags_json = ?, kind = ?, version = ?, skill_md = ?,
|
|
33802
|
+
revision_id = ?, revision_number = revision_number + 1, updated_at = ?
|
|
33803
|
+
WHERE org_id = ? AND slug = ? AND tombstoned_at IS NULL AND revision_id = ?
|
|
33804
|
+
RETURNING *`, [
|
|
33805
|
+
next.displayName,
|
|
33806
|
+
next.description,
|
|
33807
|
+
next.category,
|
|
33808
|
+
JSON.stringify(next.tags),
|
|
33809
|
+
next.kind,
|
|
33810
|
+
next.version ?? null,
|
|
33811
|
+
next.skillMd ?? null,
|
|
33812
|
+
revisionId,
|
|
33813
|
+
nowIso(),
|
|
33814
|
+
principal.orgId,
|
|
33815
|
+
slug,
|
|
33816
|
+
current.revisionId
|
|
33817
|
+
]);
|
|
33818
|
+
if (!row) {
|
|
33819
|
+
const nowRow = this.get("SELECT revision_id, tombstoned_at FROM skills_registry WHERE org_id = ? AND slug = ? LIMIT 1", [
|
|
33820
|
+
principal.orgId,
|
|
33821
|
+
slug
|
|
33822
|
+
]);
|
|
33823
|
+
if (nowRow && nowRow.tombstoned_at == null) {
|
|
33824
|
+
const currentId = typeof nowRow.revision_id === "string" ? nowRow.revision_id : null;
|
|
33825
|
+
throw new SkillRevisionConflictError(slug, expectedRevisionId, currentId);
|
|
33826
|
+
}
|
|
33827
|
+
return null;
|
|
33828
|
+
}
|
|
33829
|
+
this.db.run("DELETE FROM skills_tags WHERE org_id = ? AND slug = ?", [principal.orgId, slug]);
|
|
33830
|
+
const insertTag = this.db.prepare("INSERT OR IGNORE INTO skills_tags (org_id, slug, tag) VALUES (?, ?, ?)");
|
|
33831
|
+
for (const tag of next.tags) {
|
|
33832
|
+
if (!tag.trim())
|
|
33833
|
+
continue;
|
|
33834
|
+
insertTag.run(principal.orgId, slug, tag);
|
|
33835
|
+
}
|
|
33836
|
+
return rowToSkill(row);
|
|
33837
|
+
})();
|
|
33697
33838
|
}
|
|
33698
|
-
async deleteSkill(principal, slug) {
|
|
33839
|
+
async deleteSkill(principal, slug, tombstoneWindowMs) {
|
|
33699
33840
|
return this.db.transaction(() => {
|
|
33700
|
-
const existing = this.get("SELECT
|
|
33841
|
+
const existing = this.get("SELECT tombstoned_at FROM skills_registry WHERE org_id = ? AND slug = ?", [principal.orgId, slug]);
|
|
33701
33842
|
if (!existing)
|
|
33702
|
-
return
|
|
33703
|
-
|
|
33704
|
-
|
|
33705
|
-
|
|
33706
|
-
|
|
33843
|
+
return null;
|
|
33844
|
+
if (existing.tombstoned_at != null) {
|
|
33845
|
+
const row2 = this.get("SELECT * FROM skills_registry WHERE org_id = ? AND slug = ? LIMIT 1", [principal.orgId, slug]);
|
|
33846
|
+
return rowToSkill(row2);
|
|
33847
|
+
}
|
|
33848
|
+
const tombstonedAt = nowIso();
|
|
33849
|
+
const purgeAfter = new Date(Date.now() + tombstoneWindowMs).toISOString();
|
|
33850
|
+
const row = this.get(`UPDATE skills_registry
|
|
33851
|
+
SET tombstoned_at = ?, tombstone_purge_after = ?, updated_at = ?
|
|
33852
|
+
WHERE org_id = ? AND slug = ?
|
|
33853
|
+
RETURNING *`, [tombstonedAt, purgeAfter, tombstonedAt, principal.orgId, slug]);
|
|
33854
|
+
return rowToSkill(row);
|
|
33855
|
+
})();
|
|
33856
|
+
}
|
|
33857
|
+
async purgeExpiredTombstones(principal) {
|
|
33858
|
+
return this.db.transaction(() => {
|
|
33859
|
+
const now = nowIso();
|
|
33860
|
+
const expired = this.all("SELECT * FROM skills_registry WHERE org_id = ? AND tombstoned_at IS NOT NULL AND tombstone_purge_after <= ?", [principal.orgId, now]);
|
|
33861
|
+
const purged = [];
|
|
33862
|
+
for (const row of expired) {
|
|
33863
|
+
const record = rowToSkill(row);
|
|
33864
|
+
this.db.run("DELETE FROM skills_registry WHERE org_id = ? AND slug = ?", [principal.orgId, record.slug]);
|
|
33865
|
+
this.db.run("DELETE FROM skills_tags WHERE org_id = ? AND slug = ?", [principal.orgId, record.slug]);
|
|
33866
|
+
if (record.bundleSha256)
|
|
33867
|
+
this.collectOrphanBundle(principal.orgId, record.bundleSha256);
|
|
33868
|
+
purged.push(record);
|
|
33869
|
+
}
|
|
33870
|
+
return purged;
|
|
33707
33871
|
})();
|
|
33708
33872
|
}
|
|
33709
33873
|
async getSkillBundle(principal, sha256) {
|
|
33710
33874
|
const row = this.get("SELECT * FROM skills_bundles WHERE org_id = ? AND sha256 = ? LIMIT 1", [principal.orgId, sha256]);
|
|
33711
33875
|
return row ? rowToSkillBundle(row) : null;
|
|
33712
33876
|
}
|
|
33877
|
+
async pinSkill(principal, slug, metadata = {}) {
|
|
33878
|
+
const row = this.get(`INSERT INTO skills_pins (org_id, principal, slug, pinned_at, metadata_json)
|
|
33879
|
+
VALUES (?, ?, ?, ?, ?)
|
|
33880
|
+
ON CONFLICT (org_id, principal, slug) DO UPDATE SET
|
|
33881
|
+
pinned_at = excluded.pinned_at,
|
|
33882
|
+
metadata_json = excluded.metadata_json
|
|
33883
|
+
RETURNING *`, [principal.orgId, principal.apiKeyId, slug, nowIso(), JSON.stringify(metadata)]);
|
|
33884
|
+
return rowToPin(row);
|
|
33885
|
+
}
|
|
33886
|
+
async unpinSkill(principal, slug) {
|
|
33887
|
+
const result = this.db.run("DELETE FROM skills_pins WHERE org_id = ? AND principal = ? AND slug = ?", [principal.orgId, principal.apiKeyId, slug]);
|
|
33888
|
+
return result.changes > 0;
|
|
33889
|
+
}
|
|
33890
|
+
async listPins(principal) {
|
|
33891
|
+
return this.all("SELECT * FROM skills_pins WHERE org_id = ? AND principal = ? ORDER BY slug ASC", [principal.orgId, principal.apiKeyId]).map(rowToPin);
|
|
33892
|
+
}
|
|
33893
|
+
async listTags(principal) {
|
|
33894
|
+
await this.purgeExpiredTombstones(principal);
|
|
33895
|
+
return this.all("SELECT DISTINCT tag FROM skills_tags WHERE org_id = ? ORDER BY tag ASC", [principal.orgId]).map((row) => String(row.tag));
|
|
33896
|
+
}
|
|
33897
|
+
async listSkillsByTag(principal, tag) {
|
|
33898
|
+
await this.purgeExpiredTombstones(principal);
|
|
33899
|
+
return this.all(`SELECT s.* FROM skills_registry s
|
|
33900
|
+
JOIN skills_tags t ON t.org_id = s.org_id AND t.slug = s.slug
|
|
33901
|
+
WHERE t.org_id = ? AND t.tag = ? AND s.tombstoned_at IS NULL
|
|
33902
|
+
ORDER BY s.slug ASC`, [principal.orgId, tag]).map(rowToSkill);
|
|
33903
|
+
}
|
|
33904
|
+
async listPinsByTag(principal, tag) {
|
|
33905
|
+
await this.purgeExpiredTombstones(principal);
|
|
33906
|
+
return this.all(`SELECT p.* FROM skills_pins p
|
|
33907
|
+
JOIN skills_tags t ON t.org_id = p.org_id AND t.slug = p.slug
|
|
33908
|
+
JOIN skills_registry s ON s.org_id = p.org_id AND s.slug = p.slug
|
|
33909
|
+
WHERE p.org_id = ? AND p.principal = ? AND t.tag = ? AND s.tombstoned_at IS NULL
|
|
33910
|
+
ORDER BY p.slug ASC`, [principal.orgId, principal.apiKeyId, tag]).map(rowToPin);
|
|
33911
|
+
}
|
|
33912
|
+
async listPublishedSlugs(principal) {
|
|
33913
|
+
return this.all("SELECT slug FROM skills_registry WHERE org_id = ? AND tombstoned_at IS NULL ORDER BY slug ASC", [principal.orgId]).map((row) => String(row.slug));
|
|
33914
|
+
}
|
|
33713
33915
|
collectOrphanBundle(orgId, sha256) {
|
|
33714
33916
|
const referenced = this.get("SELECT 1 AS present FROM skills_registry WHERE org_id = ? AND bundle_sha256 = ? LIMIT 1", [orgId, sha256]);
|
|
33715
33917
|
if (referenced)
|
|
@@ -33796,6 +33998,21 @@ function parseScopes(value) {
|
|
|
33796
33998
|
}
|
|
33797
33999
|
|
|
33798
34000
|
// src/server/store.ts
|
|
34001
|
+
function recordFieldsOf(input, carriedBundle, carriedSkillMd) {
|
|
34002
|
+
return {
|
|
34003
|
+
slug: input.slug,
|
|
34004
|
+
displayName: input.displayName,
|
|
34005
|
+
description: input.description,
|
|
34006
|
+
category: input.category,
|
|
34007
|
+
tags: input.tags,
|
|
34008
|
+
source: input.source,
|
|
34009
|
+
kind: input.kind,
|
|
34010
|
+
...input.version ? { version: input.version } : {},
|
|
34011
|
+
...carriedSkillMd ? { skillMd: carriedSkillMd } : {},
|
|
34012
|
+
bundleSha256: input.bundle?.sha256 ?? carriedBundle.bundleSha256,
|
|
34013
|
+
bundleByteSize: input.bundle?.byteSize ?? carriedBundle.bundleByteSize
|
|
34014
|
+
};
|
|
34015
|
+
}
|
|
33799
34016
|
function resolvePoolMax(env = process.env) {
|
|
33800
34017
|
const parsed = Number.parseInt(env.HASNA_SKILLS_DATABASE_POOL_MAX || env.SKILLS_DATABASE_POOL_MAX || "", 10);
|
|
33801
34018
|
return Number.isFinite(parsed) && parsed > 0 ? parsed : 4;
|
|
@@ -33833,6 +34050,7 @@ class MemorySkillsStore {
|
|
|
33833
34050
|
idempotency = new Map;
|
|
33834
34051
|
skills = new Map;
|
|
33835
34052
|
bundles = new Map;
|
|
34053
|
+
pins = new Map;
|
|
33836
34054
|
constructor(apiKeys = []) {
|
|
33837
34055
|
for (const key of apiKeys)
|
|
33838
34056
|
this.addApiKey(key.token, key.principal);
|
|
@@ -33934,6 +34152,9 @@ class MemorySkillsStore {
|
|
|
33934
34152
|
const key = skillKey(input.principal.orgId, input.slug);
|
|
33935
34153
|
const now = nowIso();
|
|
33936
34154
|
const previous = this.skills.get(key);
|
|
34155
|
+
if (previous && !previous.tombstonedAt && input.expectedRevisionId !== previous.revisionId) {
|
|
34156
|
+
throw new SkillRevisionConflictError(input.slug, input.expectedRevisionId, previous.revisionId);
|
|
34157
|
+
}
|
|
33937
34158
|
if (input.bundle) {
|
|
33938
34159
|
const bundleMapKey = skillKey(input.principal.orgId, input.bundle.sha256);
|
|
33939
34160
|
this.bundles.set(bundleMapKey, {
|
|
@@ -33943,6 +34164,8 @@ class MemorySkillsStore {
|
|
|
33943
34164
|
createdAt: this.bundles.get(bundleMapKey)?.createdAt ?? now
|
|
33944
34165
|
});
|
|
33945
34166
|
}
|
|
34167
|
+
const carriedBundle = !input.bundle && previous?.bundleSha256 ? { bundleSha256: previous.bundleSha256, ...previous.bundleByteSize === undefined ? {} : { bundleByteSize: previous.bundleByteSize } } : {};
|
|
34168
|
+
const carriedSkillMd = typeof input.skillMd === "string" ? input.skillMd : previous?.skillMd;
|
|
33946
34169
|
const record = {
|
|
33947
34170
|
orgId: input.principal.orgId,
|
|
33948
34171
|
slug: input.slug,
|
|
@@ -33953,11 +34176,13 @@ class MemorySkillsStore {
|
|
|
33953
34176
|
source: input.source,
|
|
33954
34177
|
kind: input.kind,
|
|
33955
34178
|
...input.version ? { version: input.version } : {},
|
|
33956
|
-
...
|
|
33957
|
-
...input.bundle ? { bundleSha256: input.bundle.sha256, bundleByteSize: input.bundle.byteSize } :
|
|
34179
|
+
...carriedSkillMd ? { skillMd: carriedSkillMd } : {},
|
|
34180
|
+
...input.bundle ? { bundleSha256: input.bundle.sha256, bundleByteSize: input.bundle.byteSize } : carriedBundle,
|
|
33958
34181
|
publishedByUserId: input.principal.userId,
|
|
33959
34182
|
createdAt: previous?.createdAt ?? now,
|
|
33960
|
-
updatedAt: now
|
|
34183
|
+
updatedAt: now,
|
|
34184
|
+
revisionId: revisionIdOfRecord(recordFieldsOf(input, carriedBundle, carriedSkillMd)),
|
|
34185
|
+
revisionNumber: (previous?.revisionNumber ?? 0) + 1
|
|
33961
34186
|
};
|
|
33962
34187
|
this.skills.set(key, record);
|
|
33963
34188
|
if (previous?.bundleSha256 && input.bundle && previous.bundleSha256 !== input.bundle.sha256) {
|
|
@@ -33966,33 +34191,107 @@ class MemorySkillsStore {
|
|
|
33966
34191
|
return record;
|
|
33967
34192
|
}
|
|
33968
34193
|
async listSkills(principal) {
|
|
33969
|
-
return Array.from(this.skills.values()).filter((skill) => skill.orgId === principal.orgId).sort((a3, b3) => a3.slug.localeCompare(b3.slug));
|
|
34194
|
+
return Array.from(this.skills.values()).filter((skill) => skill.orgId === principal.orgId && !skill.tombstonedAt).sort((a3, b3) => a3.slug.localeCompare(b3.slug));
|
|
33970
34195
|
}
|
|
33971
34196
|
async getSkill(principal, slug) {
|
|
33972
34197
|
const skill = this.skills.get(skillKey(principal.orgId, slug));
|
|
33973
34198
|
return skill && skill.orgId === principal.orgId ? skill : null;
|
|
33974
34199
|
}
|
|
33975
|
-
async updateSkill(principal, slug, patch) {
|
|
34200
|
+
async updateSkill(principal, slug, patch, expectedRevisionId) {
|
|
33976
34201
|
const current = await this.getSkill(principal, slug);
|
|
33977
|
-
if (!current)
|
|
34202
|
+
if (!current || current.tombstonedAt)
|
|
33978
34203
|
return null;
|
|
33979
|
-
|
|
34204
|
+
if (expectedRevisionId !== current.revisionId) {
|
|
34205
|
+
throw new SkillRevisionConflictError(slug, expectedRevisionId, current.revisionId);
|
|
34206
|
+
}
|
|
34207
|
+
const latest = this.skills.get(skillKey(principal.orgId, slug));
|
|
34208
|
+
if (!latest || latest.tombstonedAt)
|
|
34209
|
+
return null;
|
|
34210
|
+
if (latest.revisionId !== current.revisionId) {
|
|
34211
|
+
throw new SkillRevisionConflictError(slug, expectedRevisionId, latest.revisionId);
|
|
34212
|
+
}
|
|
34213
|
+
const next = {
|
|
34214
|
+
...latest,
|
|
34215
|
+
...patch,
|
|
34216
|
+
updatedAt: nowIso(),
|
|
34217
|
+
revisionId: revisionIdOfRecord({ ...latest, ...patch }),
|
|
34218
|
+
revisionNumber: latest.revisionNumber + 1
|
|
34219
|
+
};
|
|
33980
34220
|
this.skills.set(skillKey(principal.orgId, slug), next);
|
|
33981
34221
|
return next;
|
|
33982
34222
|
}
|
|
33983
|
-
async deleteSkill(principal, slug) {
|
|
34223
|
+
async deleteSkill(principal, slug, tombstoneWindowMs) {
|
|
33984
34224
|
const current = await this.getSkill(principal, slug);
|
|
33985
34225
|
if (!current)
|
|
33986
|
-
return
|
|
33987
|
-
|
|
33988
|
-
|
|
33989
|
-
|
|
33990
|
-
|
|
34226
|
+
return null;
|
|
34227
|
+
if (!current.tombstonedAt) {
|
|
34228
|
+
const tombstoned = nowIso();
|
|
34229
|
+
const purgeAfter = new Date(Date.now() + tombstoneWindowMs).toISOString();
|
|
34230
|
+
const next = { ...current, tombstonedAt: tombstoned, tombstonePurgeAfter: purgeAfter, updatedAt: tombstoned };
|
|
34231
|
+
this.skills.set(skillKey(principal.orgId, slug), next);
|
|
34232
|
+
return next;
|
|
34233
|
+
}
|
|
34234
|
+
return current;
|
|
34235
|
+
}
|
|
34236
|
+
async purgeExpiredTombstones(principal) {
|
|
34237
|
+
const now = nowIso();
|
|
34238
|
+
const purged = [];
|
|
34239
|
+
for (const [key, skill] of this.skills) {
|
|
34240
|
+
if (skill.orgId !== principal.orgId || !skill.tombstonedAt || !skill.tombstonePurgeAfter)
|
|
34241
|
+
continue;
|
|
34242
|
+
if (skill.tombstonePurgeAfter > now)
|
|
34243
|
+
continue;
|
|
34244
|
+
this.skills.delete(key);
|
|
34245
|
+
if (skill.bundleSha256)
|
|
34246
|
+
this.collectOrphanBundle(principal.orgId, skill.bundleSha256);
|
|
34247
|
+
purged.push(skill);
|
|
34248
|
+
}
|
|
34249
|
+
return purged;
|
|
33991
34250
|
}
|
|
33992
34251
|
async getSkillBundle(principal, sha256) {
|
|
33993
34252
|
const bundle = this.bundles.get(skillKey(principal.orgId, sha256));
|
|
33994
34253
|
return bundle && bundle.orgId === principal.orgId ? bundle : null;
|
|
33995
34254
|
}
|
|
34255
|
+
async pinSkill(principal, slug, metadata = {}) {
|
|
34256
|
+
const pin = { orgId: principal.orgId, principal: principal.apiKeyId, slug, pinnedAt: nowIso(), metadata: { ...metadata } };
|
|
34257
|
+
this.pins.set(pinKey(principal.orgId, principal.apiKeyId, slug), pin);
|
|
34258
|
+
return pin;
|
|
34259
|
+
}
|
|
34260
|
+
async unpinSkill(principal, slug) {
|
|
34261
|
+
return this.pins.delete(pinKey(principal.orgId, principal.apiKeyId, slug));
|
|
34262
|
+
}
|
|
34263
|
+
async listPins(principal) {
|
|
34264
|
+
return Array.from(this.pins.values()).filter((pin) => pin.orgId === principal.orgId && pin.principal === principal.apiKeyId).sort((a3, b3) => a3.slug.localeCompare(b3.slug));
|
|
34265
|
+
}
|
|
34266
|
+
async listTags(principal) {
|
|
34267
|
+
await this.purgeExpiredTombstones(principal);
|
|
34268
|
+
const tags = new Set;
|
|
34269
|
+
for (const skill of this.skills.values()) {
|
|
34270
|
+
if (skill.orgId !== principal.orgId)
|
|
34271
|
+
continue;
|
|
34272
|
+
for (const tag of skill.tags) {
|
|
34273
|
+
if (tag.trim())
|
|
34274
|
+
tags.add(tag);
|
|
34275
|
+
}
|
|
34276
|
+
}
|
|
34277
|
+
return [...tags].sort();
|
|
34278
|
+
}
|
|
34279
|
+
async listSkillsByTag(principal, tag) {
|
|
34280
|
+
await this.purgeExpiredTombstones(principal);
|
|
34281
|
+
return Array.from(this.skills.values()).filter((skill) => skill.orgId === principal.orgId && !skill.tombstonedAt && skill.tags.includes(tag)).sort((a3, b3) => a3.slug.localeCompare(b3.slug));
|
|
34282
|
+
}
|
|
34283
|
+
async listPinsByTag(principal, tag) {
|
|
34284
|
+
await this.purgeExpiredTombstones(principal);
|
|
34285
|
+
const taggedSlugs = new Set;
|
|
34286
|
+
for (const skill of this.skills.values()) {
|
|
34287
|
+
if (skill.orgId === principal.orgId && !skill.tombstonedAt && skill.tags.includes(tag))
|
|
34288
|
+
taggedSlugs.add(skill.slug);
|
|
34289
|
+
}
|
|
34290
|
+
return Array.from(this.pins.values()).filter((pin) => pin.orgId === principal.orgId && pin.principal === principal.apiKeyId && taggedSlugs.has(pin.slug)).sort((a3, b3) => a3.slug.localeCompare(b3.slug));
|
|
34291
|
+
}
|
|
34292
|
+
async listPublishedSlugs(principal) {
|
|
34293
|
+
return Array.from(this.skills.values()).filter((skill) => skill.orgId === principal.orgId && !skill.tombstonedAt).map((skill) => skill.slug).sort();
|
|
34294
|
+
}
|
|
33996
34295
|
collectOrphanBundle(orgId, sha256) {
|
|
33997
34296
|
const referenced = Array.from(this.skills.values()).some((skill) => skill.orgId === orgId && skill.bundleSha256 === sha256);
|
|
33998
34297
|
if (!referenced)
|
|
@@ -34010,6 +34309,9 @@ class MemorySkillsStore {
|
|
|
34010
34309
|
function skillKey(orgId, slug) {
|
|
34011
34310
|
return `${orgId.length}:${orgId}:${slug}`;
|
|
34012
34311
|
}
|
|
34312
|
+
function pinKey(orgId, principal, slug) {
|
|
34313
|
+
return `${orgId.length}:${orgId}:${principal.length}:${principal}:${slug}`;
|
|
34314
|
+
}
|
|
34013
34315
|
|
|
34014
34316
|
class PostgresSkillsStore {
|
|
34015
34317
|
backend = { kind: "postgres", durable: true, label: "postgres" };
|
|
@@ -34029,6 +34331,14 @@ class PostgresSkillsStore {
|
|
|
34029
34331
|
} catch (error) {
|
|
34030
34332
|
throw new Error("the configured Postgres database is reachable but has no skills schema. Run `skills-migrate` against it " + "before starting the server - unlike SQLite, Postgres is not migrated automatically, so that several " + `replicas cannot race to migrate a shared database. Driver reported: ${connectionFailureSummary(error)}`);
|
|
34031
34333
|
}
|
|
34334
|
+
await this.backfillLegacyRevisions();
|
|
34335
|
+
}
|
|
34336
|
+
async backfillLegacyRevisions() {
|
|
34337
|
+
const rows = await this.sql`SELECT * FROM skills_registry WHERE revision_id = ${""}`;
|
|
34338
|
+
for (const row of rows) {
|
|
34339
|
+
const record = rowToSkill(row);
|
|
34340
|
+
await this.sql`UPDATE skills_registry SET revision_id = ${revisionIdOfRecord(record)} WHERE org_id = ${record.orgId} AND slug = ${record.slug}`;
|
|
34341
|
+
}
|
|
34032
34342
|
}
|
|
34033
34343
|
async close() {
|
|
34034
34344
|
await this.sql.close?.();
|
|
@@ -34263,8 +34573,33 @@ class PostgresSkillsStore {
|
|
|
34263
34573
|
async publishSkill(input) {
|
|
34264
34574
|
const orgId = input.principal.orgId;
|
|
34265
34575
|
return await this.sql.begin(async (tx) => {
|
|
34266
|
-
const previousRows = await tx`
|
|
34267
|
-
|
|
34576
|
+
const previousRows = await tx`
|
|
34577
|
+
SELECT revision_id, revision_number, bundle_sha256, bundle_byte_size, skill_md, tombstoned_at
|
|
34578
|
+
FROM skills_registry WHERE org_id = ${orgId} AND slug = ${input.slug} LIMIT 1
|
|
34579
|
+
`;
|
|
34580
|
+
const previous = previousRows[0];
|
|
34581
|
+
const previousSha = typeof previous?.bundle_sha256 === "string" ? String(previous.bundle_sha256) : null;
|
|
34582
|
+
const previousRevisionId = typeof previous?.revision_id === "string" && previous.revision_id ? String(previous.revision_id) : null;
|
|
34583
|
+
const tombstoned = previous?.tombstoned_at != null;
|
|
34584
|
+
const carriedSkillMd = typeof input.skillMd === "string" ? input.skillMd : typeof previous?.skill_md === "string" ? String(previous.skill_md) : null;
|
|
34585
|
+
if (previous && !tombstoned && input.expectedRevisionId !== previousRevisionId) {
|
|
34586
|
+
throw new SkillRevisionConflictError(input.slug, input.expectedRevisionId, previousRevisionId);
|
|
34587
|
+
}
|
|
34588
|
+
const carriedSha = input.bundle?.sha256 ?? previousSha;
|
|
34589
|
+
const carriedSize = input.bundle?.byteSize ?? (previous?.bundle_byte_size == null ? null : Number(previous.bundle_byte_size));
|
|
34590
|
+
const revisionId = revisionIdOfRecord({
|
|
34591
|
+
slug: input.slug,
|
|
34592
|
+
displayName: input.displayName,
|
|
34593
|
+
description: input.description,
|
|
34594
|
+
category: input.category,
|
|
34595
|
+
tags: input.tags,
|
|
34596
|
+
source: input.source,
|
|
34597
|
+
kind: input.kind,
|
|
34598
|
+
...input.version ? { version: input.version } : {},
|
|
34599
|
+
...carriedSkillMd ? { skillMd: carriedSkillMd } : {},
|
|
34600
|
+
...carriedSha ? { bundleSha256: carriedSha } : {},
|
|
34601
|
+
...carriedSize === null || carriedSize === undefined ? {} : { bundleByteSize: carriedSize }
|
|
34602
|
+
});
|
|
34268
34603
|
if (input.bundle) {
|
|
34269
34604
|
await tx`
|
|
34270
34605
|
INSERT INTO skills_bundles (org_id, sha256, byte_size, content_type, storage_kind, storage_key, body_blob)
|
|
@@ -34279,10 +34614,10 @@ class PostgresSkillsStore {
|
|
|
34279
34614
|
}
|
|
34280
34615
|
const rows = await tx`
|
|
34281
34616
|
INSERT INTO skills_registry (org_id, slug, display_name, description, category, tags_json, source, kind, version, skill_md,
|
|
34282
|
-
bundle_sha256, bundle_byte_size, published_by_user_id, updated_at)
|
|
34617
|
+
bundle_sha256, bundle_byte_size, published_by_user_id, revision_id, revision_number, updated_at)
|
|
34283
34618
|
VALUES (${orgId}, ${input.slug}, ${input.displayName}, ${input.description}, ${input.category}, ${JSON.stringify(input.tags)}::jsonb,
|
|
34284
|
-
${input.source}, ${input.kind}, ${input.version ?? null}, ${
|
|
34285
|
-
${input.bundle?.sha256 ?? null}, ${input.bundle?.byteSize ?? null}, ${input.principal.userId}, now())
|
|
34619
|
+
${input.source}, ${input.kind}, ${input.version ?? null}, ${carriedSkillMd},
|
|
34620
|
+
${input.bundle?.sha256 ?? null}, ${input.bundle?.byteSize ?? null}, ${input.principal.userId}, ${revisionId}, 1, now())
|
|
34286
34621
|
ON CONFLICT (org_id, slug) DO UPDATE SET
|
|
34287
34622
|
display_name = EXCLUDED.display_name,
|
|
34288
34623
|
description = EXCLUDED.description,
|
|
@@ -34297,9 +34632,23 @@ class PostgresSkillsStore {
|
|
|
34297
34632
|
bundle_sha256 = COALESCE(EXCLUDED.bundle_sha256, skills_registry.bundle_sha256),
|
|
34298
34633
|
bundle_byte_size = COALESCE(EXCLUDED.bundle_byte_size, skills_registry.bundle_byte_size),
|
|
34299
34634
|
published_by_user_id = EXCLUDED.published_by_user_id,
|
|
34635
|
+
revision_id = EXCLUDED.revision_id,
|
|
34636
|
+
-- Current + 1, not EXCLUDED.revision_number: the insert path minted 1, but on
|
|
34637
|
+
-- the update path the ACTUAL row's counter is the truth (it may have advanced
|
|
34638
|
+
-- since the pre-read, which is exactly what the WHERE guard below detects).
|
|
34639
|
+
revision_number = skills_registry.revision_number + 1,
|
|
34640
|
+
tombstoned_at = NULL,
|
|
34641
|
+
tombstone_purge_after = NULL,
|
|
34300
34642
|
updated_at = EXCLUDED.updated_at
|
|
34643
|
+
WHERE skills_registry.tombstoned_at IS NOT NULL
|
|
34644
|
+
OR skills_registry.revision_id = ${input.expectedRevisionId ?? NO_REVISION_SENTINEL2}
|
|
34301
34645
|
RETURNING *
|
|
34302
34646
|
`;
|
|
34647
|
+
if (!rows[0]) {
|
|
34648
|
+
const current = await tx`SELECT revision_id FROM skills_registry WHERE org_id = ${orgId} AND slug = ${input.slug} LIMIT 1`;
|
|
34649
|
+
const currentId = current[0] && typeof current[0].revision_id === "string" ? String(current[0].revision_id) : null;
|
|
34650
|
+
throw new SkillRevisionConflictError(input.slug, input.expectedRevisionId, currentId);
|
|
34651
|
+
}
|
|
34303
34652
|
if (previousSha && input.bundle && previousSha !== input.bundle.sha256) {
|
|
34304
34653
|
await tx`
|
|
34305
34654
|
DELETE FROM skills_bundles
|
|
@@ -34307,55 +34656,182 @@ class PostgresSkillsStore {
|
|
|
34307
34656
|
AND NOT EXISTS (SELECT 1 FROM skills_registry WHERE org_id = ${orgId} AND bundle_sha256 = ${previousSha})
|
|
34308
34657
|
`;
|
|
34309
34658
|
}
|
|
34659
|
+
await tx`DELETE FROM skills_tags WHERE org_id = ${orgId} AND slug = ${input.slug}`;
|
|
34660
|
+
for (const tag of input.tags) {
|
|
34661
|
+
if (!tag.trim())
|
|
34662
|
+
continue;
|
|
34663
|
+
await tx`
|
|
34664
|
+
INSERT INTO skills_tags (org_id, slug, tag) VALUES (${orgId}, ${input.slug}, ${tag})
|
|
34665
|
+
ON CONFLICT DO NOTHING
|
|
34666
|
+
`;
|
|
34667
|
+
}
|
|
34310
34668
|
return rowToSkill(rows[0]);
|
|
34311
34669
|
});
|
|
34312
34670
|
}
|
|
34313
34671
|
async listSkills(principal) {
|
|
34314
|
-
|
|
34672
|
+
await this.purgeExpiredTombstones(principal);
|
|
34673
|
+
const rows = await this.sql`
|
|
34674
|
+
SELECT * FROM skills_registry WHERE org_id = ${principal.orgId} AND tombstoned_at IS NULL ORDER BY slug ASC
|
|
34675
|
+
`;
|
|
34315
34676
|
return rows.map(rowToSkill);
|
|
34316
34677
|
}
|
|
34317
34678
|
async getSkill(principal, slug) {
|
|
34318
34679
|
const rows = await this.sql`SELECT * FROM skills_registry WHERE org_id = ${principal.orgId} AND slug = ${slug} LIMIT 1`;
|
|
34319
34680
|
return rows[0] ? rowToSkill(rows[0]) : null;
|
|
34320
34681
|
}
|
|
34321
|
-
async updateSkill(principal, slug, patch) {
|
|
34682
|
+
async updateSkill(principal, slug, patch, expectedRevisionId) {
|
|
34322
34683
|
const current = await this.getSkill(principal, slug);
|
|
34323
|
-
if (!current)
|
|
34684
|
+
if (!current || current.tombstonedAt)
|
|
34324
34685
|
return null;
|
|
34686
|
+
if (expectedRevisionId !== current.revisionId) {
|
|
34687
|
+
throw new SkillRevisionConflictError(slug, expectedRevisionId, current.revisionId);
|
|
34688
|
+
}
|
|
34325
34689
|
const next = { ...current, ...patch };
|
|
34326
|
-
|
|
34327
|
-
|
|
34328
|
-
|
|
34329
|
-
|
|
34330
|
-
|
|
34331
|
-
|
|
34332
|
-
|
|
34333
|
-
|
|
34334
|
-
|
|
34690
|
+
return await this.sql.begin(async (tx) => {
|
|
34691
|
+
const revisionId = revisionIdOfRecord(next);
|
|
34692
|
+
const updated = await tx`
|
|
34693
|
+
UPDATE skills_registry
|
|
34694
|
+
SET display_name = ${next.displayName}, description = ${next.description}, category = ${next.category},
|
|
34695
|
+
tags_json = ${JSON.stringify(next.tags)}::jsonb, kind = ${next.kind}, version = ${next.version ?? null},
|
|
34696
|
+
skill_md = ${next.skillMd ?? null}, revision_id = ${revisionId}, revision_number = revision_number + 1, updated_at = now()
|
|
34697
|
+
WHERE org_id = ${principal.orgId} AND slug = ${slug} AND tombstoned_at IS NULL AND revision_id = ${current.revisionId}
|
|
34698
|
+
RETURNING *
|
|
34699
|
+
`;
|
|
34700
|
+
if (!updated[0]) {
|
|
34701
|
+
const nowRows = await tx`
|
|
34702
|
+
SELECT revision_id, tombstoned_at FROM skills_registry WHERE org_id = ${principal.orgId} AND slug = ${slug} LIMIT 1
|
|
34703
|
+
`;
|
|
34704
|
+
if (nowRows[0] && nowRows[0].tombstoned_at == null) {
|
|
34705
|
+
const currentId = String(nowRows[0].revision_id);
|
|
34706
|
+
throw new SkillRevisionConflictError(slug, expectedRevisionId, currentId);
|
|
34707
|
+
}
|
|
34708
|
+
return null;
|
|
34709
|
+
}
|
|
34710
|
+
await tx`DELETE FROM skills_tags WHERE org_id = ${principal.orgId} AND slug = ${slug}`;
|
|
34711
|
+
for (const tag of next.tags) {
|
|
34712
|
+
if (!tag.trim())
|
|
34713
|
+
continue;
|
|
34714
|
+
await tx`
|
|
34715
|
+
INSERT INTO skills_tags (org_id, slug, tag) VALUES (${principal.orgId}, ${slug}, ${tag})
|
|
34716
|
+
ON CONFLICT DO NOTHING
|
|
34717
|
+
`;
|
|
34718
|
+
}
|
|
34719
|
+
return rowToSkill(updated[0]);
|
|
34720
|
+
});
|
|
34335
34721
|
}
|
|
34336
|
-
async deleteSkill(principal, slug) {
|
|
34722
|
+
async deleteSkill(principal, slug, tombstoneWindowMs) {
|
|
34337
34723
|
return await this.sql.begin(async (tx) => {
|
|
34724
|
+
const existingRows = await tx`
|
|
34725
|
+
SELECT tombstoned_at FROM skills_registry WHERE org_id = ${principal.orgId} AND slug = ${slug} LIMIT 1
|
|
34726
|
+
`;
|
|
34727
|
+
if (!existingRows[0])
|
|
34728
|
+
return null;
|
|
34729
|
+
if (existingRows[0].tombstoned_at != null) {
|
|
34730
|
+
const rows2 = await tx`SELECT * FROM skills_registry WHERE org_id = ${principal.orgId} AND slug = ${slug} LIMIT 1`;
|
|
34731
|
+
return rowToSkill(rows2[0]);
|
|
34732
|
+
}
|
|
34338
34733
|
const rows = await tx`
|
|
34339
|
-
|
|
34340
|
-
|
|
34734
|
+
UPDATE skills_registry
|
|
34735
|
+
SET tombstoned_at = now(), tombstone_purge_after = now() + (${tombstoneWindowMs}::int * interval '1 millisecond'), updated_at = now()
|
|
34736
|
+
WHERE org_id = ${principal.orgId} AND slug = ${slug}
|
|
34737
|
+
RETURNING *
|
|
34341
34738
|
`;
|
|
34342
|
-
|
|
34343
|
-
|
|
34344
|
-
|
|
34345
|
-
|
|
34739
|
+
return rows[0] ? rowToSkill(rows[0]) : null;
|
|
34740
|
+
});
|
|
34741
|
+
}
|
|
34742
|
+
async purgeExpiredTombstones(principal) {
|
|
34743
|
+
return await this.sql.begin(async (tx) => {
|
|
34744
|
+
const expiredRows = await tx`
|
|
34745
|
+
SELECT * FROM skills_registry
|
|
34746
|
+
WHERE org_id = ${principal.orgId} AND tombstoned_at IS NOT NULL AND tombstone_purge_after <= now()
|
|
34747
|
+
`;
|
|
34748
|
+
if (!expiredRows.length)
|
|
34749
|
+
return [];
|
|
34750
|
+
const purged = [];
|
|
34751
|
+
for (const row of expiredRows) {
|
|
34752
|
+
const record = rowToSkill(row);
|
|
34346
34753
|
await tx`
|
|
34347
|
-
DELETE FROM
|
|
34348
|
-
WHERE org_id = ${principal.orgId} AND sha256 = ${sha}
|
|
34349
|
-
AND NOT EXISTS (SELECT 1 FROM skills_registry WHERE org_id = ${principal.orgId} AND bundle_sha256 = ${sha})
|
|
34754
|
+
DELETE FROM skills_registry WHERE org_id = ${principal.orgId} AND slug = ${record.slug} AND tombstone_purge_after <= now()
|
|
34350
34755
|
`;
|
|
34756
|
+
await tx`DELETE FROM skills_tags WHERE org_id = ${principal.orgId} AND slug = ${record.slug}`;
|
|
34757
|
+
await tx`
|
|
34758
|
+
DELETE FROM skills_registry WHERE org_id = ${principal.orgId} AND slug = ${record.slug} AND tombstone_purge_after <= now()
|
|
34759
|
+
`;
|
|
34760
|
+
if (record.bundleSha256) {
|
|
34761
|
+
await tx`
|
|
34762
|
+
DELETE FROM skills_bundles
|
|
34763
|
+
WHERE org_id = ${principal.orgId} AND sha256 = ${record.bundleSha256}
|
|
34764
|
+
AND NOT EXISTS (SELECT 1 FROM skills_registry WHERE org_id = ${principal.orgId} AND bundle_sha256 = ${record.bundleSha256})
|
|
34765
|
+
`;
|
|
34766
|
+
}
|
|
34767
|
+
purged.push(record);
|
|
34351
34768
|
}
|
|
34352
|
-
return
|
|
34769
|
+
return purged;
|
|
34353
34770
|
});
|
|
34354
34771
|
}
|
|
34355
34772
|
async getSkillBundle(principal, sha256) {
|
|
34356
34773
|
const rows = await this.sql`SELECT * FROM skills_bundles WHERE org_id = ${principal.orgId} AND sha256 = ${sha256} LIMIT 1`;
|
|
34357
34774
|
return rows[0] ? rowToSkillBundle(rows[0]) : null;
|
|
34358
34775
|
}
|
|
34776
|
+
async pinSkill(principal, slug, metadata = {}) {
|
|
34777
|
+
const rows = await this.sql`
|
|
34778
|
+
INSERT INTO skills_pins (org_id, principal, slug, pinned_at, metadata_json)
|
|
34779
|
+
VALUES (${principal.orgId}, ${principal.apiKeyId}, ${slug}, now(), ${JSON.stringify(metadata)}::jsonb)
|
|
34780
|
+
ON CONFLICT (org_id, principal, slug) DO UPDATE SET
|
|
34781
|
+
pinned_at = now(),
|
|
34782
|
+
metadata_json = EXCLUDED.metadata_json
|
|
34783
|
+
RETURNING *
|
|
34784
|
+
`;
|
|
34785
|
+
return rowToPin(rows[0]);
|
|
34786
|
+
}
|
|
34787
|
+
async unpinSkill(principal, slug) {
|
|
34788
|
+
const rows = await this.sql`
|
|
34789
|
+
DELETE FROM skills_pins WHERE org_id = ${principal.orgId} AND principal = ${principal.apiKeyId} AND slug = ${slug}
|
|
34790
|
+
RETURNING 1 AS present
|
|
34791
|
+
`;
|
|
34792
|
+
return rows.length > 0;
|
|
34793
|
+
}
|
|
34794
|
+
async listPins(principal) {
|
|
34795
|
+
const rows = await this.sql`
|
|
34796
|
+
SELECT * FROM skills_pins WHERE org_id = ${principal.orgId} AND principal = ${principal.apiKeyId} ORDER BY slug ASC
|
|
34797
|
+
`;
|
|
34798
|
+
return rows.map(rowToPin);
|
|
34799
|
+
}
|
|
34800
|
+
async listTags(principal) {
|
|
34801
|
+
await this.purgeExpiredTombstones(principal);
|
|
34802
|
+
const rows = await this.sql`
|
|
34803
|
+
SELECT DISTINCT tag FROM skills_tags WHERE org_id = ${principal.orgId} ORDER BY tag ASC
|
|
34804
|
+
`;
|
|
34805
|
+
return rows.map((row) => String(row.tag));
|
|
34806
|
+
}
|
|
34807
|
+
async listSkillsByTag(principal, tag) {
|
|
34808
|
+
await this.purgeExpiredTombstones(principal);
|
|
34809
|
+
const rows = await this.sql`
|
|
34810
|
+
SELECT s.* FROM skills_registry s
|
|
34811
|
+
JOIN skills_tags t ON t.org_id = s.org_id AND t.slug = s.slug
|
|
34812
|
+
WHERE t.org_id = ${principal.orgId} AND t.tag = ${tag} AND s.tombstoned_at IS NULL
|
|
34813
|
+
ORDER BY s.slug ASC
|
|
34814
|
+
`;
|
|
34815
|
+
return rows.map(rowToSkill);
|
|
34816
|
+
}
|
|
34817
|
+
async listPinsByTag(principal, tag) {
|
|
34818
|
+
await this.purgeExpiredTombstones(principal);
|
|
34819
|
+
const rows = await this.sql`
|
|
34820
|
+
SELECT p.* FROM skills_pins p
|
|
34821
|
+
JOIN skills_tags t ON t.org_id = p.org_id AND t.slug = p.slug
|
|
34822
|
+
JOIN skills_registry s ON s.org_id = p.org_id AND s.slug = p.slug
|
|
34823
|
+
WHERE p.org_id = ${principal.orgId} AND p.principal = ${principal.apiKeyId}
|
|
34824
|
+
AND t.tag = ${tag} AND s.tombstoned_at IS NULL
|
|
34825
|
+
ORDER BY p.slug ASC
|
|
34826
|
+
`;
|
|
34827
|
+
return rows.map(rowToPin);
|
|
34828
|
+
}
|
|
34829
|
+
async listPublishedSlugs(principal) {
|
|
34830
|
+
const rows = await this.sql`
|
|
34831
|
+
SELECT slug FROM skills_registry WHERE org_id = ${principal.orgId} AND tombstoned_at IS NULL ORDER BY slug ASC
|
|
34832
|
+
`;
|
|
34833
|
+
return rows.map((row) => String(row.slug));
|
|
34834
|
+
}
|
|
34359
34835
|
async collectOrphanBundle(orgId, sha256) {
|
|
34360
34836
|
await this.sql`
|
|
34361
34837
|
DELETE FROM skills_bundles
|
|
@@ -34364,6 +34840,7 @@ class PostgresSkillsStore {
|
|
|
34364
34840
|
`;
|
|
34365
34841
|
}
|
|
34366
34842
|
}
|
|
34843
|
+
var NO_REVISION_SENTINEL2 = "0000000000000000000000000000000000000000000000000000000000000000";
|
|
34367
34844
|
function isUniqueViolation(error) {
|
|
34368
34845
|
const code = error?.code;
|
|
34369
34846
|
if (code === "23505" || code === 23505)
|
|
@@ -34425,7 +34902,7 @@ ${summary}
|
|
|
34425
34902
|
textArtifact(run, "show-notes.md", `# Show Notes
|
|
34426
34903
|
|
|
34427
34904
|
- ${summary}
|
|
34428
|
-
- Generated by the
|
|
34905
|
+
- Generated by the skills deterministic worker.
|
|
34429
34906
|
`),
|
|
34430
34907
|
textArtifact(run, "clips.csv", `start,end,title,summary
|
|
34431
34908
|
00:00,00:30,"Opening","${csv(summary)}"
|
|
@@ -34459,7 +34936,7 @@ function textArtifact(run, relativePath, bodyText, contentType = relativePath.en
|
|
|
34459
34936
|
relativePath,
|
|
34460
34937
|
contentType,
|
|
34461
34938
|
byteSize: bytes.byteLength,
|
|
34462
|
-
sha256:
|
|
34939
|
+
sha256: createHash5("sha256").update(bytes).digest("hex"),
|
|
34463
34940
|
visibility: "private"
|
|
34464
34941
|
},
|
|
34465
34942
|
body: { relativePath, bodyText, contentType }
|
|
@@ -34521,7 +34998,7 @@ function csv(value) {
|
|
|
34521
34998
|
}
|
|
34522
34999
|
|
|
34523
35000
|
// src/server/skills-api.ts
|
|
34524
|
-
import { createHash as
|
|
35001
|
+
import { createHash as createHash6 } from "crypto";
|
|
34525
35002
|
|
|
34526
35003
|
// src/lib/registry-merge.ts
|
|
34527
35004
|
var SKILL_SOURCE_PRECEDENCE = {
|
|
@@ -34749,7 +35226,7 @@ var DEVELOPMENT_TOOLS_SKILLS = [
|
|
|
34749
35226
|
{
|
|
34750
35227
|
name: "monitor",
|
|
34751
35228
|
displayName: "Monitor",
|
|
34752
|
-
description: "Operate the
|
|
35229
|
+
description: "Operate the monitor MCP for machine health, processes, cron jobs, and cleanup workflows",
|
|
34753
35230
|
category: "Development Tools",
|
|
34754
35231
|
tags: ["monitoring", "mcp", "processes", "operations"]
|
|
34755
35232
|
},
|
|
@@ -34795,6 +35272,14 @@ var DEVELOPMENT_TOOLS_SKILLS = [
|
|
|
34795
35272
|
description: "Validate configuration files for syntax and schema compliance",
|
|
34796
35273
|
category: "Development Tools",
|
|
34797
35274
|
tags: ["config", "validation", "schema", "linting"]
|
|
35275
|
+
},
|
|
35276
|
+
{
|
|
35277
|
+
name: "session-inject-monitor",
|
|
35278
|
+
displayName: "Session Inject Monitor",
|
|
35279
|
+
description: "Set up a declarative monitor that injects a prompt into a live coding-agent session when a watched source (conversations, email, todos, knowledge, command output) has new content",
|
|
35280
|
+
category: "Development Tools",
|
|
35281
|
+
tags: ["monitor", "session", "injection", "automation", "wake"],
|
|
35282
|
+
kind: "instruction"
|
|
34798
35283
|
}
|
|
34799
35284
|
];
|
|
34800
35285
|
|
|
@@ -35182,7 +35667,7 @@ var DESIGN_BRANDING_SKILLS = [
|
|
|
35182
35667
|
displayName: "Site Analyze",
|
|
35183
35668
|
description: "Analyze any website's design system \u2014 detects shadcn/ui, Tailwind, extracts colors, typography, and components via Playwright + Claude Vision.",
|
|
35184
35669
|
category: "Design & Branding",
|
|
35185
|
-
tags: ["design", "shadcn", "tailwind", "colors", "typography", "playwright", "analysis", "
|
|
35670
|
+
tags: ["design", "shadcn", "tailwind", "colors", "typography", "playwright", "analysis", "styles"]
|
|
35186
35671
|
}
|
|
35187
35672
|
];
|
|
35188
35673
|
|
|
@@ -35613,6 +36098,9 @@ function getPortableSkillsRoot(options = {}) {
|
|
|
35613
36098
|
if (options.rootDir)
|
|
35614
36099
|
return options.rootDir;
|
|
35615
36100
|
const appDir = options.homeDir ? join7(options.homeDir, ".hasna", "skills") : getDataDir();
|
|
36101
|
+
const cache3 = join7(appDir, SKILLS_CACHE_DIRNAME);
|
|
36102
|
+
if (isOwnerLayoutMigrated(appDir) && safeIsDirectory(cache3))
|
|
36103
|
+
return cache3;
|
|
35616
36104
|
const installed = join7(appDir, INSTALLED_SKILLS_DIRNAME);
|
|
35617
36105
|
migrateLegacySkillLayout(appDir, installed);
|
|
35618
36106
|
return installed;
|
|
@@ -35895,7 +36383,6 @@ import { join as join10 } from "path";
|
|
|
35895
36383
|
import { existsSync as existsSync6, readFileSync as readFileSync6, rmSync as rmSync2 } from "fs";
|
|
35896
36384
|
import { dirname as dirname7, join as join9 } from "path";
|
|
35897
36385
|
import { fileURLToPath as fileURLToPath2 } from "url";
|
|
35898
|
-
|
|
35899
36386
|
// src/lib/utils.ts
|
|
35900
36387
|
function normalizeSkillName(name) {
|
|
35901
36388
|
return name;
|
|
@@ -35996,6 +36483,17 @@ var MAX_SLUG_LENGTH = 128;
|
|
|
35996
36483
|
var MAX_SKILL_MD_BYTES = 512000;
|
|
35997
36484
|
var MAX_MANIFEST_BYTES = MAX_SKILL_MD_BYTES + 64000;
|
|
35998
36485
|
var ALLOWED_PUBLISH_PARTS = new Set(["manifest", "bundle"]);
|
|
36486
|
+
function pinPayload(pin) {
|
|
36487
|
+
return { slug: pin.slug, pinnedAt: pin.pinnedAt, metadata: pin.metadata };
|
|
36488
|
+
}
|
|
36489
|
+
function pinMetadataField(body) {
|
|
36490
|
+
if (body.metadata === undefined)
|
|
36491
|
+
return {};
|
|
36492
|
+
if (!body.metadata || typeof body.metadata !== "object" || Array.isArray(body.metadata)) {
|
|
36493
|
+
throw new SkillRequestError(400, "INVALID_METADATA", "`metadata` must be a JSON object");
|
|
36494
|
+
}
|
|
36495
|
+
return body.metadata;
|
|
36496
|
+
}
|
|
35999
36497
|
|
|
36000
36498
|
class SkillRequestError extends Error {
|
|
36001
36499
|
status;
|
|
@@ -36024,33 +36522,130 @@ function publishedPayload(record) {
|
|
|
36024
36522
|
...publishedSkillMeta(record),
|
|
36025
36523
|
slug: record.slug,
|
|
36026
36524
|
publishedSource: record.source,
|
|
36525
|
+
...record.skillMd ? { skillMd: record.skillMd } : {},
|
|
36027
36526
|
...record.bundleSha256 ? { bundleSha256: record.bundleSha256, bundleByteSize: record.bundleByteSize } : {},
|
|
36028
36527
|
publishedAt: record.createdAt,
|
|
36029
|
-
updatedAt: record.updatedAt
|
|
36528
|
+
updatedAt: record.updatedAt,
|
|
36529
|
+
revisionId: record.revisionId,
|
|
36530
|
+
revisionNumber: record.revisionNumber
|
|
36531
|
+
};
|
|
36532
|
+
}
|
|
36533
|
+
function revisionEtag(revisionId) {
|
|
36534
|
+
return `"${revisionId}"`;
|
|
36535
|
+
}
|
|
36536
|
+
function parseIfMatch(value) {
|
|
36537
|
+
if (value === null || value.trim() === "")
|
|
36538
|
+
return;
|
|
36539
|
+
const trimmed = value.trim();
|
|
36540
|
+
if (trimmed === "*") {
|
|
36541
|
+
throw new SkillRequestError(400, "INVALID_IF_MATCH", "If-Match must name the exact revision id (the ETag of the current revision); '*' is not accepted");
|
|
36542
|
+
}
|
|
36543
|
+
const unquoted = trimmed.length >= 2 && trimmed.startsWith('"') && trimmed.endsWith('"') ? trimmed.slice(1, -1) : trimmed;
|
|
36544
|
+
if (!REVISION_ID_PATTERN.test(unquoted)) {
|
|
36545
|
+
throw new SkillRequestError(400, "INVALID_IF_MATCH", "If-Match must carry a revision id: a 64-character lowercase hex sha-256, quoted as the server's ETag");
|
|
36546
|
+
}
|
|
36547
|
+
return unquoted;
|
|
36548
|
+
}
|
|
36549
|
+
async function tombstoneStatus(store, artifactStorage, principal, record) {
|
|
36550
|
+
if (!record.tombstonedAt)
|
|
36551
|
+
return "live";
|
|
36552
|
+
if (record.tombstonePurgeAfter && record.tombstonePurgeAfter <= new Date().toISOString()) {
|
|
36553
|
+
const purged = await store.purgeExpiredTombstones(principal);
|
|
36554
|
+
for (const removed of purged) {
|
|
36555
|
+
if (removed.bundleSha256)
|
|
36556
|
+
await discardCollectedObject(store, artifactStorage, principal, removed.bundleSha256);
|
|
36557
|
+
}
|
|
36558
|
+
return "purged";
|
|
36559
|
+
}
|
|
36560
|
+
return {
|
|
36561
|
+
slug: record.slug,
|
|
36562
|
+
deleted: true,
|
|
36563
|
+
code: "TOMBSTONED",
|
|
36564
|
+
tombstonedAt: record.tombstonedAt,
|
|
36565
|
+
tombstonePurgeAfter: record.tombstonePurgeAfter,
|
|
36566
|
+
revisionId: record.revisionId
|
|
36030
36567
|
};
|
|
36031
36568
|
}
|
|
36032
36569
|
async function listMergedSkills(store, principal) {
|
|
36033
36570
|
const published = await store.listSkills(principal);
|
|
36571
|
+
return mergedSkillPayloads(published, listServerSkills());
|
|
36572
|
+
}
|
|
36573
|
+
function mergedSkillPayloads(published, bundled) {
|
|
36034
36574
|
const publishedBySlug = new Map(published.map((record) => [record.slug, record]));
|
|
36035
|
-
const merged = mergeSkillRegistryLists(
|
|
36575
|
+
const merged = mergeSkillRegistryLists(bundled, published.map(publishedSkillMeta));
|
|
36036
36576
|
return merged.map((skill) => {
|
|
36037
36577
|
const record = publishedBySlug.get(skill.name);
|
|
36038
36578
|
return record ? publishedPayload(record) : skill;
|
|
36039
36579
|
});
|
|
36040
36580
|
}
|
|
36041
|
-
async function
|
|
36581
|
+
async function resolvePublishedSkill(store, artifactStorage, principal, slug) {
|
|
36042
36582
|
const record = await store.getSkill(principal, slug);
|
|
36043
|
-
if (record)
|
|
36044
|
-
return
|
|
36583
|
+
if (!record)
|
|
36584
|
+
return { kind: "absent" };
|
|
36585
|
+
const status = await tombstoneStatus(store, artifactStorage, principal, record);
|
|
36586
|
+
if (status === "purged")
|
|
36587
|
+
return { kind: "absent" };
|
|
36588
|
+
if (status !== "live")
|
|
36589
|
+
return { kind: "tombstone", payload: status };
|
|
36590
|
+
return { kind: "published", record };
|
|
36591
|
+
}
|
|
36592
|
+
async function listOrgTags(store, principal) {
|
|
36593
|
+
const publishedSlugs = await store.listPublishedSlugs(principal);
|
|
36594
|
+
const tags = new Set;
|
|
36595
|
+
for (const tag of await store.listTags(principal)) {
|
|
36596
|
+
if (tag.trim())
|
|
36597
|
+
tags.add(tag);
|
|
36598
|
+
}
|
|
36599
|
+
for (const skill of listServerSkills()) {
|
|
36600
|
+
if (publishedSlugs.includes(skill.name))
|
|
36601
|
+
continue;
|
|
36602
|
+
for (const tag of skill.tags) {
|
|
36603
|
+
if (tag.trim())
|
|
36604
|
+
tags.add(tag);
|
|
36605
|
+
}
|
|
36606
|
+
}
|
|
36607
|
+
return [...tags].sort();
|
|
36608
|
+
}
|
|
36609
|
+
async function listMergedSkillsByTag(store, principal, tag) {
|
|
36610
|
+
const published = await store.listSkillsByTag(principal, tag);
|
|
36611
|
+
const publishedSlugs = await store.listPublishedSlugs(principal);
|
|
36612
|
+
const bundled = listServerSkills().filter((skill) => skill.tags.includes(tag) && !publishedSlugs.includes(skill.name));
|
|
36613
|
+
return mergedSkillPayloads(published, bundled);
|
|
36614
|
+
}
|
|
36615
|
+
function skillSummary(skill) {
|
|
36616
|
+
return {
|
|
36617
|
+
slug: String(skill.slug ?? skill.name),
|
|
36618
|
+
...typeof skill.name === "string" ? { name: skill.name } : {},
|
|
36619
|
+
...typeof skill.version === "string" ? { version: skill.version } : {},
|
|
36620
|
+
...typeof skill.updatedAt === "string" ? { updatedAt: skill.updatedAt } : {}
|
|
36621
|
+
};
|
|
36622
|
+
}
|
|
36623
|
+
async function listPinsByTag(store, principal, tag) {
|
|
36624
|
+
const publishedSlugs = await store.listPublishedSlugs(principal);
|
|
36625
|
+
const bundledTaggedSlugs = new Set;
|
|
36626
|
+
for (const skill of listServerSkills()) {
|
|
36627
|
+
if (skill.tags.includes(tag) && !publishedSlugs.includes(skill.name))
|
|
36628
|
+
bundledTaggedSlugs.add(skill.name);
|
|
36629
|
+
}
|
|
36630
|
+
const publishedPins = await store.listPinsByTag(principal, tag);
|
|
36631
|
+
const bundledPins = bundledTaggedSlugs.size ? (await store.listPins(principal)).filter((pin) => bundledTaggedSlugs.has(pin.slug)) : [];
|
|
36632
|
+
return [...publishedPins, ...bundledPins].sort((a3, b3) => a3.slug.localeCompare(b3.slug)).map(pinPayload);
|
|
36633
|
+
}
|
|
36634
|
+
async function getMergedSkill(store, artifactStorage, principal, slug) {
|
|
36635
|
+
const resolved = await resolvePublishedSkill(store, artifactStorage, principal, slug);
|
|
36636
|
+
if (resolved.kind === "tombstone")
|
|
36637
|
+
return resolved.payload;
|
|
36638
|
+
if (resolved.kind === "published")
|
|
36639
|
+
return publishedPayload(resolved.record);
|
|
36045
36640
|
const bundled = getServerSkill(slug);
|
|
36046
36641
|
return bundled ? bundled : null;
|
|
36047
36642
|
}
|
|
36048
|
-
async function getMergedSkillMd(store, principal, slug) {
|
|
36049
|
-
const
|
|
36050
|
-
if (
|
|
36051
|
-
return record.skillMd;
|
|
36052
|
-
if (record)
|
|
36643
|
+
async function getMergedSkillMd(store, artifactStorage, principal, slug) {
|
|
36644
|
+
const resolved = await resolvePublishedSkill(store, artifactStorage, principal, slug);
|
|
36645
|
+
if (resolved.kind === "tombstone")
|
|
36053
36646
|
return null;
|
|
36647
|
+
if (resolved.kind === "published")
|
|
36648
|
+
return resolved.record.skillMd ?? null;
|
|
36054
36649
|
return getServerSkillMd(slug);
|
|
36055
36650
|
}
|
|
36056
36651
|
async function parsePublishRequest(request, config) {
|
|
@@ -36092,7 +36687,7 @@ async function parsePublishRequest(request, config) {
|
|
|
36092
36687
|
if (bundleBytes.byteLength === 0) {
|
|
36093
36688
|
throw new SkillRequestError(400, "BUNDLE_EMPTY", "the uploaded bundle is empty");
|
|
36094
36689
|
}
|
|
36095
|
-
const sha256 =
|
|
36690
|
+
const sha256 = createHash6("sha256").update(bundleBytes).digest("hex");
|
|
36096
36691
|
const claimed = optionalString(manifest.bundleSha256);
|
|
36097
36692
|
if (claimed)
|
|
36098
36693
|
assertSha2562(claimed);
|
|
@@ -36120,9 +36715,13 @@ async function parsePublishRequest(request, config) {
|
|
|
36120
36715
|
}
|
|
36121
36716
|
return { input: buildPublishInput(parseManifestJson(text)) };
|
|
36122
36717
|
}
|
|
36123
|
-
async function storePublishedSkill(store, artifactStorage, principal, parsed) {
|
|
36718
|
+
async function storePublishedSkill(store, artifactStorage, principal, parsed, expectedRevisionId) {
|
|
36124
36719
|
const superseded = (await store.getSkill(principal, parsed.input.slug))?.bundleSha256;
|
|
36125
|
-
let input = {
|
|
36720
|
+
let input = {
|
|
36721
|
+
...parsed.input,
|
|
36722
|
+
principal,
|
|
36723
|
+
...expectedRevisionId ? { expectedRevisionId } : {}
|
|
36724
|
+
};
|
|
36126
36725
|
if (parsed.bundleBytes && input.bundle) {
|
|
36127
36726
|
const placement = await artifactStorage.putBundle(principal.orgId, input.bundle.sha256, parsed.bundleBytes, input.bundle.contentType);
|
|
36128
36727
|
input = { ...input, bundle: { ...input.bundle, ...placement } };
|
|
@@ -36133,15 +36732,8 @@ async function storePublishedSkill(store, artifactStorage, principal, parsed) {
|
|
|
36133
36732
|
}
|
|
36134
36733
|
return record;
|
|
36135
36734
|
}
|
|
36136
|
-
async function deletePublishedSkill(store, artifactStorage, principal, slug) {
|
|
36137
|
-
|
|
36138
|
-
if (!record)
|
|
36139
|
-
return false;
|
|
36140
|
-
const deleted = await store.deleteSkill(principal, slug);
|
|
36141
|
-
if (deleted && record.bundleSha256) {
|
|
36142
|
-
await discardCollectedObject(store, artifactStorage, principal, record.bundleSha256);
|
|
36143
|
-
}
|
|
36144
|
-
return deleted;
|
|
36735
|
+
async function deletePublishedSkill(store, artifactStorage, principal, slug, tombstoneWindowMs) {
|
|
36736
|
+
return store.deleteSkill(principal, slug, tombstoneWindowMs);
|
|
36145
36737
|
}
|
|
36146
36738
|
async function discardCollectedObject(store, artifactStorage, principal, sha256) {
|
|
36147
36739
|
if (await store.getSkillBundle(principal, sha256))
|
|
@@ -36161,7 +36753,7 @@ async function readPublishedBundle(store, artifactStorage, principal, slug) {
|
|
|
36161
36753
|
if (!bytes) {
|
|
36162
36754
|
throw new SkillRequestError(503, "BUNDLE_BACKEND_UNAVAILABLE", "bundle storage backend unavailable");
|
|
36163
36755
|
}
|
|
36164
|
-
const actual =
|
|
36756
|
+
const actual = createHash6("sha256").update(bytes).digest("hex");
|
|
36165
36757
|
if (actual !== record.bundleSha256) {
|
|
36166
36758
|
throw new SkillRequestError(500, "BUNDLE_DIGEST_DRIFT", `stored bundle for '${slug}' hashes to ${actual} but was published as ${record.bundleSha256}`);
|
|
36167
36759
|
}
|
|
@@ -36277,10 +36869,10 @@ async function createSkillsFetchHandler(options = {}) {
|
|
|
36277
36869
|
const segments = pathSegments(url.pathname);
|
|
36278
36870
|
try {
|
|
36279
36871
|
if (request.method === "GET" && url.pathname === "/health") {
|
|
36280
|
-
return json({ ok: true, service: "
|
|
36872
|
+
return json({ ok: true, service: "skills", time: new Date().toISOString() });
|
|
36281
36873
|
}
|
|
36282
36874
|
if (request.method === "GET" && url.pathname === "/ready") {
|
|
36283
|
-
return json({ ok: true, service: "
|
|
36875
|
+
return json({ ok: true, service: "skills" });
|
|
36284
36876
|
}
|
|
36285
36877
|
if (url.pathname.startsWith("/api/")) {
|
|
36286
36878
|
const principal = await authenticateRequest(store, request);
|
|
@@ -36298,6 +36890,14 @@ async function createSkillsFetchHandler(options = {}) {
|
|
|
36298
36890
|
if (error instanceof SkillRequestError) {
|
|
36299
36891
|
return json({ error: error.message, code: error.code }, { status: error.status });
|
|
36300
36892
|
}
|
|
36893
|
+
if (error instanceof SkillRevisionConflictError) {
|
|
36894
|
+
return json({
|
|
36895
|
+
error: error.message,
|
|
36896
|
+
code: "REVISION_CONFLICT",
|
|
36897
|
+
slug: error.slug,
|
|
36898
|
+
...error.currentRevisionId ? { currentRevisionId: error.currentRevisionId } : {}
|
|
36899
|
+
}, { status: 409 });
|
|
36900
|
+
}
|
|
36301
36901
|
return json({ error: "internal server error", detail: error.message }, { status: 500 });
|
|
36302
36902
|
}
|
|
36303
36903
|
};
|
|
@@ -36317,24 +36917,43 @@ async function handleApiV1(store, principal, request, parts, config, artifactSto
|
|
|
36317
36917
|
return json({ error: "invalid path segment", code: "INVALID_PATH" }, { status: 400 });
|
|
36318
36918
|
}
|
|
36319
36919
|
if (resource === "skills") {
|
|
36320
|
-
if (request.method === "GET" && !id)
|
|
36920
|
+
if (request.method === "GET" && !id) {
|
|
36921
|
+
const tag = new URL(request.url).searchParams.get("tag");
|
|
36922
|
+
if (tag !== null && tag !== "")
|
|
36923
|
+
return json(await listMergedSkillsByTag(store, principal, tag));
|
|
36321
36924
|
return json(await listMergedSkills(store, principal));
|
|
36925
|
+
}
|
|
36322
36926
|
if (request.method === "POST" && !id) {
|
|
36927
|
+
const expectedRevisionId = parseIfMatch(request.headers.get("if-match"));
|
|
36323
36928
|
const parsed = await parsePublishRequest(request, config);
|
|
36324
|
-
const record = await storePublishedSkill(store, artifactStorage, principal, parsed);
|
|
36325
|
-
return json(publishedPayload(record), { status: 201 });
|
|
36929
|
+
const record = await storePublishedSkill(store, artifactStorage, principal, parsed, expectedRevisionId);
|
|
36930
|
+
return json(publishedPayload(record), { status: 201, headers: { ETag: revisionEtag(record.revisionId) } });
|
|
36326
36931
|
}
|
|
36327
36932
|
if (request.method === "GET" && id && subresource === "skill.md") {
|
|
36328
|
-
const
|
|
36933
|
+
const resolved = await resolvePublishedSkill(store, artifactStorage, principal, id);
|
|
36934
|
+
if (resolved.kind === "tombstone") {
|
|
36935
|
+
return json({ error: "skill was deleted", ...resolved.payload }, { status: 410 });
|
|
36936
|
+
}
|
|
36937
|
+
const docs = await getMergedSkillMd(store, artifactStorage, principal, id);
|
|
36329
36938
|
return docs ? new Response(docs, { headers: { "Content-Type": "text/markdown; charset=utf-8", "Cache-Control": "no-store" } }) : json({ error: "skill not found", code: "SKILL_NOT_FOUND" }, { status: 404 });
|
|
36330
36939
|
}
|
|
36331
36940
|
if (request.method === "GET" && id && subresource === "bundle") {
|
|
36941
|
+
const resolved = await resolvePublishedSkill(store, artifactStorage, principal, id);
|
|
36942
|
+
if (resolved.kind === "tombstone") {
|
|
36943
|
+
return json({ error: "skill was deleted", ...resolved.payload }, { status: 410 });
|
|
36944
|
+
}
|
|
36945
|
+
if (resolved.kind === "absent") {
|
|
36946
|
+
return json({ error: "skill not found", code: "SKILL_NOT_FOUND" }, { status: 404 });
|
|
36947
|
+
}
|
|
36332
36948
|
const { record, bytes } = await readPublishedBundle(store, artifactStorage, principal, id);
|
|
36333
36949
|
const headers = {
|
|
36334
36950
|
"Content-Type": "application/gzip",
|
|
36335
36951
|
"Content-Length": String(bytes.byteLength),
|
|
36336
36952
|
"Content-Disposition": `attachment; filename="${record.slug}.tar.gz"`,
|
|
36337
36953
|
"X-Skill-Bundle-Sha256": record.bundleSha256 ?? "",
|
|
36954
|
+
"X-Skill-Revision-Id": record.revisionId,
|
|
36955
|
+
"X-Skill-Revision-Number": String(record.revisionNumber),
|
|
36956
|
+
ETag: revisionEtag(record.revisionId),
|
|
36338
36957
|
"Cache-Control": "no-store"
|
|
36339
36958
|
};
|
|
36340
36959
|
if (config.bundleSigningKey) {
|
|
@@ -36343,17 +36962,56 @@ async function handleApiV1(store, principal, request, parts, config, artifactSto
|
|
|
36343
36962
|
return new Response(bytes, { headers });
|
|
36344
36963
|
}
|
|
36345
36964
|
if (request.method === "GET" && id && !subresource) {
|
|
36346
|
-
const
|
|
36965
|
+
const resolved = await resolvePublishedSkill(store, artifactStorage, principal, id);
|
|
36966
|
+
if (resolved.kind === "tombstone") {
|
|
36967
|
+
return json({ error: "skill was deleted", ...resolved.payload }, { status: 410 });
|
|
36968
|
+
}
|
|
36969
|
+
if (resolved.kind === "published") {
|
|
36970
|
+
return json(publishedPayload(resolved.record), { headers: { ETag: revisionEtag(resolved.record.revisionId) } });
|
|
36971
|
+
}
|
|
36972
|
+
const skill = await getMergedSkill(store, artifactStorage, principal, id);
|
|
36347
36973
|
return skill ? json(skill) : json({ error: "skill not found", code: "SKILL_NOT_FOUND" }, { status: 404 });
|
|
36348
36974
|
}
|
|
36349
36975
|
if ((request.method === "PUT" || request.method === "PATCH") && id && !subresource) {
|
|
36976
|
+
const expectedRevisionId = parseIfMatch(request.headers.get("if-match"));
|
|
36350
36977
|
const body = await readJson(request, config.requestBodyLimitBytes);
|
|
36351
|
-
const updated = await store.updateSkill(principal, id, skillPatch(body));
|
|
36352
|
-
return updated ? json(publishedPayload(updated)) : json({ error: "published skill not found", code: "SKILL_NOT_FOUND" }, { status: 404 });
|
|
36978
|
+
const updated = await store.updateSkill(principal, id, skillPatch(body), expectedRevisionId);
|
|
36979
|
+
return updated ? json(publishedPayload(updated), { headers: { ETag: revisionEtag(updated.revisionId) } }) : json({ error: "published skill not found", code: "SKILL_NOT_FOUND" }, { status: 404 });
|
|
36353
36980
|
}
|
|
36354
36981
|
if (request.method === "DELETE" && id && !subresource) {
|
|
36355
|
-
const removed = await deletePublishedSkill(store, artifactStorage, principal, id);
|
|
36356
|
-
return removed ? json({
|
|
36982
|
+
const removed = await deletePublishedSkill(store, artifactStorage, principal, id, config.tombstoneWindowMs);
|
|
36983
|
+
return removed ? json({
|
|
36984
|
+
deleted: true,
|
|
36985
|
+
slug: id,
|
|
36986
|
+
...removed.tombstonedAt ? { tombstonedAt: removed.tombstonedAt, tombstonePurgeAfter: removed.tombstonePurgeAfter } : {}
|
|
36987
|
+
}) : json({ error: "published skill not found", code: "SKILL_NOT_FOUND" }, { status: 404 });
|
|
36988
|
+
}
|
|
36989
|
+
}
|
|
36990
|
+
if (resource === "pins") {
|
|
36991
|
+
if (request.method === "GET" && !id) {
|
|
36992
|
+
const tag = new URL(request.url).searchParams.get("tag");
|
|
36993
|
+
if (tag !== null && tag !== "")
|
|
36994
|
+
return json(await listPinsByTag(store, principal, tag));
|
|
36995
|
+
return json((await store.listPins(principal)).map(pinPayload));
|
|
36996
|
+
}
|
|
36997
|
+
if (request.method === "PUT" && id && !subresource) {
|
|
36998
|
+
assertPublishableSlug(id);
|
|
36999
|
+
const body = await readJson(request, config.requestBodyLimitBytes);
|
|
37000
|
+
const pin = await store.pinSkill(principal, id, pinMetadataField(body));
|
|
37001
|
+
return json(pinPayload(pin));
|
|
37002
|
+
}
|
|
37003
|
+
if (request.method === "DELETE" && id && !subresource) {
|
|
37004
|
+
assertPublishableSlug(id);
|
|
37005
|
+
const removed = await store.unpinSkill(principal, id);
|
|
37006
|
+
return removed ? json({ deleted: true, slug: id }) : json({ error: "pin not found", code: "PIN_NOT_FOUND" }, { status: 404 });
|
|
37007
|
+
}
|
|
37008
|
+
}
|
|
37009
|
+
if (resource === "tags") {
|
|
37010
|
+
if (request.method === "GET" && !id) {
|
|
37011
|
+
return json(await listOrgTags(store, principal));
|
|
37012
|
+
}
|
|
37013
|
+
if (request.method === "GET" && id && subresource === "skills") {
|
|
37014
|
+
return json((await listMergedSkillsByTag(store, principal, id)).map(skillSummary));
|
|
36357
37015
|
}
|
|
36358
37016
|
}
|
|
36359
37017
|
if (resource === "runs") {
|
|
@@ -36505,5 +37163,5 @@ function clampInt(value, fallback, max) {
|
|
|
36505
37163
|
// src/server/index.ts
|
|
36506
37164
|
var config = resolveServerConfig();
|
|
36507
37165
|
var server = await startSkillsServer({ config });
|
|
36508
|
-
console.log(`
|
|
37166
|
+
console.log(`skills API listening on http://${config.host}:${server.port}`);
|
|
36509
37167
|
console.log(`storage: ${resolveDatabaseTarget(config.databaseUrl).label}`);
|