@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.
- package/openapi.json +542 -1
- package/package.json +1 -1
- package/plugin/skills/artifacts/SKILL.md +151 -0
- package/plugin/skills/artifacts/examples/static-report.sh +1 -1
- package/plugin/skills/pages/SKILL.md +274 -0
- package/src/artifact-sdk/browser-sdk.ts +105 -20
- package/src/be/db.ts +239 -0
- package/src/be/migrations/059_pages.sql +34 -0
- package/src/be/migrations/060_page_versions.sql +19 -0
- package/src/commands/artifact.ts +17 -11
- package/src/http/index.ts +7 -1
- package/src/http/page-proxy.ts +208 -0
- package/src/http/pages-public.ts +466 -0
- package/src/http/pages.ts +608 -0
- package/src/http/utils.ts +68 -5
- package/src/pages/version.ts +44 -0
- package/src/prompts/session-templates.ts +51 -0
- package/src/server.ts +10 -1
- package/src/tests/artifact-commands.test.ts +92 -0
- package/src/tests/artifact-sdk.test.ts +80 -74
- package/src/tests/create-page-tool.test.ts +197 -0
- package/src/tests/error-tracker.test.ts +30 -0
- package/src/tests/fixtures/sample-json-page.json +52 -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 +266 -0
- package/src/tests/page-session.test.ts +164 -0
- package/src/tests/pages-actions-endpoint.test.ts +102 -0
- package/src/tests/pages-authed-mode.test.ts +207 -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/prompt-template-session.test.ts +3 -2
- package/src/tests/skill-update-scope.test.ts +165 -0
- package/src/tests/workflow-wait-event.test.ts +4 -7
- package/src/tools/create-page.ts +263 -0
- package/src/tools/skills/skill-update.ts +26 -0
- package/src/tools/tool-config.ts +3 -0
- package/src/types.ts +54 -0
- package/src/utils/error-tracker.ts +55 -1
- package/src/utils/page-session.ts +254 -0
- 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).
|
|
265
|
-
//
|
|
266
|
-
|
|
267
|
-
|
|
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 {
|
package/src/tools/tool-config.ts
CHANGED
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
|
|
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
|
);
|