@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.
- package/README.md +1 -0
- package/openapi.json +1335 -236
- package/package.json +4 -4
- package/plugin/skills/artifacts/SKILL.md +151 -0
- package/plugin/skills/artifacts/examples/static-report.sh +1 -1
- package/plugin/skills/kv-storage/SKILL.md +168 -0
- package/plugin/skills/pages/SKILL.md +423 -0
- package/src/artifact-sdk/browser-sdk.ts +396 -19
- package/src/be/db.ts +548 -0
- package/src/be/migrations/059_pages.sql +34 -0
- package/src/be/migrations/060_page_versions.sql +19 -0
- package/src/be/migrations/061_kv_store.sql +34 -0
- package/src/be/migrations/062_pages_view_count.sql +9 -0
- package/src/commands/artifact.ts +17 -11
- package/src/commands/provider-credentials.ts +1 -1
- package/src/http/index.ts +9 -1
- package/src/http/kv.ts +658 -0
- package/src/http/page-proxy.ts +213 -0
- package/src/http/pages-public.ts +507 -0
- package/src/http/pages.ts +608 -0
- package/src/http/status.ts +1 -1
- package/src/http/utils.ts +68 -5
- package/src/pages/version.ts +44 -0
- package/src/prompts/session-templates.ts +51 -0
- package/src/providers/pi-mono-adapter.ts +3 -3
- package/src/providers/pi-mono-extension.ts +1 -1
- package/src/server.ts +29 -1
- package/src/tasks/context-key.ts +28 -0
- package/src/telemetry.ts +65 -1
- package/src/tests/artifact-commands.test.ts +92 -0
- package/src/tests/artifact-sdk.test.ts +80 -74
- package/src/tests/context-key.test.ts +17 -0
- package/src/tests/create-page-tool.test.ts +197 -0
- package/src/tests/fixtures/sample-json-page.json +52 -0
- package/src/tests/kv-http.test.ts +331 -0
- package/src/tests/kv-namespace-resolution.test.ts +172 -0
- package/src/tests/kv-page-proxy.test.ts +212 -0
- package/src/tests/kv-storage.test.ts +227 -0
- package/src/tests/kv-tool.test.ts +217 -0
- package/src/tests/launch-password-rejection.test.ts +139 -0
- package/src/tests/page-proxy-authed.test.ts +146 -0
- package/src/tests/page-proxy.test.ts +270 -0
- package/src/tests/page-session.test.ts +169 -0
- package/src/tests/pages-actions-endpoint.test.ts +102 -0
- package/src/tests/pages-authed-mode.test.ts +211 -0
- package/src/tests/pages-http.test.ts +193 -0
- package/src/tests/pages-list-endpoint.test.ts +149 -0
- package/src/tests/pages-password-hash.test.ts +57 -0
- package/src/tests/pages-password-mode.test.ts +265 -0
- package/src/tests/pages-public-authed-401.test.ts +102 -0
- package/src/tests/pages-public-html.test.ts +151 -0
- package/src/tests/pages-public-json-redirect.test.ts +86 -0
- package/src/tests/pages-storage.test.ts +196 -0
- package/src/tests/pages-versioning.test.ts +231 -0
- package/src/tests/pages-view-count.test.ts +220 -0
- package/src/tests/prompt-template-session.test.ts +3 -2
- package/src/tests/skill-update-scope.test.ts +165 -0
- package/src/tests/swarm-diff.test.ts +303 -0
- package/src/tests/telemetry-init.test.ts +149 -0
- package/src/tests/workflow-wait-event.test.ts +4 -7
- package/src/tools/create-page.ts +263 -0
- package/src/tools/kv/index.ts +5 -0
- package/src/tools/kv/kv-delete.ts +89 -0
- package/src/tools/kv/kv-get.ts +64 -0
- package/src/tools/kv/kv-incr.ts +116 -0
- package/src/tools/kv/kv-list.ts +81 -0
- package/src/tools/kv/kv-set.ts +194 -0
- package/src/tools/kv/resolve-namespace.ts +58 -0
- package/src/tools/skills/skill-update.ts +26 -0
- package/src/tools/tool-config.ts +10 -0
- package/src/types.ts +107 -0
- package/src/utils/internal-ai/complete-structured.ts +2 -2
- package/src/utils/internal-ai/credentials.ts +3 -3
- package/src/utils/page-session.ts +254 -0
- 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;
|