@desplega.ai/agent-swarm 1.92.2 → 1.93.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 +63 -3
- package/package.json +5 -5
- package/src/be/db.ts +91 -6
- package/src/be/memory/boot-reembed.ts +0 -1
- package/src/be/memory/providers/sqlite-store.ts +42 -25
- package/src/be/memory/raters/llm-client.ts +12 -5
- package/src/be/memory/types.ts +3 -0
- package/src/be/migrations/088_script_runs_list_indexes.sql +10 -0
- package/src/be/migrations/089_harness_variant.sql +2 -0
- package/src/be/modelsdev-cache.json +1222 -986
- package/src/be/seed-pricing.ts +1 -0
- package/src/be/seed-scripts/catalog/boot-triage.inline.ts +221 -0
- package/src/be/seed-scripts/catalog/catalog-report.inline.ts +457 -0
- package/src/be/seed-scripts/catalog/compound-insights.inline.ts +863 -0
- package/src/be/seed-scripts/catalog/ops-catalog-audit.inline.ts +506 -0
- package/src/be/seed-scripts/index.ts +5 -5
- package/src/be/skill-sync.ts +28 -179
- package/src/commands/runner.ts +124 -7
- package/src/http/api-keys.ts +42 -0
- package/src/http/mcp-bridge.ts +1 -1
- package/src/http/memory.ts +23 -24
- package/src/http/tasks.ts +10 -6
- package/src/providers/claude-adapter.ts +33 -1
- package/src/providers/claude-managed-adapter.ts +3 -0
- package/src/providers/claude-managed-models.ts +7 -0
- package/src/providers/codex-adapter.ts +8 -1
- package/src/providers/codex-models.ts +1 -0
- package/src/providers/codex-oauth/auth-json.ts +1 -0
- package/src/providers/harness-version.ts +7 -0
- package/src/providers/opencode-adapter.ts +11 -4
- package/src/providers/pi-mono-adapter.ts +12 -2
- package/src/providers/types.ts +2 -0
- package/src/scripts-runtime/egress-secrets.ts +83 -0
- package/src/scripts-runtime/eval-harness.ts +4 -0
- package/src/scripts-runtime/executors/types.ts +7 -0
- package/src/scripts-runtime/loader.ts +2 -0
- package/src/server-user.ts +2 -2
- package/src/slack/channel-join.ts +41 -0
- package/src/tests/additive-buffer.test.ts +0 -1
- package/src/tests/api-key-tracking.test.ts +113 -0
- package/src/tests/approval-requests.test.ts +0 -6
- package/src/tests/claude-managed-setup.test.ts +0 -4
- package/src/tests/codex-pool.test.ts +2 -6
- package/src/tests/http-api-integration.test.ts +4 -6
- package/src/tests/memory-edges.test.ts +0 -2
- package/src/tests/memory-rate-endpoint.test.ts +0 -2
- package/src/tests/memory-rater-e2e.test.ts +0 -2
- package/src/tests/memory-store.test.ts +19 -1
- package/src/tests/memory.test.ts +51 -0
- package/src/tests/model-control.test.ts +1 -1
- package/src/tests/reload-config.test.ts +33 -17
- package/src/tests/runner-skills-refresh.test.ts +216 -46
- package/src/tests/script-runs-http.test.ts +7 -1
- package/src/tests/scripts-runtime-secret-egress.test.ts +129 -0
- package/src/tests/seed-scripts.test.ts +13 -1
- package/src/tests/session-attach.test.ts +6 -6
- package/src/tests/skill-fs-writer.test.ts +250 -0
- package/src/tests/slack-attachments-block.test.ts +0 -1
- package/src/tests/slack-blocks.test.ts +0 -1
- package/src/tests/slack-channel-join.test.ts +80 -0
- package/src/tests/slack-identity-resolution.test.ts +0 -1
- package/src/tests/structured-output.test.ts +0 -2
- package/src/tests/use-dismissible-card.test.ts +0 -4
- package/src/tools/schedules/create-schedule.ts +2 -2
- package/src/tools/schedules/update-schedule.ts +1 -1
- package/src/tools/send-task.ts +2 -2
- package/src/tools/slack-post.ts +18 -15
- package/src/tools/slack-read.ts +9 -11
- package/src/tools/slack-reply.ts +18 -15
- package/src/tools/slack-start-thread.ts +17 -14
- package/src/tools/task-action.ts +2 -2
- package/src/types.ts +11 -0
- package/src/utils/context-window.ts +3 -0
- package/src/utils/credentials.ts +22 -2
- package/src/utils/skill-fs-writer.ts +220 -0
- package/src/utils/skills-refresh.ts +123 -40
package/src/be/seed-pricing.ts
CHANGED
|
@@ -67,6 +67,7 @@ const MANUAL_PRICING_OVERRIDES: Array<{
|
|
|
67
67
|
* fields the models.dev snapshot doesn't index directly; we map them here.
|
|
68
68
|
*/
|
|
69
69
|
const ANTHROPIC_SHORTNAME_TO_MODELSDEV: Record<string, string> = {
|
|
70
|
+
fable: "claude-fable-5",
|
|
70
71
|
opus: "claude-opus-4-8",
|
|
71
72
|
sonnet: "claude-sonnet-4-6",
|
|
72
73
|
haiku: "claude-haiku-4-5",
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
export const argsSchema = z.object({
|
|
4
|
+
nowIso: z.string().optional().describe("Triage clock override (default: current time)"),
|
|
5
|
+
failureLookbackMinutes: z
|
|
6
|
+
.number()
|
|
7
|
+
.int()
|
|
8
|
+
.positive()
|
|
9
|
+
.optional()
|
|
10
|
+
.describe("Look back this many minutes for real failures (default 60)"),
|
|
11
|
+
stuckMinutes: z
|
|
12
|
+
.number()
|
|
13
|
+
.int()
|
|
14
|
+
.positive()
|
|
15
|
+
.optional()
|
|
16
|
+
.describe("Flag in-progress tasks older than this on offline agents (default 5)"),
|
|
17
|
+
deployWindowMinutes: z
|
|
18
|
+
.number()
|
|
19
|
+
.int()
|
|
20
|
+
.positive()
|
|
21
|
+
.optional()
|
|
22
|
+
.describe("Window around now for merged agent-swarm PRs (default 15)"),
|
|
23
|
+
repo: z
|
|
24
|
+
.string()
|
|
25
|
+
.optional()
|
|
26
|
+
.describe("Optional GitHub repository in 'owner/name' form for restart/deploy PR detection"),
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
const BENIGN_FAILURE_RE = /^(superseded_workflow_task|cancelled|reboot-sweep)$/i;
|
|
30
|
+
|
|
31
|
+
function rowsToObjects(res: any): any[] {
|
|
32
|
+
const p = res?.data ?? res;
|
|
33
|
+
const cols: string[] = p?.columns ?? [];
|
|
34
|
+
return (p?.rows ?? []).map((r: any) =>
|
|
35
|
+
Array.isArray(r) ? Object.fromEntries(cols.map((c, i) => [c, r[i]])) : r,
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function query(ctx: any, sql: string, params?: unknown[]): Promise<any[]> {
|
|
40
|
+
try {
|
|
41
|
+
return rowsToObjects(await ctx.swarm.db_query({ sql, params }));
|
|
42
|
+
} catch (error) {
|
|
43
|
+
return [{ unavailable: error instanceof Error ? error.message : String(error) }];
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function taskPreview(task: unknown): string {
|
|
48
|
+
return String(task || "").replace(/\s+/g, " ").trim().slice(0, 180);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function summarizeTask(row: any): any {
|
|
52
|
+
return {
|
|
53
|
+
id: row.id,
|
|
54
|
+
status: row.status,
|
|
55
|
+
taskType: row.taskType || null,
|
|
56
|
+
agentId: row.agentId || null,
|
|
57
|
+
agentName: row.agentName || null,
|
|
58
|
+
scheduleId: row.scheduleId || null,
|
|
59
|
+
parentTaskId: row.parentTaskId || null,
|
|
60
|
+
failureReason: row.failureReason || null,
|
|
61
|
+
createdAt: row.createdAt,
|
|
62
|
+
lastUpdatedAt: row.lastUpdatedAt,
|
|
63
|
+
taskPreview: taskPreview(row.task),
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async function recentMergedPrs(
|
|
68
|
+
ctx: any,
|
|
69
|
+
repo: string | undefined,
|
|
70
|
+
nowMs: number,
|
|
71
|
+
windowMinutes: number,
|
|
72
|
+
): Promise<any> {
|
|
73
|
+
if (!repo) return { skipped: "repo not provided" };
|
|
74
|
+
if (!/^[^/\s]+\/[^/\s]+$/.test(repo)) return { error: "repo must be in 'owner/name' form" };
|
|
75
|
+
|
|
76
|
+
const windowMs = windowMinutes * 60 * 1000;
|
|
77
|
+
try {
|
|
78
|
+
const response = await ctx.stdlib.fetch(
|
|
79
|
+
"https://api.github.com/repos/" +
|
|
80
|
+
repo +
|
|
81
|
+
"/pulls?state=closed&sort=updated&direction=desc&per_page=20",
|
|
82
|
+
{
|
|
83
|
+
headers: {
|
|
84
|
+
Accept: "application/vnd.github+json",
|
|
85
|
+
"User-Agent": "agent-swarm-boot-triage",
|
|
86
|
+
},
|
|
87
|
+
},
|
|
88
|
+
);
|
|
89
|
+
if (!response.ok) return { error: `GitHub API ${response.status}` };
|
|
90
|
+
const prs = (await response.json()) as any[];
|
|
91
|
+
return prs
|
|
92
|
+
.filter((pr) => pr?.merged_at)
|
|
93
|
+
.map((pr) => {
|
|
94
|
+
const mergedAtMs = Date.parse(pr.merged_at);
|
|
95
|
+
return {
|
|
96
|
+
repo,
|
|
97
|
+
number: pr.number,
|
|
98
|
+
title: pr.title,
|
|
99
|
+
url: pr.html_url,
|
|
100
|
+
mergedAt: pr.merged_at,
|
|
101
|
+
minutesFromRestart: Math.round((mergedAtMs - nowMs) / 60000),
|
|
102
|
+
};
|
|
103
|
+
})
|
|
104
|
+
.filter((pr) => Math.abs(pr.minutesFromRestart * 60000) <= windowMs);
|
|
105
|
+
} catch (error) {
|
|
106
|
+
return { error: error instanceof Error ? error.message : String(error) };
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Read-only post-restart triage snapshot for the heartbeat.boot-triage prompt. */
|
|
111
|
+
export default async function bootTriage(args: any, ctx: any) {
|
|
112
|
+
const parsed = argsSchema.safeParse(args || {});
|
|
113
|
+
if (!parsed.success) return { error: "invalid args: " + parsed.error.message };
|
|
114
|
+
|
|
115
|
+
const now = parsed.data.nowIso ? new Date(parsed.data.nowIso) : new Date();
|
|
116
|
+
const nowMs = now.getTime();
|
|
117
|
+
const failureLookbackMinutes = parsed.data.failureLookbackMinutes || 60;
|
|
118
|
+
const stuckMinutes = parsed.data.stuckMinutes || 5;
|
|
119
|
+
const deployWindowMinutes = parsed.data.deployWindowMinutes || 15;
|
|
120
|
+
const repo = parsed.data.repo;
|
|
121
|
+
|
|
122
|
+
const mergedPrs = await recentMergedPrs(ctx, repo, nowMs, deployWindowMinutes);
|
|
123
|
+
|
|
124
|
+
const recentFailureRows = await query(
|
|
125
|
+
ctx,
|
|
126
|
+
`SELECT t.id, t.task, t.status, t.taskType, t.agentId, a.name as agentName,
|
|
127
|
+
t.scheduleId, t.parentTaskId, t.failureReason, t.createdAt, t.lastUpdatedAt
|
|
128
|
+
FROM agent_tasks t
|
|
129
|
+
LEFT JOIN agents a ON a.id = t.agentId
|
|
130
|
+
WHERE t.status = 'failed'
|
|
131
|
+
AND datetime(t.lastUpdatedAt) >= datetime(?, ?)
|
|
132
|
+
ORDER BY datetime(t.lastUpdatedAt) DESC
|
|
133
|
+
LIMIT 50`,
|
|
134
|
+
[now.toISOString(), `-${failureLookbackMinutes} minutes`],
|
|
135
|
+
);
|
|
136
|
+
const recentlyFailedTasks = recentFailureRows
|
|
137
|
+
.filter((row) => !row.unavailable)
|
|
138
|
+
.filter((row) => !BENIGN_FAILURE_RE.test(String(row.failureReason || "")))
|
|
139
|
+
.map(summarizeTask);
|
|
140
|
+
|
|
141
|
+
const stuckOfflineRows = await query(
|
|
142
|
+
ctx,
|
|
143
|
+
`SELECT t.id, t.task, t.status, t.taskType, t.agentId, a.name as agentName,
|
|
144
|
+
t.scheduleId, t.parentTaskId, t.failureReason, t.createdAt, t.lastUpdatedAt
|
|
145
|
+
FROM agent_tasks t
|
|
146
|
+
JOIN agents a ON a.id = t.agentId
|
|
147
|
+
WHERE t.status = 'in_progress'
|
|
148
|
+
AND a.status = 'offline'
|
|
149
|
+
AND datetime(t.lastUpdatedAt) <= datetime(?, ?)
|
|
150
|
+
ORDER BY datetime(t.lastUpdatedAt) ASC
|
|
151
|
+
LIMIT 50`,
|
|
152
|
+
[now.toISOString(), `-${stuckMinutes} minutes`],
|
|
153
|
+
);
|
|
154
|
+
const stuckInProgressOnOfflineAgents = stuckOfflineRows
|
|
155
|
+
.filter((row) => !row.unavailable)
|
|
156
|
+
.map(summarizeTask);
|
|
157
|
+
|
|
158
|
+
const orphanRows = await query(
|
|
159
|
+
ctx,
|
|
160
|
+
`SELECT t.id, t.task, t.status, t.taskType, t.agentId, a.name as agentName,
|
|
161
|
+
t.scheduleId, t.parentTaskId, t.failureReason, t.createdAt, t.lastUpdatedAt
|
|
162
|
+
FROM agent_tasks t
|
|
163
|
+
JOIN agents a ON a.id = t.agentId
|
|
164
|
+
WHERE t.status IN ('pending', 'offered')
|
|
165
|
+
AND a.status = 'offline'
|
|
166
|
+
ORDER BY datetime(t.lastUpdatedAt) ASC
|
|
167
|
+
LIMIT 50`,
|
|
168
|
+
);
|
|
169
|
+
const orphanedPendingOrOfferedOnOfflineWorkers = orphanRows
|
|
170
|
+
.filter((row) => !row.unavailable)
|
|
171
|
+
.map(summarizeTask);
|
|
172
|
+
|
|
173
|
+
const supersededRows = await query(
|
|
174
|
+
ctx,
|
|
175
|
+
`SELECT p.id, p.task, p.status, p.taskType, p.agentId, a.name as agentName,
|
|
176
|
+
p.scheduleId, p.parentTaskId, p.failureReason, p.createdAt, p.lastUpdatedAt
|
|
177
|
+
FROM agent_tasks p
|
|
178
|
+
LEFT JOIN agents a ON a.id = p.agentId
|
|
179
|
+
WHERE p.status = 'superseded'
|
|
180
|
+
AND datetime(p.lastUpdatedAt) >= datetime(?, ?)
|
|
181
|
+
AND NOT EXISTS (
|
|
182
|
+
SELECT 1
|
|
183
|
+
FROM agent_tasks c
|
|
184
|
+
WHERE c.parentTaskId = p.id
|
|
185
|
+
AND c.taskType = 'resume'
|
|
186
|
+
AND c.status NOT IN ('completed', 'failed', 'cancelled', 'superseded')
|
|
187
|
+
)
|
|
188
|
+
ORDER BY datetime(p.lastUpdatedAt) DESC
|
|
189
|
+
LIMIT 50`,
|
|
190
|
+
[now.toISOString(), `-${failureLookbackMinutes} minutes`],
|
|
191
|
+
);
|
|
192
|
+
const supersededTasksMissingResumeChild = supersededRows
|
|
193
|
+
.filter((row) => !row.unavailable)
|
|
194
|
+
.map(summarizeTask);
|
|
195
|
+
|
|
196
|
+
return {
|
|
197
|
+
generatedAt: now.toISOString(),
|
|
198
|
+
windows: {
|
|
199
|
+
failureLookbackMinutes,
|
|
200
|
+
stuckMinutes,
|
|
201
|
+
deployWindowMinutes,
|
|
202
|
+
},
|
|
203
|
+
deployRestartDetection: {
|
|
204
|
+
source: repo ? "github:" + repo : null,
|
|
205
|
+
mergedPrsWithinWindow: Array.isArray(mergedPrs) ? mergedPrs : [],
|
|
206
|
+
skipped: Array.isArray(mergedPrs) ? null : mergedPrs.skipped || null,
|
|
207
|
+
error: Array.isArray(mergedPrs) ? null : mergedPrs.error,
|
|
208
|
+
},
|
|
209
|
+
recentlyFailedTasks,
|
|
210
|
+
stuckInProgressOnOfflineAgents,
|
|
211
|
+
orphanedPendingOrOfferedOnOfflineWorkers,
|
|
212
|
+
supersededTasksMissingResumeChild,
|
|
213
|
+
summary: {
|
|
214
|
+
mergedPrsWithinWindow: Array.isArray(mergedPrs) ? mergedPrs.length : 0,
|
|
215
|
+
recentlyFailedTasks: recentlyFailedTasks.length,
|
|
216
|
+
stuckInProgressOnOfflineAgents: stuckInProgressOnOfflineAgents.length,
|
|
217
|
+
orphanedPendingOrOfferedOnOfflineWorkers: orphanedPendingOrOfferedOnOfflineWorkers.length,
|
|
218
|
+
supersededTasksMissingResumeChild: supersededTasksMissingResumeChild.length,
|
|
219
|
+
},
|
|
220
|
+
};
|
|
221
|
+
}
|
|
@@ -0,0 +1,457 @@
|
|
|
1
|
+
export type CatalogReportFinding = {
|
|
2
|
+
id: string;
|
|
3
|
+
severity?: string;
|
|
4
|
+
summary: string;
|
|
5
|
+
action?: string;
|
|
6
|
+
samples?: unknown[];
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export type CatalogReportSection = {
|
|
10
|
+
key: string;
|
|
11
|
+
label?: string;
|
|
12
|
+
goal: string;
|
|
13
|
+
findingCount?: number;
|
|
14
|
+
checks?: Record<string, unknown>;
|
|
15
|
+
findings?: CatalogReportFinding[];
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export type CatalogReport = {
|
|
19
|
+
title: string;
|
|
20
|
+
slug: string;
|
|
21
|
+
description: string;
|
|
22
|
+
generatedAt: string;
|
|
23
|
+
lede: string;
|
|
24
|
+
metrics: Array<[string, unknown]>;
|
|
25
|
+
sections: CatalogReportSection[];
|
|
26
|
+
appendix: unknown;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
function catalogReportAsText(value: unknown): string {
|
|
30
|
+
return typeof value === "string" ? value : value == null ? "" : JSON.stringify(value);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function catalogReportHtmlEscape(value: unknown): string {
|
|
34
|
+
return catalogReportAsText(value)
|
|
35
|
+
.replace(/&/g, "&")
|
|
36
|
+
.replace(/</g, "<")
|
|
37
|
+
.replace(/>/g, ">")
|
|
38
|
+
.replace(/"/g, """);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function catalogReportHumanLabel(value: string): string {
|
|
42
|
+
return value
|
|
43
|
+
.replace(/([a-z0-9])([A-Z])/g, "$1 $2")
|
|
44
|
+
.replace(/[-_.]+/g, " ")
|
|
45
|
+
.replace(/\b\w/g, (char) => char.toUpperCase());
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function catalogReportFormatMetric(value: unknown): string {
|
|
49
|
+
if (typeof value === "number") return new Intl.NumberFormat("en-US").format(value);
|
|
50
|
+
return catalogReportAsText(value);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function catalogReportSeverityTone(value?: string): string {
|
|
54
|
+
if (value === "critical") return "danger";
|
|
55
|
+
if (value === "high") return "warn";
|
|
56
|
+
if (value === "medium") return "note";
|
|
57
|
+
return "low";
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function catalogReportRenderSampleValue(value: unknown): string {
|
|
61
|
+
if (Array.isArray(value)) {
|
|
62
|
+
return value
|
|
63
|
+
.map((item) => (typeof item === "object" && item !== null ? JSON.stringify(item) : catalogReportAsText(item)))
|
|
64
|
+
.join(", ");
|
|
65
|
+
}
|
|
66
|
+
if (value && typeof value === "object") return JSON.stringify(value);
|
|
67
|
+
return catalogReportAsText(value);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function catalogReportRenderSamples(samples?: unknown[]): string {
|
|
71
|
+
if (!Array.isArray(samples) || samples.length === 0) return "";
|
|
72
|
+
const normalized = samples.map((sampleRow) =>
|
|
73
|
+
sampleRow && typeof sampleRow === "object" && !Array.isArray(sampleRow)
|
|
74
|
+
? (sampleRow as Record<string, unknown>)
|
|
75
|
+
: { value: sampleRow },
|
|
76
|
+
);
|
|
77
|
+
const columns = Array.from(
|
|
78
|
+
new Set(normalized.flatMap((sampleRow) => Object.keys(sampleRow).slice(0, 6))),
|
|
79
|
+
).slice(0, 6);
|
|
80
|
+
if (columns.length === 0) return "";
|
|
81
|
+
const rows = normalized
|
|
82
|
+
.map(
|
|
83
|
+
(sampleRow) =>
|
|
84
|
+
`<tr>${columns
|
|
85
|
+
.map((column) => `<td>${catalogReportHtmlEscape(catalogReportRenderSampleValue(sampleRow[column]))}</td>`)
|
|
86
|
+
.join("")}</tr>`,
|
|
87
|
+
)
|
|
88
|
+
.join("");
|
|
89
|
+
return `<div class="sample-table" aria-label="Sample rows">
|
|
90
|
+
<table>
|
|
91
|
+
<thead><tr>${columns.map((column) => `<th>${catalogReportHtmlEscape(catalogReportHumanLabel(column))}</th>`).join("")}</tr></thead>
|
|
92
|
+
<tbody>${rows}</tbody>
|
|
93
|
+
</table>
|
|
94
|
+
</div>`;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function renderCatalogReportPage(report: CatalogReport): string {
|
|
98
|
+
const sections = report.sections
|
|
99
|
+
.map((section) => {
|
|
100
|
+
const findings = (section.findings || [])
|
|
101
|
+
.map(
|
|
102
|
+
(finding) => `<article class="finding ${catalogReportHtmlEscape(catalogReportSeverityTone(finding.severity))}">
|
|
103
|
+
<div class="finding-head">
|
|
104
|
+
<div>
|
|
105
|
+
<p class="finding-id">${catalogReportHtmlEscape(finding.id)}</p>
|
|
106
|
+
<h3>${catalogReportHtmlEscape(finding.summary)}</h3>
|
|
107
|
+
</div>
|
|
108
|
+
<span class="pill ${catalogReportHtmlEscape(catalogReportSeverityTone(finding.severity))}">${catalogReportHtmlEscape(
|
|
109
|
+
finding.severity || "low",
|
|
110
|
+
)}</span>
|
|
111
|
+
</div>
|
|
112
|
+
${finding.action ? `<p class="action">${catalogReportHtmlEscape(finding.action)}</p>` : ""}
|
|
113
|
+
${catalogReportRenderSamples(finding.samples)}
|
|
114
|
+
</article>`,
|
|
115
|
+
)
|
|
116
|
+
.join("");
|
|
117
|
+
const checks = Object.entries(section.checks || {})
|
|
118
|
+
.map(
|
|
119
|
+
([label, value]) =>
|
|
120
|
+
`<div class="check"><span>${catalogReportHtmlEscape(catalogReportHumanLabel(label))}</span><strong>${catalogReportHtmlEscape(
|
|
121
|
+
catalogReportFormatMetric(value),
|
|
122
|
+
)}</strong></div>`,
|
|
123
|
+
)
|
|
124
|
+
.join("");
|
|
125
|
+
return `<section class="section">
|
|
126
|
+
<div class="section-grid">
|
|
127
|
+
<aside class="checks">
|
|
128
|
+
<p class="section-kicker">${catalogReportHtmlEscape(section.label || catalogReportHumanLabel(section.key))}</p>
|
|
129
|
+
<div class="check-list">${checks}</div>
|
|
130
|
+
</aside>
|
|
131
|
+
<div>
|
|
132
|
+
<div class="section-head">
|
|
133
|
+
<h2>${catalogReportHtmlEscape(section.goal)}</h2>
|
|
134
|
+
<span>${catalogReportHtmlEscape(catalogReportFormatMetric(section.findingCount ?? section.findings?.length ?? 0))} finding(s)</span>
|
|
135
|
+
</div>
|
|
136
|
+
<div class="findings">
|
|
137
|
+
${findings || '<p class="empty">No actionable findings in this cluster.</p>'}
|
|
138
|
+
</div>
|
|
139
|
+
</div>
|
|
140
|
+
</div>
|
|
141
|
+
</section>`;
|
|
142
|
+
})
|
|
143
|
+
.join("\n");
|
|
144
|
+
return `<!doctype html>
|
|
145
|
+
<html lang="en">
|
|
146
|
+
<head>
|
|
147
|
+
<meta charset="utf-8">
|
|
148
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
149
|
+
<meta name="theme-color" content="#f5f2ea">
|
|
150
|
+
<title>${catalogReportHtmlEscape(report.title)}</title>
|
|
151
|
+
<style>
|
|
152
|
+
:root {
|
|
153
|
+
color-scheme: light;
|
|
154
|
+
--bg: #f5f2ea;
|
|
155
|
+
--panel: #ffffff;
|
|
156
|
+
--ink: #18181b;
|
|
157
|
+
--muted: #5f6368;
|
|
158
|
+
--line: #ded8cb;
|
|
159
|
+
--accent: #255c99;
|
|
160
|
+
--danger: #b42318;
|
|
161
|
+
--danger-bg: #fff1f0;
|
|
162
|
+
--warn: #b54708;
|
|
163
|
+
--warn-bg: #fff7ed;
|
|
164
|
+
--note: #175cd3;
|
|
165
|
+
--note-bg: #eff6ff;
|
|
166
|
+
--low: #067647;
|
|
167
|
+
--low-bg: #ecfdf3;
|
|
168
|
+
--radius: 8px;
|
|
169
|
+
--shadow: 0 1px 2px rgba(24, 24, 27, 0.06), 0 14px 36px rgba(24, 24, 27, 0.07);
|
|
170
|
+
}
|
|
171
|
+
* { box-sizing: border-box; }
|
|
172
|
+
body {
|
|
173
|
+
margin: 0;
|
|
174
|
+
background: var(--bg);
|
|
175
|
+
color: var(--ink);
|
|
176
|
+
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
|
177
|
+
font-size: 16px;
|
|
178
|
+
line-height: 1.55;
|
|
179
|
+
}
|
|
180
|
+
main {
|
|
181
|
+
width: min(1120px, calc(100% - 32px));
|
|
182
|
+
margin: 0 auto;
|
|
183
|
+
padding: 48px 0 72px;
|
|
184
|
+
}
|
|
185
|
+
header { margin-bottom: 28px; }
|
|
186
|
+
.eyebrow {
|
|
187
|
+
margin: 0 0 8px;
|
|
188
|
+
color: var(--muted);
|
|
189
|
+
font-size: 13px;
|
|
190
|
+
font-weight: 750;
|
|
191
|
+
letter-spacing: 0.08em;
|
|
192
|
+
text-transform: uppercase;
|
|
193
|
+
}
|
|
194
|
+
h1 {
|
|
195
|
+
margin: 0;
|
|
196
|
+
max-width: 860px;
|
|
197
|
+
font-size: clamp(2rem, 4vw, 3rem);
|
|
198
|
+
line-height: 1.05;
|
|
199
|
+
letter-spacing: 0;
|
|
200
|
+
}
|
|
201
|
+
.lede {
|
|
202
|
+
max-width: 780px;
|
|
203
|
+
margin: 16px 0 0;
|
|
204
|
+
color: var(--muted);
|
|
205
|
+
font-size: 18px;
|
|
206
|
+
}
|
|
207
|
+
.metrics {
|
|
208
|
+
display: grid;
|
|
209
|
+
grid-template-columns: repeat(4, minmax(0, 1fr));
|
|
210
|
+
gap: 12px;
|
|
211
|
+
margin: 32px 0;
|
|
212
|
+
}
|
|
213
|
+
.metric, .section, details {
|
|
214
|
+
background: var(--panel);
|
|
215
|
+
border: 1px solid var(--line);
|
|
216
|
+
border-radius: var(--radius);
|
|
217
|
+
box-shadow: var(--shadow);
|
|
218
|
+
}
|
|
219
|
+
.metric { padding: 18px; }
|
|
220
|
+
.metric strong {
|
|
221
|
+
display: block;
|
|
222
|
+
font-size: 32px;
|
|
223
|
+
line-height: 1;
|
|
224
|
+
font-variant-numeric: tabular-nums;
|
|
225
|
+
}
|
|
226
|
+
.metric span {
|
|
227
|
+
display: block;
|
|
228
|
+
margin-top: 8px;
|
|
229
|
+
color: var(--muted);
|
|
230
|
+
font-size: 13px;
|
|
231
|
+
font-weight: 650;
|
|
232
|
+
}
|
|
233
|
+
.section {
|
|
234
|
+
margin-top: 18px;
|
|
235
|
+
padding: 24px;
|
|
236
|
+
}
|
|
237
|
+
.section-grid {
|
|
238
|
+
display: grid;
|
|
239
|
+
grid-template-columns: 260px minmax(0, 1fr);
|
|
240
|
+
gap: 28px;
|
|
241
|
+
}
|
|
242
|
+
.section-kicker {
|
|
243
|
+
margin: 0 0 12px;
|
|
244
|
+
color: var(--accent);
|
|
245
|
+
font-size: 13px;
|
|
246
|
+
font-weight: 800;
|
|
247
|
+
letter-spacing: 0.08em;
|
|
248
|
+
text-transform: uppercase;
|
|
249
|
+
}
|
|
250
|
+
.check-list {
|
|
251
|
+
display: grid;
|
|
252
|
+
gap: 8px;
|
|
253
|
+
}
|
|
254
|
+
.check {
|
|
255
|
+
display: flex;
|
|
256
|
+
align-items: baseline;
|
|
257
|
+
justify-content: space-between;
|
|
258
|
+
gap: 12px;
|
|
259
|
+
padding: 10px 0;
|
|
260
|
+
border-bottom: 1px solid var(--line);
|
|
261
|
+
}
|
|
262
|
+
.check span {
|
|
263
|
+
color: var(--muted);
|
|
264
|
+
font-size: 13px;
|
|
265
|
+
}
|
|
266
|
+
.check strong {
|
|
267
|
+
font-size: 18px;
|
|
268
|
+
font-variant-numeric: tabular-nums;
|
|
269
|
+
}
|
|
270
|
+
.section-head {
|
|
271
|
+
display: flex;
|
|
272
|
+
align-items: start;
|
|
273
|
+
justify-content: space-between;
|
|
274
|
+
gap: 16px;
|
|
275
|
+
margin-bottom: 16px;
|
|
276
|
+
}
|
|
277
|
+
.section-head h2 {
|
|
278
|
+
max-width: 680px;
|
|
279
|
+
margin: 0;
|
|
280
|
+
font-size: 24px;
|
|
281
|
+
line-height: 1.2;
|
|
282
|
+
letter-spacing: 0;
|
|
283
|
+
}
|
|
284
|
+
.section-head > span {
|
|
285
|
+
flex: 0 0 auto;
|
|
286
|
+
color: var(--muted);
|
|
287
|
+
font-size: 13px;
|
|
288
|
+
font-weight: 700;
|
|
289
|
+
white-space: nowrap;
|
|
290
|
+
}
|
|
291
|
+
.findings {
|
|
292
|
+
display: grid;
|
|
293
|
+
gap: 12px;
|
|
294
|
+
}
|
|
295
|
+
.finding {
|
|
296
|
+
border: 1px solid var(--line);
|
|
297
|
+
border-left: 4px solid var(--note);
|
|
298
|
+
border-radius: var(--radius);
|
|
299
|
+
padding: 16px;
|
|
300
|
+
background: #fffdf8;
|
|
301
|
+
}
|
|
302
|
+
.finding.danger { border-left-color: var(--danger); }
|
|
303
|
+
.finding.warn { border-left-color: var(--warn); }
|
|
304
|
+
.finding.low { border-left-color: var(--low); }
|
|
305
|
+
.finding-head {
|
|
306
|
+
display: flex;
|
|
307
|
+
align-items: start;
|
|
308
|
+
justify-content: space-between;
|
|
309
|
+
gap: 16px;
|
|
310
|
+
}
|
|
311
|
+
.finding-id {
|
|
312
|
+
margin: 0 0 4px;
|
|
313
|
+
color: var(--muted);
|
|
314
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
|
315
|
+
font-size: 12px;
|
|
316
|
+
}
|
|
317
|
+
h3 {
|
|
318
|
+
margin: 0;
|
|
319
|
+
font-size: 17px;
|
|
320
|
+
line-height: 1.3;
|
|
321
|
+
letter-spacing: 0;
|
|
322
|
+
}
|
|
323
|
+
.pill {
|
|
324
|
+
display: inline-flex;
|
|
325
|
+
align-items: center;
|
|
326
|
+
min-height: 26px;
|
|
327
|
+
padding: 4px 9px;
|
|
328
|
+
border-radius: 999px;
|
|
329
|
+
font-size: 12px;
|
|
330
|
+
font-weight: 800;
|
|
331
|
+
text-transform: uppercase;
|
|
332
|
+
white-space: nowrap;
|
|
333
|
+
}
|
|
334
|
+
.pill.danger { background: var(--danger-bg); color: var(--danger); }
|
|
335
|
+
.pill.warn { background: var(--warn-bg); color: var(--warn); }
|
|
336
|
+
.pill.note { background: var(--note-bg); color: var(--note); }
|
|
337
|
+
.pill.low { background: var(--low-bg); color: var(--low); }
|
|
338
|
+
.action {
|
|
339
|
+
margin: 10px 0 0;
|
|
340
|
+
color: var(--muted);
|
|
341
|
+
}
|
|
342
|
+
.sample-table {
|
|
343
|
+
margin-top: 14px;
|
|
344
|
+
overflow-x: auto;
|
|
345
|
+
border: 1px solid var(--line);
|
|
346
|
+
border-radius: var(--radius);
|
|
347
|
+
background: var(--panel);
|
|
348
|
+
}
|
|
349
|
+
table {
|
|
350
|
+
width: 100%;
|
|
351
|
+
min-width: 640px;
|
|
352
|
+
border-collapse: collapse;
|
|
353
|
+
}
|
|
354
|
+
th, td {
|
|
355
|
+
padding: 10px 12px;
|
|
356
|
+
border-bottom: 1px solid var(--line);
|
|
357
|
+
text-align: left;
|
|
358
|
+
vertical-align: top;
|
|
359
|
+
}
|
|
360
|
+
th {
|
|
361
|
+
color: var(--muted);
|
|
362
|
+
font-size: 12px;
|
|
363
|
+
font-weight: 800;
|
|
364
|
+
letter-spacing: 0.06em;
|
|
365
|
+
text-transform: uppercase;
|
|
366
|
+
}
|
|
367
|
+
td {
|
|
368
|
+
max-width: 360px;
|
|
369
|
+
color: #27272a;
|
|
370
|
+
font-size: 13px;
|
|
371
|
+
overflow-wrap: anywhere;
|
|
372
|
+
}
|
|
373
|
+
tr:last-child td { border-bottom: 0; }
|
|
374
|
+
.empty {
|
|
375
|
+
margin: 0;
|
|
376
|
+
color: var(--muted);
|
|
377
|
+
}
|
|
378
|
+
details {
|
|
379
|
+
margin-top: 24px;
|
|
380
|
+
padding: 18px;
|
|
381
|
+
}
|
|
382
|
+
summary {
|
|
383
|
+
cursor: pointer;
|
|
384
|
+
font-weight: 800;
|
|
385
|
+
}
|
|
386
|
+
pre {
|
|
387
|
+
margin: 16px 0 0;
|
|
388
|
+
max-height: 560px;
|
|
389
|
+
overflow: auto;
|
|
390
|
+
padding: 16px;
|
|
391
|
+
border-radius: var(--radius);
|
|
392
|
+
background: #111827;
|
|
393
|
+
color: #f9fafb;
|
|
394
|
+
font-size: 12px;
|
|
395
|
+
line-height: 1.45;
|
|
396
|
+
}
|
|
397
|
+
@media (max-width: 860px) {
|
|
398
|
+
main { width: min(100% - 24px, 1120px); padding-top: 32px; }
|
|
399
|
+
.metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
|
400
|
+
.section-grid { grid-template-columns: 1fr; gap: 18px; }
|
|
401
|
+
.section { padding: 18px; }
|
|
402
|
+
.section-head { display: block; }
|
|
403
|
+
.section-head > span { display: block; margin-top: 8px; }
|
|
404
|
+
}
|
|
405
|
+
@media (max-width: 520px) {
|
|
406
|
+
.metrics { grid-template-columns: 1fr; }
|
|
407
|
+
.lede { font-size: 16px; }
|
|
408
|
+
.finding-head { display: block; }
|
|
409
|
+
.pill { margin-top: 10px; }
|
|
410
|
+
}
|
|
411
|
+
</style>
|
|
412
|
+
</head>
|
|
413
|
+
<body>
|
|
414
|
+
<main>
|
|
415
|
+
<header>
|
|
416
|
+
<p class="eyebrow">Generated ${catalogReportHtmlEscape(report.generatedAt)}</p>
|
|
417
|
+
<h1>${catalogReportHtmlEscape(report.title)}</h1>
|
|
418
|
+
<p class="lede">${catalogReportHtmlEscape(report.lede)}</p>
|
|
419
|
+
</header>
|
|
420
|
+
<section class="metrics" aria-label="Audit summary">
|
|
421
|
+
${report.metrics
|
|
422
|
+
.map(
|
|
423
|
+
([label, value]) =>
|
|
424
|
+
`<div class="metric"><strong>${catalogReportHtmlEscape(catalogReportFormatMetric(value))}</strong><span>${catalogReportHtmlEscape(
|
|
425
|
+
label,
|
|
426
|
+
)}</span></div>`,
|
|
427
|
+
)
|
|
428
|
+
.join("")}
|
|
429
|
+
</section>
|
|
430
|
+
${sections}
|
|
431
|
+
<details>
|
|
432
|
+
<summary>Compressed JSON appendix</summary>
|
|
433
|
+
<pre>${catalogReportHtmlEscape(JSON.stringify(report.appendix, null, 2))}</pre>
|
|
434
|
+
</details>
|
|
435
|
+
</main>
|
|
436
|
+
</body>
|
|
437
|
+
</html>`;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
export async function publishCatalogReportPage(report: CatalogReport, ctx: any): Promise<any> {
|
|
441
|
+
const response = await ctx.swarm.page_create({
|
|
442
|
+
title: report.title,
|
|
443
|
+
slug: report.slug,
|
|
444
|
+
description: report.description,
|
|
445
|
+
contentType: "text/html",
|
|
446
|
+
authMode: "authed",
|
|
447
|
+
body: renderCatalogReportPage(report),
|
|
448
|
+
});
|
|
449
|
+
const payload = response?.data ?? response;
|
|
450
|
+
if (payload?.success === false) return { error: payload.error || "page_create failed" };
|
|
451
|
+
return {
|
|
452
|
+
id: payload?.id ?? payload?.page?.id ?? null,
|
|
453
|
+
appUrl: payload?.appUrl ?? payload?.app_url ?? null,
|
|
454
|
+
apiUrl: payload?.apiUrl ?? payload?.api_url ?? null,
|
|
455
|
+
version: payload?.version ?? payload?.page?.version ?? null,
|
|
456
|
+
};
|
|
457
|
+
}
|