@desplega.ai/agent-swarm 1.78.1 → 1.79.1

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 (75) hide show
  1. package/README.md +1 -0
  2. package/openapi.json +1335 -236
  3. package/package.json +4 -4
  4. package/plugin/skills/artifacts/SKILL.md +151 -0
  5. package/plugin/skills/artifacts/examples/static-report.sh +1 -1
  6. package/plugin/skills/kv-storage/SKILL.md +168 -0
  7. package/plugin/skills/pages/SKILL.md +423 -0
  8. package/src/artifact-sdk/browser-sdk.ts +396 -19
  9. package/src/be/db.ts +548 -0
  10. package/src/be/migrations/059_pages.sql +34 -0
  11. package/src/be/migrations/060_page_versions.sql +19 -0
  12. package/src/be/migrations/061_kv_store.sql +34 -0
  13. package/src/be/migrations/062_pages_view_count.sql +9 -0
  14. package/src/commands/artifact.ts +17 -11
  15. package/src/commands/provider-credentials.ts +1 -1
  16. package/src/http/index.ts +9 -1
  17. package/src/http/kv.ts +658 -0
  18. package/src/http/page-proxy.ts +213 -0
  19. package/src/http/pages-public.ts +507 -0
  20. package/src/http/pages.ts +608 -0
  21. package/src/http/status.ts +1 -1
  22. package/src/http/utils.ts +68 -5
  23. package/src/pages/version.ts +44 -0
  24. package/src/prompts/session-templates.ts +51 -0
  25. package/src/providers/pi-mono-adapter.ts +3 -3
  26. package/src/providers/pi-mono-extension.ts +1 -1
  27. package/src/server.ts +29 -1
  28. package/src/tasks/context-key.ts +28 -0
  29. package/src/telemetry.ts +65 -1
  30. package/src/tests/artifact-commands.test.ts +92 -0
  31. package/src/tests/artifact-sdk.test.ts +80 -74
  32. package/src/tests/context-key.test.ts +17 -0
  33. package/src/tests/create-page-tool.test.ts +197 -0
  34. package/src/tests/fixtures/sample-json-page.json +52 -0
  35. package/src/tests/kv-http.test.ts +331 -0
  36. package/src/tests/kv-namespace-resolution.test.ts +172 -0
  37. package/src/tests/kv-page-proxy.test.ts +212 -0
  38. package/src/tests/kv-storage.test.ts +227 -0
  39. package/src/tests/kv-tool.test.ts +217 -0
  40. package/src/tests/launch-password-rejection.test.ts +139 -0
  41. package/src/tests/page-proxy-authed.test.ts +146 -0
  42. package/src/tests/page-proxy.test.ts +270 -0
  43. package/src/tests/page-session.test.ts +169 -0
  44. package/src/tests/pages-actions-endpoint.test.ts +102 -0
  45. package/src/tests/pages-authed-mode.test.ts +211 -0
  46. package/src/tests/pages-http.test.ts +193 -0
  47. package/src/tests/pages-list-endpoint.test.ts +149 -0
  48. package/src/tests/pages-password-hash.test.ts +57 -0
  49. package/src/tests/pages-password-mode.test.ts +265 -0
  50. package/src/tests/pages-public-authed-401.test.ts +102 -0
  51. package/src/tests/pages-public-html.test.ts +151 -0
  52. package/src/tests/pages-public-json-redirect.test.ts +86 -0
  53. package/src/tests/pages-storage.test.ts +196 -0
  54. package/src/tests/pages-versioning.test.ts +231 -0
  55. package/src/tests/pages-view-count.test.ts +220 -0
  56. package/src/tests/prompt-template-session.test.ts +3 -2
  57. package/src/tests/skill-update-scope.test.ts +165 -0
  58. package/src/tests/swarm-diff.test.ts +303 -0
  59. package/src/tests/telemetry-init.test.ts +149 -0
  60. package/src/tests/workflow-wait-event.test.ts +4 -7
  61. package/src/tools/create-page.ts +263 -0
  62. package/src/tools/kv/index.ts +5 -0
  63. package/src/tools/kv/kv-delete.ts +89 -0
  64. package/src/tools/kv/kv-get.ts +64 -0
  65. package/src/tools/kv/kv-incr.ts +116 -0
  66. package/src/tools/kv/kv-list.ts +81 -0
  67. package/src/tools/kv/kv-set.ts +194 -0
  68. package/src/tools/kv/resolve-namespace.ts +58 -0
  69. package/src/tools/skills/skill-update.ts +26 -0
  70. package/src/tools/tool-config.ts +10 -0
  71. package/src/types.ts +107 -0
  72. package/src/utils/internal-ai/complete-structured.ts +2 -2
  73. package/src/utils/internal-ai/credentials.ts +3 -3
  74. package/src/utils/page-session.ts +254 -0
  75. package/plugin/skills/artifacts/skill.md +0 -70
package/src/be/db.ts CHANGED
@@ -34,10 +34,17 @@ import type {
34
34
  InboxMessage,
35
35
  InboxMessageStatus,
36
36
  InputValue,
37
+ KvEntry,
38
+ KvValueType,
37
39
  McpServer,
38
40
  McpServerScope,
39
41
  McpServerTransport,
40
42
  McpServerWithInstallInfo,
43
+ Page,
44
+ PageAuthMode,
45
+ PageContentType,
46
+ PageSnapshot,
47
+ PageVersion,
41
48
  PricingProvider,
42
49
  PricingRow,
43
50
  PricingTokenClass,
@@ -6237,6 +6244,255 @@ export function getWorkflowVersion(workflowId: string, version: number): Workflo
6237
6244
  return row ? rowToWorkflowVersion(row) : null;
6238
6245
  }
6239
6246
 
6247
+ // ============================================================================
6248
+ // Pages CRUD + version history
6249
+ // ----------------------------------------------------------------------------
6250
+ // DB-backed lightweight artifacts. Mirrors the workflow versioning pattern:
6251
+ // parent table `pages` holds the CURRENT state, history table `page_versions`
6252
+ // holds pre-update snapshots. snapshotPage() (src/pages/version.ts) MUST be
6253
+ // called BEFORE updatePage() so the snapshot freezes pre-update content.
6254
+ // ============================================================================
6255
+
6256
+ type PageRow = {
6257
+ id: string;
6258
+ agentId: string;
6259
+ slug: string;
6260
+ title: string;
6261
+ description: string | null;
6262
+ contentType: string;
6263
+ authMode: string;
6264
+ passwordHash: string | null;
6265
+ body: string;
6266
+ needsCredentials: string | null;
6267
+ createdAt: string;
6268
+ updatedAt: string;
6269
+ view_count: number;
6270
+ };
6271
+
6272
+ function rowToPage(row: PageRow): Page {
6273
+ return {
6274
+ id: row.id,
6275
+ agentId: row.agentId,
6276
+ slug: row.slug,
6277
+ title: row.title,
6278
+ description: row.description ?? undefined,
6279
+ contentType: row.contentType as PageContentType,
6280
+ authMode: row.authMode as PageAuthMode,
6281
+ passwordHash: row.passwordHash ?? undefined,
6282
+ body: row.body,
6283
+ needsCredentials: row.needsCredentials
6284
+ ? (JSON.parse(row.needsCredentials) as string[])
6285
+ : undefined,
6286
+ viewCount: typeof row.view_count === "number" ? row.view_count : 0,
6287
+ createdAt: normalizeDateRequired(row.createdAt),
6288
+ updatedAt: normalizeDateRequired(row.updatedAt),
6289
+ };
6290
+ }
6291
+
6292
+ export function createPage(data: {
6293
+ agentId: string;
6294
+ slug: string;
6295
+ title: string;
6296
+ description?: string;
6297
+ contentType: PageContentType;
6298
+ authMode: PageAuthMode;
6299
+ passwordHash?: string;
6300
+ body: string;
6301
+ needsCredentials?: string[];
6302
+ }): Page {
6303
+ const row = getDb()
6304
+ .prepare<
6305
+ PageRow,
6306
+ [string, string, string, string | null, string, string, string | null, string, string | null]
6307
+ >(
6308
+ `INSERT INTO pages (agentId, slug, title, description, contentType, authMode, passwordHash, body, needsCredentials)
6309
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING *`,
6310
+ )
6311
+ .get(
6312
+ data.agentId,
6313
+ data.slug,
6314
+ data.title,
6315
+ data.description ?? null,
6316
+ data.contentType,
6317
+ data.authMode,
6318
+ data.passwordHash ?? null,
6319
+ data.body,
6320
+ data.needsCredentials ? JSON.stringify(data.needsCredentials) : null,
6321
+ );
6322
+ if (!row) throw new Error("Failed to create page");
6323
+ return rowToPage(row);
6324
+ }
6325
+
6326
+ export function getPage(id: string): Page | null {
6327
+ const row = getDb().prepare<PageRow, [string]>("SELECT * FROM pages WHERE id = ?").get(id);
6328
+ return row ? rowToPage(row) : null;
6329
+ }
6330
+
6331
+ export function getPageBySlug(agentId: string, slug: string): Page | null {
6332
+ const row = getDb()
6333
+ .prepare<PageRow, [string, string]>("SELECT * FROM pages WHERE agentId = ? AND slug = ?")
6334
+ .get(agentId, slug);
6335
+ return row ? rowToPage(row) : null;
6336
+ }
6337
+
6338
+ export function listPagesByAgent(agentId: string, limit = 100, offset = 0): Page[] {
6339
+ return getDb()
6340
+ .prepare<PageRow, [string, number, number]>(
6341
+ "SELECT * FROM pages WHERE agentId = ? ORDER BY updatedAt DESC LIMIT ? OFFSET ?",
6342
+ )
6343
+ .all(agentId, limit, offset)
6344
+ .map(rowToPage);
6345
+ }
6346
+
6347
+ export function listAllPages(limit = 100, offset = 0): Page[] {
6348
+ return getDb()
6349
+ .prepare<PageRow, [number, number]>(
6350
+ "SELECT * FROM pages ORDER BY updatedAt DESC LIMIT ? OFFSET ?",
6351
+ )
6352
+ .all(limit, offset)
6353
+ .map(rowToPage);
6354
+ }
6355
+
6356
+ /**
6357
+ * Apply a patch to a page. Does NOT snapshot — caller must invoke
6358
+ * `snapshotPage(id, agentId)` BEFORE calling this to preserve pre-update
6359
+ * state (mirrors the workflow update pattern at src/http/workflows.ts:483).
6360
+ *
6361
+ * Always bumps `updatedAt` even if no other field changed (keeps the index
6362
+ * useful for list ordering).
6363
+ */
6364
+ export function updatePage(
6365
+ id: string,
6366
+ data: {
6367
+ title?: string;
6368
+ description?: string | null;
6369
+ contentType?: PageContentType;
6370
+ authMode?: PageAuthMode;
6371
+ passwordHash?: string | null;
6372
+ body?: string;
6373
+ needsCredentials?: string[] | null;
6374
+ slug?: string;
6375
+ },
6376
+ ): Page | null {
6377
+ const updates: string[] = [];
6378
+ const params: (string | number | null)[] = [];
6379
+ if (data.title !== undefined) {
6380
+ updates.push("title = ?");
6381
+ params.push(data.title);
6382
+ }
6383
+ if (data.description !== undefined) {
6384
+ updates.push("description = ?");
6385
+ params.push(data.description ?? null);
6386
+ }
6387
+ if (data.contentType !== undefined) {
6388
+ updates.push("contentType = ?");
6389
+ params.push(data.contentType);
6390
+ }
6391
+ if (data.authMode !== undefined) {
6392
+ updates.push("authMode = ?");
6393
+ params.push(data.authMode);
6394
+ }
6395
+ if (data.passwordHash !== undefined) {
6396
+ updates.push("passwordHash = ?");
6397
+ params.push(data.passwordHash ?? null);
6398
+ }
6399
+ if (data.body !== undefined) {
6400
+ updates.push("body = ?");
6401
+ params.push(data.body);
6402
+ }
6403
+ if (data.needsCredentials !== undefined) {
6404
+ updates.push("needsCredentials = ?");
6405
+ params.push(data.needsCredentials ? JSON.stringify(data.needsCredentials) : null);
6406
+ }
6407
+ if (data.slug !== undefined) {
6408
+ updates.push("slug = ?");
6409
+ params.push(data.slug);
6410
+ }
6411
+ if (updates.length === 0) return getPage(id);
6412
+ updates.push("updatedAt = ?");
6413
+ params.push(new Date().toISOString());
6414
+ params.push(id);
6415
+ const row = getDb()
6416
+ .prepare<PageRow, (string | number | null)[]>(
6417
+ `UPDATE pages SET ${updates.join(", ")} WHERE id = ? RETURNING *`,
6418
+ )
6419
+ .get(...params);
6420
+ return row ? rowToPage(row) : null;
6421
+ }
6422
+
6423
+ export function deletePage(id: string): boolean {
6424
+ // ON DELETE CASCADE on page_versions.pageId handles history cleanup.
6425
+ const result = getDb().run("DELETE FROM pages WHERE id = ?", [id]);
6426
+ return result.changes > 0;
6427
+ }
6428
+
6429
+ /**
6430
+ * Bump the `view_count` counter on a page by 1. Called from `pages-public.ts`
6431
+ * on every successful 200 from `GET /p/:id` (HTML inline serve) and
6432
+ * `GET /p/:id.json` (JSON metadata fetch). No-op when the page doesn't
6433
+ * exist — caller already guards on `getPage(id)` before reaching the bump
6434
+ * path, so this only fires for valid ids. Wrapped in try/catch by the
6435
+ * caller so an unexpected DB error never breaks page serving.
6436
+ */
6437
+ export function incrementPageViewCount(id: string): boolean {
6438
+ const result = getDb().run("UPDATE pages SET view_count = view_count + 1 WHERE id = ?", [id]);
6439
+ return result.changes > 0;
6440
+ }
6441
+
6442
+ type PageVersionRow = {
6443
+ id: string;
6444
+ pageId: string;
6445
+ version: number;
6446
+ snapshot: string;
6447
+ changedByAgentId: string | null;
6448
+ createdAt: string;
6449
+ };
6450
+
6451
+ function rowToPageVersion(row: PageVersionRow): PageVersion {
6452
+ return {
6453
+ id: row.id,
6454
+ pageId: row.pageId,
6455
+ version: row.version,
6456
+ snapshot: JSON.parse(row.snapshot) as PageSnapshot,
6457
+ changedByAgentId: row.changedByAgentId ?? undefined,
6458
+ createdAt: normalizeDateRequired(row.createdAt),
6459
+ };
6460
+ }
6461
+
6462
+ export function createPageVersion(data: {
6463
+ pageId: string;
6464
+ version: number;
6465
+ snapshot: PageSnapshot;
6466
+ changedByAgentId?: string;
6467
+ }): PageVersion {
6468
+ const row = getDb()
6469
+ .prepare<PageVersionRow, [string, number, string, string | null]>(
6470
+ `INSERT INTO page_versions (pageId, version, snapshot, changedByAgentId)
6471
+ VALUES (?, ?, ?, ?) RETURNING *`,
6472
+ )
6473
+ .get(data.pageId, data.version, JSON.stringify(data.snapshot), data.changedByAgentId ?? null);
6474
+ if (!row) throw new Error("Failed to create page version");
6475
+ return rowToPageVersion(row);
6476
+ }
6477
+
6478
+ export function getPageVersions(pageId: string): PageVersion[] {
6479
+ return getDb()
6480
+ .prepare<PageVersionRow, [string]>(
6481
+ "SELECT * FROM page_versions WHERE pageId = ? ORDER BY version DESC",
6482
+ )
6483
+ .all(pageId)
6484
+ .map(rowToPageVersion);
6485
+ }
6486
+
6487
+ export function getPageVersion(pageId: string, version: number): PageVersion | null {
6488
+ const row = getDb()
6489
+ .prepare<PageVersionRow, [string, number]>(
6490
+ "SELECT * FROM page_versions WHERE pageId = ? AND version = ?",
6491
+ )
6492
+ .get(pageId, version);
6493
+ return row ? rowToPageVersion(row) : null;
6494
+ }
6495
+
6240
6496
  // ============================================================================
6241
6497
  // Prompt Template Operations
6242
6498
  // ============================================================================
@@ -9439,3 +9695,295 @@ export function hasFirstCompletedTask(): boolean {
9439
9695
  .get();
9440
9696
  return row !== null;
9441
9697
  }
9698
+
9699
+ // ============================================================================
9700
+ // KV store (kv_entries)
9701
+ // ============================================================================
9702
+ //
9703
+ // Namespaced key/value with lazy expire-on-read TTL. See:
9704
+ // - src/be/migrations/061_kv_store.sql (schema)
9705
+ // - src/http/kv.ts (REST surface + namespace resolution)
9706
+ // - src/tools/kv/* (MCP surface)
9707
+ //
9708
+ // Conventions:
9709
+ // - All sizing / regex validation happens at the HTTP / MCP boundary so the
9710
+ // helpers below can assume well-formed inputs.
9711
+ // - `value` is stored verbatim in TEXT; helpers decode based on value_type.
9712
+ // - "now" is `unixepoch('subsec') * 1000` (unix-ms), consistent with the
9713
+ // migration's DEFAULTs — using JS `Date.now()` for the few helpers that
9714
+ // need to mention an explicit timestamp keeps the math identical at ms
9715
+ // resolution.
9716
+
9717
+ interface KvRow {
9718
+ namespace: string;
9719
+ key: string;
9720
+ value: string;
9721
+ value_type: KvValueType;
9722
+ expires_at: number | null;
9723
+ created_at: number;
9724
+ updated_at: number;
9725
+ }
9726
+
9727
+ function decodeKvRow(row: KvRow): KvEntry {
9728
+ let value: unknown;
9729
+ if (row.value_type === "json") {
9730
+ try {
9731
+ value = JSON.parse(row.value);
9732
+ } catch {
9733
+ // Stored JSON is corrupt — surface as raw string rather than throwing
9734
+ // on read; the row is still recoverable by the caller.
9735
+ value = row.value;
9736
+ }
9737
+ } else if (row.value_type === "integer") {
9738
+ value = Number(row.value);
9739
+ } else {
9740
+ value = row.value;
9741
+ }
9742
+ return {
9743
+ namespace: row.namespace,
9744
+ key: row.key,
9745
+ value,
9746
+ valueType: row.value_type,
9747
+ expiresAt: row.expires_at,
9748
+ createdAt: row.created_at,
9749
+ updatedAt: row.updated_at,
9750
+ };
9751
+ }
9752
+
9753
+ function encodeKvValue(value: unknown, valueType: KvValueType): string {
9754
+ if (valueType === "json") {
9755
+ return JSON.stringify(value);
9756
+ }
9757
+ if (valueType === "integer") {
9758
+ if (typeof value === "number") {
9759
+ if (!Number.isInteger(value) || !Number.isSafeInteger(value)) {
9760
+ throw new Error("integer value must be a JS-safe integer");
9761
+ }
9762
+ return String(value);
9763
+ }
9764
+ if (typeof value === "string" && /^-?\d+$/.test(value)) {
9765
+ return value;
9766
+ }
9767
+ throw new Error("integer value must be a JS-safe integer");
9768
+ }
9769
+ // 'string'
9770
+ if (typeof value !== "string") {
9771
+ throw new Error("string value must be a string");
9772
+ }
9773
+ return value;
9774
+ }
9775
+
9776
+ /**
9777
+ * Get a single KV entry. Returns null if missing OR expired; expired rows are
9778
+ * deleted inline (single-row DELETE WHERE) so the row count stays bounded over
9779
+ * time without a background sweeper.
9780
+ */
9781
+ export function getKv(namespace: string, key: string): KvEntry | null {
9782
+ const row = getDb()
9783
+ .prepare<KvRow, [string, string]>(
9784
+ `SELECT namespace, key, value, value_type, expires_at, created_at, updated_at
9785
+ FROM kv_entries WHERE namespace = ? AND key = ?`,
9786
+ )
9787
+ .get(namespace, key);
9788
+ if (!row) return null;
9789
+ if (row.expires_at !== null && row.expires_at <= Date.now()) {
9790
+ getDb()
9791
+ .prepare<unknown, [string, string]>(`DELETE FROM kv_entries WHERE namespace = ? AND key = ?`)
9792
+ .run(namespace, key);
9793
+ return null;
9794
+ }
9795
+ return decodeKvRow(row);
9796
+ }
9797
+
9798
+ /**
9799
+ * Upsert a KV entry. Caller passes the decoded value + valueType; we encode
9800
+ * before storing. `expiresAt` is unix-ms (NULL means no expiry).
9801
+ *
9802
+ * If the key already exists with a different `valueType` we still overwrite —
9803
+ * INCR is the only collision-sensitive op and it does its own check.
9804
+ */
9805
+ export function upsertKv(input: {
9806
+ namespace: string;
9807
+ key: string;
9808
+ value: unknown;
9809
+ valueType: KvValueType;
9810
+ expiresAt?: number | null;
9811
+ }): KvEntry {
9812
+ const encoded = encodeKvValue(input.value, input.valueType);
9813
+ const expiresAt = input.expiresAt ?? null;
9814
+ const now = Date.now();
9815
+ const row = getDb()
9816
+ .prepare<KvRow, [string, string, string, KvValueType, number | null, number, number]>(
9817
+ `INSERT INTO kv_entries (namespace, key, value, value_type, expires_at, created_at, updated_at)
9818
+ VALUES (?, ?, ?, ?, ?, ?, ?)
9819
+ ON CONFLICT(namespace, key) DO UPDATE SET
9820
+ value = excluded.value,
9821
+ value_type = excluded.value_type,
9822
+ expires_at = excluded.expires_at,
9823
+ updated_at = excluded.updated_at
9824
+ RETURNING namespace, key, value, value_type, expires_at, created_at, updated_at`,
9825
+ )
9826
+ .get(input.namespace, input.key, encoded, input.valueType, expiresAt, now, now);
9827
+ if (!row) throw new Error("Failed to upsert kv entry");
9828
+ return decodeKvRow(row);
9829
+ }
9830
+
9831
+ /**
9832
+ * Delete a KV entry. Returns true if a row was removed, false if nothing
9833
+ * existed. Does not differentiate expired-but-not-yet-swept from never-existed.
9834
+ */
9835
+ export function deleteKv(namespace: string, key: string): boolean {
9836
+ const result = getDb()
9837
+ .prepare<unknown, [string, string]>(`DELETE FROM kv_entries WHERE namespace = ? AND key = ?`)
9838
+ .run(namespace, key);
9839
+ return result.changes > 0;
9840
+ }
9841
+
9842
+ export class KvTypeCollisionError extends Error {
9843
+ readonly existingType: KvValueType;
9844
+ constructor(existingType: KvValueType) {
9845
+ super(`Cannot INCR a key with value_type '${existingType}'`);
9846
+ this.name = "KvTypeCollisionError";
9847
+ this.existingType = existingType;
9848
+ }
9849
+ }
9850
+
9851
+ /**
9852
+ * Atomically increment an integer KV entry. Creates the entry (set to `by`)
9853
+ * if it doesn't exist or has expired. Throws `KvTypeCollisionError` if the
9854
+ * existing row's `value_type` is not 'integer' — the HTTP layer maps that to
9855
+ * 409.
9856
+ */
9857
+ export function incrKv(namespace: string, key: string, by: number): KvEntry {
9858
+ if (!Number.isInteger(by) || !Number.isSafeInteger(by)) {
9859
+ throw new Error("INCR `by` must be a JS-safe integer");
9860
+ }
9861
+ const database = getDb();
9862
+ return database.transaction((): KvEntry => {
9863
+ const existing = database
9864
+ .prepare<KvRow, [string, string]>(
9865
+ `SELECT namespace, key, value, value_type, expires_at, created_at, updated_at
9866
+ FROM kv_entries WHERE namespace = ? AND key = ?`,
9867
+ )
9868
+ .get(namespace, key);
9869
+
9870
+ const now = Date.now();
9871
+ const expired =
9872
+ existing?.expires_at !== null &&
9873
+ existing !== null &&
9874
+ existing.expires_at !== null &&
9875
+ existing.expires_at <= now;
9876
+
9877
+ if (!existing || expired) {
9878
+ // Insert (or replace if expired). `upsertKv` re-enters the prepared
9879
+ // statement cache cheaply; inlining keeps this in one transaction.
9880
+ const row = database
9881
+ .prepare<KvRow, [string, string, string, number | null, number, number]>(
9882
+ `INSERT INTO kv_entries (namespace, key, value, value_type, expires_at, created_at, updated_at)
9883
+ VALUES (?, ?, ?, 'integer', ?, ?, ?)
9884
+ ON CONFLICT(namespace, key) DO UPDATE SET
9885
+ value = excluded.value,
9886
+ value_type = excluded.value_type,
9887
+ expires_at = excluded.expires_at,
9888
+ updated_at = excluded.updated_at
9889
+ RETURNING namespace, key, value, value_type, expires_at, created_at, updated_at`,
9890
+ )
9891
+ .get(namespace, key, String(by), null, now, now);
9892
+ if (!row) throw new Error("Failed to insert kv entry on INCR");
9893
+ return decodeKvRow(row);
9894
+ }
9895
+
9896
+ if (existing.value_type !== "integer") {
9897
+ throw new KvTypeCollisionError(existing.value_type);
9898
+ }
9899
+
9900
+ const current = Number(existing.value);
9901
+ if (!Number.isSafeInteger(current)) {
9902
+ throw new Error("Stored integer KV value is not a JS-safe integer");
9903
+ }
9904
+ const next = current + by;
9905
+ if (!Number.isSafeInteger(next)) {
9906
+ throw new Error("INCR would overflow JS-safe integer range");
9907
+ }
9908
+
9909
+ const row = database
9910
+ .prepare<KvRow, [string, number, string, string]>(
9911
+ `UPDATE kv_entries SET value = ?, updated_at = ?
9912
+ WHERE namespace = ? AND key = ?
9913
+ RETURNING namespace, key, value, value_type, expires_at, created_at, updated_at`,
9914
+ )
9915
+ .get(String(next), now, namespace, key);
9916
+ if (!row) throw new Error("Failed to update kv entry on INCR");
9917
+ return decodeKvRow(row);
9918
+ })();
9919
+ }
9920
+
9921
+ /**
9922
+ * List entries in a namespace, optionally filtered by prefix. Expired rows
9923
+ * are filtered out by the SELECT (no inline DELETE — listing should be a
9924
+ * stable cursor; sweeping happens on point-reads instead).
9925
+ *
9926
+ * `limit` is capped by the caller (HTTP enforces ≤1000); helper does no extra
9927
+ * bounds-check beyond what SQL accepts.
9928
+ */
9929
+ export function listKv(
9930
+ namespace: string,
9931
+ opts: { prefix?: string; limit: number; offset: number },
9932
+ ): KvEntry[] {
9933
+ const now = Date.now();
9934
+ if (opts.prefix !== undefined && opts.prefix.length > 0) {
9935
+ // LIKE-escape `\` `%` `_` so a user-supplied prefix can't run wildcards.
9936
+ const escaped = opts.prefix.replace(/[\\%_]/g, "\\$&");
9937
+ const rows = getDb()
9938
+ .prepare<KvRow, [string, number, string, number, number]>(
9939
+ `SELECT namespace, key, value, value_type, expires_at, created_at, updated_at
9940
+ FROM kv_entries
9941
+ WHERE namespace = ?
9942
+ AND (expires_at IS NULL OR expires_at > ?)
9943
+ AND key LIKE ? ESCAPE '\\'
9944
+ ORDER BY key
9945
+ LIMIT ? OFFSET ?`,
9946
+ )
9947
+ .all(namespace, now, `${escaped}%`, opts.limit, opts.offset);
9948
+ return rows.map(decodeKvRow);
9949
+ }
9950
+ const rows = getDb()
9951
+ .prepare<KvRow, [string, number, number, number]>(
9952
+ `SELECT namespace, key, value, value_type, expires_at, created_at, updated_at
9953
+ FROM kv_entries
9954
+ WHERE namespace = ?
9955
+ AND (expires_at IS NULL OR expires_at > ?)
9956
+ ORDER BY key
9957
+ LIMIT ? OFFSET ?`,
9958
+ )
9959
+ .all(namespace, now, opts.limit, opts.offset);
9960
+ return rows.map(decodeKvRow);
9961
+ }
9962
+
9963
+ /**
9964
+ * Count entries in a namespace (optionally with a prefix filter). Expired
9965
+ * rows are excluded — same predicate as `listKv`.
9966
+ */
9967
+ export function countKv(namespace: string, opts: { prefix?: string }): number {
9968
+ const now = Date.now();
9969
+ if (opts.prefix !== undefined && opts.prefix.length > 0) {
9970
+ const escaped = opts.prefix.replace(/[\\%_]/g, "\\$&");
9971
+ const row = getDb()
9972
+ .prepare<{ n: number }, [string, number, string]>(
9973
+ `SELECT COUNT(*) AS n FROM kv_entries
9974
+ WHERE namespace = ?
9975
+ AND (expires_at IS NULL OR expires_at > ?)
9976
+ AND key LIKE ? ESCAPE '\\'`,
9977
+ )
9978
+ .get(namespace, now, `${escaped}%`);
9979
+ return row?.n ?? 0;
9980
+ }
9981
+ const row = getDb()
9982
+ .prepare<{ n: number }, [string, number]>(
9983
+ `SELECT COUNT(*) AS n FROM kv_entries
9984
+ WHERE namespace = ?
9985
+ AND (expires_at IS NULL OR expires_at > ?)`,
9986
+ )
9987
+ .get(namespace, now);
9988
+ return row?.n ?? 0;
9989
+ }
@@ -0,0 +1,34 @@
1
+ -- DB-backed Pages (parent table) — lightweight artifacts stored in SQLite and
2
+ -- served as HTML or JSON. Replaces PM2+localtunnel for static cases.
3
+ --
4
+ -- `body` stores agent-emitted content verbatim. For contentType='text/html',
5
+ -- callers MAY pass either a fragment (`<h1>hi</h1>`) or a full document
6
+ -- (`<!doctype html>...<html>...`). Step-3's `/p/:id` serving logic detects
7
+ -- `<head>` and injects BROWSER_SDK_JS after it; if absent, it prepends.
8
+ -- For contentType='application/json', the body is a JSON-render-compatible
9
+ -- spec stored as a string (parsed at render time).
10
+ --
11
+ -- `needsCredentials` is reserved for follow-up credential capture work; the
12
+ -- v1 renderer ignores it (Zod accepts, no UI prompt).
13
+ --
14
+ -- `authMode` CHECK constraint MUST stay in sync with PageAuthModeSchema in
15
+ -- src/types.ts.
16
+
17
+ CREATE TABLE IF NOT EXISTS pages (
18
+ id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))),
19
+ agentId TEXT NOT NULL,
20
+ slug TEXT NOT NULL,
21
+ title TEXT NOT NULL,
22
+ description TEXT,
23
+ contentType TEXT NOT NULL CHECK (contentType IN ('text/html','application/json')),
24
+ authMode TEXT NOT NULL DEFAULT 'public' CHECK (authMode IN ('public','authed','password')),
25
+ passwordHash TEXT,
26
+ body TEXT NOT NULL,
27
+ needsCredentials TEXT,
28
+ createdAt TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
29
+ updatedAt TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
30
+ UNIQUE (agentId, slug)
31
+ );
32
+
33
+ CREATE INDEX IF NOT EXISTS idx_pages_agentId ON pages(agentId);
34
+ CREATE INDEX IF NOT EXISTS idx_pages_updatedAt ON pages(updatedAt DESC);
@@ -0,0 +1,19 @@
1
+ -- DB-backed Pages (history table). Mirrors workflow_versions
2
+ -- (008_workflow_redesign.sql:74-82) — parent table holds CURRENT state, this
3
+ -- table holds the pre-update snapshot taken by snapshotPage() before each
4
+ -- updatePage(). Head pointer is derived from MAX(version); no head_version
5
+ -- column on the parent.
6
+ --
7
+ -- ON DELETE CASCADE: deleting a page removes its version history.
8
+
9
+ CREATE TABLE IF NOT EXISTS page_versions (
10
+ id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))),
11
+ pageId TEXT NOT NULL REFERENCES pages(id) ON DELETE CASCADE,
12
+ version INTEGER NOT NULL,
13
+ snapshot TEXT NOT NULL, -- JSON: PageSnapshot
14
+ changedByAgentId TEXT,
15
+ createdAt TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
16
+ UNIQUE (pageId, version)
17
+ );
18
+
19
+ CREATE INDEX IF NOT EXISTS idx_page_versions_pageId ON page_versions(pageId);
@@ -0,0 +1,34 @@
1
+ -- KV store: one table, namespaced. Namespace mirrors `agent_tasks.contextKey`
2
+ -- (task:slack:..., task:trackers:..., task:agent:..., task:page:..., etc.)
3
+ -- so the same string used to find sibling tasks for a Slack thread / PR /
4
+ -- Linear issue also indexes KV state for that entity.
5
+ --
6
+ -- value_type:
7
+ -- 'json' — `value` is the JSON-encoded payload (default; arbitrary shape).
8
+ -- 'string' — `value` is the raw UTF-8 string verbatim.
9
+ -- 'integer' — `value` is the decimal-string form of a JS-safe integer.
10
+ -- INCR uses this column; mixing with 'json'/'string' returns 409.
11
+ --
12
+ -- expires_at:
13
+ -- Unix-ms (matches `unixepoch('subsec') * 1000`). NULL means never expires.
14
+ -- Lazy expire on read: getKv DELETEs single expired rows; listKv filters in
15
+ -- the SELECT but does not delete (keeps cursors stable). No background sweep.
16
+ --
17
+ -- WITHOUT ROWID: every read is by full PK (namespace, key); the rowid -> btree
18
+ -- hop is wasted.
19
+
20
+ CREATE TABLE IF NOT EXISTS kv_entries (
21
+ namespace TEXT NOT NULL,
22
+ key TEXT NOT NULL,
23
+ value TEXT NOT NULL,
24
+ value_type TEXT NOT NULL DEFAULT 'json'
25
+ CHECK (value_type IN ('json','string','integer')),
26
+ expires_at INTEGER,
27
+ created_at INTEGER NOT NULL DEFAULT (CAST(unixepoch('subsec') * 1000 AS INTEGER)),
28
+ updated_at INTEGER NOT NULL DEFAULT (CAST(unixepoch('subsec') * 1000 AS INTEGER)),
29
+ PRIMARY KEY (namespace, key)
30
+ ) WITHOUT ROWID;
31
+
32
+ CREATE INDEX IF NOT EXISTS idx_kv_expires
33
+ ON kv_entries(expires_at)
34
+ WHERE expires_at IS NOT NULL;
@@ -0,0 +1,9 @@
1
+ -- Adds a per-page view counter to the `pages` table. Bumped on every
2
+ -- successful 200 from `GET /p/:id` (HTML inline serve) and `GET /p/:id.json`
3
+ -- (JSON metadata fetch). 302/401/403/404 responses do NOT bump. No
4
+ -- per-viewer dedup — Taras explicitly wanted a "super simple counter field".
5
+ --
6
+ -- Bump path: src/http/pages-public.ts → bumpViewCount() → incrementPageViewCount()
7
+ -- in src/be/db.ts. Wrapped in try/catch so analytics never breaks page serving.
8
+
9
+ ALTER TABLE pages ADD COLUMN view_count INTEGER NOT NULL DEFAULT 0;