@desplega.ai/agent-swarm 1.78.0 → 1.79.0

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 (48) hide show
  1. package/openapi.json +542 -1
  2. package/package.json +1 -1
  3. package/plugin/skills/artifacts/SKILL.md +151 -0
  4. package/plugin/skills/artifacts/examples/static-report.sh +1 -1
  5. package/plugin/skills/pages/SKILL.md +274 -0
  6. package/src/artifact-sdk/browser-sdk.ts +105 -20
  7. package/src/be/db.ts +239 -0
  8. package/src/be/migrations/059_pages.sql +34 -0
  9. package/src/be/migrations/060_page_versions.sql +19 -0
  10. package/src/commands/artifact.ts +17 -11
  11. package/src/http/index.ts +7 -1
  12. package/src/http/page-proxy.ts +208 -0
  13. package/src/http/pages-public.ts +466 -0
  14. package/src/http/pages.ts +608 -0
  15. package/src/http/utils.ts +68 -5
  16. package/src/pages/version.ts +44 -0
  17. package/src/prompts/session-templates.ts +51 -0
  18. package/src/server.ts +10 -1
  19. package/src/tests/artifact-commands.test.ts +92 -0
  20. package/src/tests/artifact-sdk.test.ts +80 -74
  21. package/src/tests/create-page-tool.test.ts +197 -0
  22. package/src/tests/error-tracker.test.ts +30 -0
  23. package/src/tests/fixtures/sample-json-page.json +52 -0
  24. package/src/tests/launch-password-rejection.test.ts +139 -0
  25. package/src/tests/page-proxy-authed.test.ts +146 -0
  26. package/src/tests/page-proxy.test.ts +266 -0
  27. package/src/tests/page-session.test.ts +164 -0
  28. package/src/tests/pages-actions-endpoint.test.ts +102 -0
  29. package/src/tests/pages-authed-mode.test.ts +207 -0
  30. package/src/tests/pages-http.test.ts +193 -0
  31. package/src/tests/pages-list-endpoint.test.ts +149 -0
  32. package/src/tests/pages-password-hash.test.ts +57 -0
  33. package/src/tests/pages-password-mode.test.ts +265 -0
  34. package/src/tests/pages-public-authed-401.test.ts +102 -0
  35. package/src/tests/pages-public-html.test.ts +151 -0
  36. package/src/tests/pages-public-json-redirect.test.ts +86 -0
  37. package/src/tests/pages-storage.test.ts +196 -0
  38. package/src/tests/pages-versioning.test.ts +231 -0
  39. package/src/tests/prompt-template-session.test.ts +3 -2
  40. package/src/tests/skill-update-scope.test.ts +165 -0
  41. package/src/tests/workflow-wait-event.test.ts +4 -7
  42. package/src/tools/create-page.ts +263 -0
  43. package/src/tools/skills/skill-update.ts +26 -0
  44. package/src/tools/tool-config.ts +3 -0
  45. package/src/types.ts +54 -0
  46. package/src/utils/error-tracker.ts +55 -1
  47. package/src/utils/page-session.ts +254 -0
  48. package/plugin/skills/artifacts/skill.md +0 -70
@@ -0,0 +1,165 @@
1
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2
+ import { unlink } from "node:fs/promises";
3
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
4
+ import { closeDb, createAgent, createSkill, getSkillById, initDb } from "../be/db";
5
+ import { registerSkillUpdateTool } from "../tools/skills/skill-update";
6
+
7
+ const TEST_DB_PATH = "./test-skill-update-scope.sqlite";
8
+
9
+ const LEAD_ID = "aaaa0000-0000-4000-8000-000000000010";
10
+ const WORKER_ID = "bbbb0000-0000-4000-8000-000000000020";
11
+
12
+ type StructuredContent = {
13
+ yourAgentId?: string;
14
+ success: boolean;
15
+ message: string;
16
+ skill?: { id: string; scope: string; ownerAgentId: string | null };
17
+ };
18
+
19
+ async function callSkillUpdate(
20
+ server: McpServer,
21
+ callerAgentId: string | undefined,
22
+ args: Record<string, unknown>,
23
+ ): Promise<{ structuredContent: StructuredContent }> {
24
+ // biome-ignore lint/complexity/noBannedTypes: accessing internal MCP SDK type for test
25
+ const tools = (server as unknown as { _registeredTools: Record<string, { handler: Function }> })
26
+ ._registeredTools;
27
+ const handler = tools["skill-update"].handler;
28
+
29
+ const extra = {
30
+ sessionId: "test-session",
31
+ requestInfo: {
32
+ headers: {
33
+ "x-agent-id": callerAgentId ?? "",
34
+ },
35
+ },
36
+ };
37
+
38
+ const result = await handler(args, extra);
39
+ return result as { structuredContent: StructuredContent };
40
+ }
41
+
42
+ describe("skill-update scope promotion", () => {
43
+ let server: McpServer;
44
+
45
+ beforeAll(async () => {
46
+ for (const suffix of ["", "-wal", "-shm"]) {
47
+ try {
48
+ await unlink(TEST_DB_PATH + suffix);
49
+ } catch {
50
+ // File doesn't exist
51
+ }
52
+ }
53
+
54
+ closeDb();
55
+ initDb(TEST_DB_PATH);
56
+
57
+ createAgent({ id: LEAD_ID, name: "Test Lead", isLead: true, status: "idle" });
58
+ createAgent({ id: WORKER_ID, name: "Test Worker", isLead: false, status: "idle" });
59
+
60
+ server = new McpServer({ name: "test-skill-update-scope", version: "1.0.0" });
61
+ registerSkillUpdateTool(server);
62
+ });
63
+
64
+ afterAll(async () => {
65
+ closeDb();
66
+ for (const suffix of ["", "-wal", "-shm"]) {
67
+ try {
68
+ await unlink(TEST_DB_PATH + suffix);
69
+ } catch {
70
+ // ignore
71
+ }
72
+ }
73
+ });
74
+
75
+ test("worker cannot promote their own skill to swarm scope", async () => {
76
+ const skill = createSkill({
77
+ name: "worker-skill-self-promote",
78
+ description: "Worker tries to promote",
79
+ content:
80
+ "---\nname: worker-skill-self-promote\ndescription: Worker tries to promote\n---\n\nBody.",
81
+ type: "personal",
82
+ scope: "agent",
83
+ ownerAgentId: WORKER_ID,
84
+ });
85
+
86
+ const result = await callSkillUpdate(server, WORKER_ID, {
87
+ skillId: skill.id,
88
+ scope: "swarm",
89
+ });
90
+
91
+ expect(result.structuredContent.success).toBe(false);
92
+ expect(result.structuredContent.message).toContain("lead");
93
+
94
+ const stored = getSkillById(skill.id);
95
+ expect(stored?.scope).toBe("agent");
96
+ expect(stored?.ownerAgentId).toBe(WORKER_ID);
97
+ });
98
+
99
+ test("lead can promote a worker's agent-scope skill to swarm without changing ownerAgentId", async () => {
100
+ const skill = createSkill({
101
+ name: "worker-skill-lead-promote",
102
+ description: "Lead promotes",
103
+ content: "---\nname: worker-skill-lead-promote\ndescription: Lead promotes\n---\n\nBody.",
104
+ type: "personal",
105
+ scope: "agent",
106
+ ownerAgentId: WORKER_ID,
107
+ });
108
+
109
+ const result = await callSkillUpdate(server, LEAD_ID, {
110
+ skillId: skill.id,
111
+ scope: "swarm",
112
+ });
113
+
114
+ expect(result.structuredContent.success).toBe(true);
115
+ expect(result.structuredContent.skill?.scope).toBe("swarm");
116
+ expect(result.structuredContent.skill?.ownerAgentId).toBe(WORKER_ID);
117
+
118
+ const stored = getSkillById(skill.id);
119
+ expect(stored?.scope).toBe("swarm");
120
+ expect(stored?.ownerAgentId).toBe(WORKER_ID);
121
+ });
122
+
123
+ test("lead demoting a swarm skill back to agent scope is allowed", async () => {
124
+ const skill = createSkill({
125
+ name: "swarm-skill-demote",
126
+ description: "Demote test",
127
+ content: "---\nname: swarm-skill-demote\ndescription: Demote test\n---\n\nBody.",
128
+ type: "personal",
129
+ scope: "swarm",
130
+ ownerAgentId: WORKER_ID,
131
+ });
132
+
133
+ const result = await callSkillUpdate(server, LEAD_ID, {
134
+ skillId: skill.id,
135
+ scope: "agent",
136
+ });
137
+
138
+ expect(result.structuredContent.success).toBe(true);
139
+ expect(result.structuredContent.skill?.scope).toBe("agent");
140
+
141
+ const stored = getSkillById(skill.id);
142
+ expect(stored?.scope).toBe("agent");
143
+ });
144
+
145
+ test("omitting scope leaves it unchanged", async () => {
146
+ const skill = createSkill({
147
+ name: "scope-untouched",
148
+ description: "No scope change",
149
+ content: "---\nname: scope-untouched\ndescription: No scope change\n---\n\nBody.",
150
+ type: "personal",
151
+ scope: "agent",
152
+ ownerAgentId: WORKER_ID,
153
+ });
154
+
155
+ const result = await callSkillUpdate(server, WORKER_ID, {
156
+ skillId: skill.id,
157
+ isEnabled: false,
158
+ });
159
+
160
+ expect(result.structuredContent.success).toBe(true);
161
+ const stored = getSkillById(skill.id);
162
+ expect(stored?.scope).toBe("agent");
163
+ expect(stored?.isEnabled).toBe(false);
164
+ });
165
+ });
@@ -261,13 +261,10 @@ describe("WaitExecutor — event mode end-to-end", () => {
261
261
 
262
262
  // Skip the 5s poller — fast-forward by directly calling the resume helper
263
263
  // with status='timeout' (the poller would do exactly this once expiresAt
264
- // passes). Sleep relative to the *actual* expiresAt so we don't race
265
- // when startWorkflowExecution overhead eats the cushion on slow CI.
266
- const expiresAtMs = new Date(ws!.expiresAt!).getTime();
267
- const sleepMs = Math.max(0, expiresAtMs - Date.now()) + 250;
268
- await new Promise((r) => setTimeout(r, sleepMs));
269
- const due = getDueWaitStates();
270
- expect(due.find((d) => d.id === ws!.id)).toBeDefined();
264
+ // passes). Poll getDueWaitStates() instead of sleeping a fixed amount:
265
+ // SQLite's strftime('now') and JS Date.now() can drift by a few ms on
266
+ // loaded CI, and setTimeout cushions need to be generous to compensate.
267
+ await waitFor(() => getDueWaitStates().some((d) => d.id === ws!.id), 5000);
271
268
 
272
269
  await resumeWaitState(ws!.id, "timeout", undefined, registry);
273
270
 
@@ -0,0 +1,263 @@
1
+ /**
2
+ * `create_page` MCP tool — capability-gated ("pages") agent-facing entry
3
+ * point for the db-backed pages feature. Creates or updates a page row in
4
+ * SQLite and returns shareable URLs.
5
+ *
6
+ * Upsert semantics: keyed by `(agentId, slug)`. If a row already exists,
7
+ * the tool calls `snapshotPage` (preserving pre-update content as a version
8
+ * row) then `updatePage` — mirroring the HTTP `PUT /api/pages/:id` flow.
9
+ * First-create skips the snapshot (no prior state).
10
+ *
11
+ * Slug derivation: explicit `slug` wins; otherwise kebab-case the title; if
12
+ * the title slugifies to empty (e.g. all symbols), fall back to the
13
+ * generated page id.
14
+ *
15
+ * Architecture note: this tool runs on the API server (per
16
+ * `src/server.ts:155`+ pattern) and accesses `src/be/db` directly. The
17
+ * worker-side DB-boundary invariant doesn't apply because `src/tools/*`
18
+ * lives on the API side.
19
+ */
20
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
21
+ import * as z from "zod";
22
+ import { createPage, getPage, getPageBySlug, getPageVersions, updatePage } from "@/be/db";
23
+ import { snapshotPage } from "@/pages/version";
24
+ import { createToolRegistrar } from "@/tools/utils";
25
+ import { PageAuthModeSchema, PageContentTypeSchema } from "@/types";
26
+
27
+ /** Same slugifier used by the HTTP createPage handler. */
28
+ function slugify(input: string): string {
29
+ const slug = input
30
+ .toLowerCase()
31
+ .normalize("NFKD")
32
+ .replace(/[^a-z0-9]+/g, "-")
33
+ .replace(/^-+|-+$/g, "");
34
+ return slug;
35
+ }
36
+
37
+ function getApiBaseUrl(): string {
38
+ const env = process.env.MCP_BASE_URL?.trim();
39
+ if (env) return env.replace(/\/+$/, "");
40
+ return `http://localhost:${process.env.PORT || "3013"}`;
41
+ }
42
+
43
+ function getAppBaseUrl(): string {
44
+ const env = process.env.APP_URL?.trim();
45
+ if (env) return env.replace(/\/+$/, "");
46
+ return "http://localhost:5274";
47
+ }
48
+
49
+ /**
50
+ * Edit counter for a page — `MAX(page_versions.version) + 1`. Returned to
51
+ * the agent as `version` so they have a monotonic "this page has been
52
+ * edited N times" signal. Mirrors the value returned by
53
+ * `PUT /api/pages/:id` (see src/http/pages.ts:pageEditCounter).
54
+ */
55
+ function pageEditCounter(pageId: string): number {
56
+ const versions = getPageVersions(pageId);
57
+ return versions.length > 0 ? versions[0]!.version + 1 : 1;
58
+ }
59
+
60
+ export const registerCreatePageTool = (server: McpServer) => {
61
+ createToolRegistrar(server)(
62
+ "create_page",
63
+ {
64
+ title: "Create or update a page",
65
+ description:
66
+ "Stores an HTML or JSON page in the swarm and returns shareable URLs. " +
67
+ "Calls are upsert-by-(agent, slug): if you previously created a page " +
68
+ "with the same slug, its prior state is snapshotted and the row is " +
69
+ "updated. Use this for static reports, dashboards, or JSON action " +
70
+ "specs that don't need a long-lived process.",
71
+ annotations: { destructiveHint: false },
72
+ inputSchema: z.object({
73
+ title: z.string().min(1).describe("Human-readable title shown in listings."),
74
+ slug: z
75
+ .string()
76
+ .min(1)
77
+ .optional()
78
+ .describe(
79
+ "URL slug. Defaults to the kebab-cased title. Same slug → updates the existing row.",
80
+ ),
81
+ body: z
82
+ .string()
83
+ .min(1)
84
+ .describe("Full page body (HTML document or JSON-render spec, per contentType)."),
85
+ contentType: PageContentTypeSchema.describe(
86
+ "'text/html' renders directly at /p/:id; 'application/json' is rendered by the SPA.",
87
+ ),
88
+ authMode: PageAuthModeSchema.default("public").describe(
89
+ "'public' — no gate; 'authed' — requires page-session cookie; 'password' — requires key.",
90
+ ),
91
+ password: z
92
+ .string()
93
+ .min(1)
94
+ .optional()
95
+ .describe(
96
+ "Plaintext password, hashed before storage. Only meaningful for authMode='password'.",
97
+ ),
98
+ description: z
99
+ .string()
100
+ .optional()
101
+ .describe("Optional short description, used in listings + OG-tag unfurl."),
102
+ needsCredentials: z
103
+ .array(z.object({ name: z.string(), description: z.string() }))
104
+ .optional()
105
+ .describe(
106
+ "Declared credential needs for JSON pages (renderer ignores for v1 — reserved for follow-up).",
107
+ ),
108
+ }),
109
+ outputSchema: z.object({
110
+ yourAgentId: z.string(),
111
+ id: z.string(),
112
+ version: z.number(),
113
+ app_url: z.string(),
114
+ api_url: z.string(),
115
+ }),
116
+ },
117
+ async (input, requestInfo, _meta) => {
118
+ if (!requestInfo.agentId) {
119
+ const msg = "Agent ID required. Set the X-Agent-ID header on the MCP request.";
120
+ return {
121
+ content: [{ type: "text", text: msg }],
122
+ structuredContent: {
123
+ yourAgentId: "",
124
+ id: "",
125
+ version: 0,
126
+ app_url: "",
127
+ api_url: "",
128
+ success: false,
129
+ message: msg,
130
+ },
131
+ isError: true,
132
+ };
133
+ }
134
+
135
+ const slug = input.slug ?? slugify(input.title);
136
+ const finalSlug = slug || "page"; // Fallback if title slugifies to empty.
137
+
138
+ // Hash password if provided. We always hash (even for non-password
139
+ // modes) so the column reflects what the caller intended; the mode
140
+ // governs whether the hash is checked at /p/:id serve time.
141
+ let passwordHash: string | undefined;
142
+ if (input.password) {
143
+ passwordHash = await Bun.password.hash(input.password, "bcrypt");
144
+ }
145
+
146
+ // `needsCredentials` is declared as `[{name, description}]` on the
147
+ // wire but the DB column accepts string[] (current schema). Flatten
148
+ // to names for v1 — the renderer ignores it anyway. Step-8 may
149
+ // revisit.
150
+ const needsCredentialsNames = input.needsCredentials?.map((c) => c.name);
151
+
152
+ // Upsert. Look up existing row by (agentId, slug).
153
+ const existing = getPageBySlug(requestInfo.agentId, finalSlug);
154
+
155
+ let id: string;
156
+ if (existing) {
157
+ // Snapshot first — failure must NOT block the update.
158
+ try {
159
+ snapshotPage(existing.id, requestInfo.agentId);
160
+ } catch {
161
+ // intentional empty
162
+ }
163
+ const updated = updatePage(existing.id, {
164
+ title: input.title,
165
+ description: input.description,
166
+ contentType: input.contentType,
167
+ authMode: input.authMode,
168
+ passwordHash: passwordHash ?? null,
169
+ body: input.body,
170
+ needsCredentials: needsCredentialsNames ?? null,
171
+ });
172
+ if (!updated) {
173
+ const msg = `Failed to update existing page ${existing.id}.`;
174
+ return {
175
+ content: [{ type: "text", text: msg }],
176
+ structuredContent: {
177
+ yourAgentId: requestInfo.agentId,
178
+ id: existing.id,
179
+ version: 0,
180
+ app_url: "",
181
+ api_url: "",
182
+ success: false,
183
+ message: msg,
184
+ },
185
+ isError: true,
186
+ };
187
+ }
188
+ id = updated.id;
189
+ } else {
190
+ try {
191
+ const created = createPage({
192
+ agentId: requestInfo.agentId,
193
+ slug: finalSlug,
194
+ title: input.title,
195
+ description: input.description,
196
+ contentType: input.contentType,
197
+ authMode: input.authMode,
198
+ passwordHash,
199
+ body: input.body,
200
+ needsCredentials: needsCredentialsNames,
201
+ });
202
+ id = created.id;
203
+ } catch (err) {
204
+ const detail = err instanceof Error ? err.message : String(err);
205
+ const msg = `Failed to create page: ${detail}`;
206
+ return {
207
+ content: [{ type: "text", text: msg }],
208
+ structuredContent: {
209
+ yourAgentId: requestInfo.agentId,
210
+ id: "",
211
+ version: 0,
212
+ app_url: "",
213
+ api_url: "",
214
+ success: false,
215
+ message: msg,
216
+ },
217
+ isError: true,
218
+ };
219
+ }
220
+ }
221
+
222
+ // Re-read after write so the page exists (defensive). 1 round-trip;
223
+ // page row is small. If it's missing here something's badly wrong.
224
+ const fresh = getPage(id);
225
+ if (!fresh) {
226
+ const msg = `Page ${id} disappeared between write and read.`;
227
+ return {
228
+ content: [{ type: "text", text: msg }],
229
+ structuredContent: {
230
+ yourAgentId: requestInfo.agentId,
231
+ id,
232
+ version: 0,
233
+ app_url: "",
234
+ api_url: "",
235
+ success: false,
236
+ message: msg,
237
+ },
238
+ isError: true,
239
+ };
240
+ }
241
+
242
+ const apiUrl = `${getApiBaseUrl()}/p/${id}`;
243
+ const appUrl = `${getAppBaseUrl()}/pages/${id}`;
244
+ const version = pageEditCounter(id);
245
+
246
+ return {
247
+ content: [
248
+ {
249
+ type: "text",
250
+ text: `Page "${input.title}" saved (slug=${finalSlug}, version=${version}).\n API: ${apiUrl}\n App: ${appUrl}`,
251
+ },
252
+ ],
253
+ structuredContent: {
254
+ yourAgentId: requestInfo.agentId,
255
+ id,
256
+ version,
257
+ app_url: appUrl,
258
+ api_url: apiUrl,
259
+ },
260
+ };
261
+ },
262
+ );
263
+ };
@@ -16,6 +16,12 @@ export const registerSkillUpdateTool = (server: McpServer) => {
16
16
  skillId: z.string().optional().describe("Skill ID to update"),
17
17
  content: z.string().optional().describe("New SKILL.md content (re-parses frontmatter)"),
18
18
  isEnabled: z.boolean().optional().describe("Toggle enabled/disabled"),
19
+ scope: z
20
+ .enum(["agent", "swarm"])
21
+ .optional()
22
+ .describe(
23
+ "Scope: agent (personal) or swarm (shared). Only leads can promote a skill to swarm scope (used by the skill-approval flow).",
24
+ ),
19
25
  }),
20
26
  outputSchema: z.object({
21
27
  yourAgentId: z.string().uuid().optional(),
@@ -91,6 +97,26 @@ export const registerSkillUpdateTool = (server: McpServer) => {
91
97
  updates.isEnabled = args.isEnabled;
92
98
  }
93
99
 
100
+ if (args.scope !== undefined && args.scope !== existing.scope) {
101
+ // Promoting to swarm scope is the skill-approval path — only leads may do it.
102
+ if (args.scope === "swarm" && !agent?.isLead) {
103
+ return {
104
+ content: [
105
+ {
106
+ type: "text",
107
+ text: 'Only lead agents can promote a skill to "swarm" scope. Use "skill-publish" to request approval.',
108
+ },
109
+ ],
110
+ structuredContent: {
111
+ yourAgentId: requestInfo.agentId,
112
+ success: false,
113
+ message: "Only lead agents can promote a skill to swarm scope.",
114
+ },
115
+ };
116
+ }
117
+ updates.scope = args.scope;
118
+ }
119
+
94
120
  const skill = updateSkill(args.skillId, updates);
95
121
  if (!skill) {
96
122
  return {
@@ -144,6 +144,9 @@ export const DEFERRED_TOOLS = new Set([
144
144
  "resolve-user",
145
145
  "manage-user",
146
146
 
147
+ // Pages (1)
148
+ "create_page",
149
+
147
150
  // Other (3)
148
151
  "cancel-task",
149
152
  "inject-learning",
package/src/types.ts CHANGED
@@ -1087,6 +1087,60 @@ export const WorkflowVersionSchema = z.object({
1087
1087
  });
1088
1088
  export type WorkflowVersion = z.infer<typeof WorkflowVersionSchema>;
1089
1089
 
1090
+ // ---------------------------------------------------------------------------
1091
+ // Pages — DB-backed lightweight artifacts (HTML or JSON spec) stored in
1092
+ // SQLite and served at /p/:id. See plan: thoughts/taras/plans/2026-05-12-db-backed-pages/.
1093
+ // PageContentTypeSchema + PageAuthModeSchema MUST stay in sync with the SQL
1094
+ // CHECK constraints in src/be/migrations/059_pages.sql.
1095
+ // ---------------------------------------------------------------------------
1096
+
1097
+ export const PageContentTypeSchema = z.enum(["text/html", "application/json"]);
1098
+ export type PageContentType = z.infer<typeof PageContentTypeSchema>;
1099
+
1100
+ export const PageAuthModeSchema = z.enum(["public", "authed", "password"]);
1101
+ export type PageAuthMode = z.infer<typeof PageAuthModeSchema>;
1102
+
1103
+ // PageSnapshot captures the mutable content fields frozen per-version in
1104
+ // page_versions.snapshot. Omits id / agentId / slug / timestamps (these are
1105
+ // invariant across versions for a given page id; the slug is a parent-only
1106
+ // identifier).
1107
+ export const PageSnapshotSchema = z.object({
1108
+ title: z.string(),
1109
+ description: z.string().optional(),
1110
+ contentType: PageContentTypeSchema,
1111
+ authMode: PageAuthModeSchema,
1112
+ passwordHash: z.string().optional(),
1113
+ body: z.string(),
1114
+ needsCredentials: z.array(z.string()).optional(),
1115
+ });
1116
+ export type PageSnapshot = z.infer<typeof PageSnapshotSchema>;
1117
+
1118
+ export const PageSchema = z.object({
1119
+ id: z.string(),
1120
+ agentId: z.string(),
1121
+ slug: z.string(),
1122
+ title: z.string(),
1123
+ description: z.string().optional(),
1124
+ contentType: PageContentTypeSchema,
1125
+ authMode: PageAuthModeSchema,
1126
+ passwordHash: z.string().optional(),
1127
+ body: z.string(),
1128
+ needsCredentials: z.array(z.string()).optional(),
1129
+ createdAt: z.string(),
1130
+ updatedAt: z.string(),
1131
+ });
1132
+ export type Page = z.infer<typeof PageSchema>;
1133
+
1134
+ export const PageVersionSchema = z.object({
1135
+ id: z.string(),
1136
+ pageId: z.string(),
1137
+ version: z.number().int().min(1),
1138
+ snapshot: PageSnapshotSchema,
1139
+ changedByAgentId: z.string().optional(),
1140
+ createdAt: z.string(),
1141
+ });
1142
+ export type PageVersion = z.infer<typeof PageVersionSchema>;
1143
+
1090
1144
  // --- Workflow Run ---
1091
1145
 
1092
1146
  export const WorkflowRunStatusSchema = z.enum([
@@ -165,16 +165,70 @@ export function trackErrorFromJson(
165
165
  }
166
166
  }
167
167
 
168
+ const MONTH_NAMES: Record<string, number> = {
169
+ jan: 0,
170
+ january: 0,
171
+ feb: 1,
172
+ february: 1,
173
+ mar: 2,
174
+ march: 2,
175
+ apr: 3,
176
+ april: 3,
177
+ may: 4,
178
+ jun: 5,
179
+ june: 5,
180
+ jul: 6,
181
+ july: 6,
182
+ aug: 7,
183
+ august: 7,
184
+ sep: 8,
185
+ sept: 8,
186
+ september: 8,
187
+ oct: 9,
188
+ october: 9,
189
+ nov: 10,
190
+ november: 10,
191
+ dec: 11,
192
+ december: 11,
193
+ };
194
+
168
195
  /**
169
196
  * Parse a rate limit error message to extract a reset time, returning an ISO datetime string.
170
197
  * Handles patterns like:
171
198
  * - "resets 3pm (UTC)" / "resets 3:30pm (UTC)"
199
+ * - "resets May 14, 5pm (UTC)" / "resets May 14 5pm (UTC)"
172
200
  * - "retry after 60 seconds" / "wait 120 seconds"
173
201
  * - "retry after 2 minutes"
174
202
  * Returns undefined if no parseable reset time is found.
175
203
  */
176
204
  export function parseRateLimitResetTime(errorMessage: string): string | undefined {
177
- // Pattern 1: "resets <time> (UTC)" — e.g. "resets 3pm (UTC)", "resets 3:30pm (UTC)"
205
+ // Pattern 1a: "resets <Month> <day>[,] <time> (UTC)" — e.g. "resets May 14, 5pm (UTC)"
206
+ const datedMatch = errorMessage.match(
207
+ /resets?\s+([A-Za-z]+)\s+(\d{1,2})(?:st|nd|rd|th)?,?\s+(\d{1,2})(?::(\d{2}))?\s*(am|pm)\s*\(?\s*UTC\s*\)?/i,
208
+ );
209
+ if (datedMatch) {
210
+ const monthIdx = MONTH_NAMES[datedMatch[1]!.toLowerCase()];
211
+ if (monthIdx !== undefined) {
212
+ const day = Number.parseInt(datedMatch[2]!, 10);
213
+ let hours = Number.parseInt(datedMatch[3]!, 10);
214
+ const minutes = datedMatch[4] ? Number.parseInt(datedMatch[4], 10) : 0;
215
+ const ampm = datedMatch[5]!.toLowerCase();
216
+ if (ampm === "pm" && hours !== 12) hours += 12;
217
+ if (ampm === "am" && hours === 12) hours = 0;
218
+
219
+ const now = new Date();
220
+ let year = now.getUTCFullYear();
221
+ let resetDate = new Date(Date.UTC(year, monthIdx, day, hours, minutes, 0));
222
+ // If the parsed date is in the past (date wrapped around year-end), assume next year.
223
+ if (resetDate.getTime() <= now.getTime()) {
224
+ year += 1;
225
+ resetDate = new Date(Date.UTC(year, monthIdx, day, hours, minutes, 0));
226
+ }
227
+ return resetDate.toISOString();
228
+ }
229
+ }
230
+
231
+ // Pattern 1b: "resets <time> (UTC)" — e.g. "resets 3pm (UTC)", "resets 3:30pm (UTC)"
178
232
  const resetTimeMatch = errorMessage.match(
179
233
  /resets?\s+(\d{1,2})(?::(\d{2}))?\s*(am|pm)\s*\(?\s*UTC\s*\)?/i,
180
234
  );