@sigrun/sigrun 0.6.0 → 0.7.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/CHANGELOG.md +102 -0
- package/README.md +105 -18
- package/dist/blocks/build-stamp.json +2 -2
- package/dist/blocks/schema.js +5 -0
- package/dist/cli.js +85 -22
- package/dist/curation/host.js +46 -21
- package/dist/curation/migrate.js +96 -0
- package/dist/curation/overlays.js +52 -16
- package/dist/curation/paths.js +22 -0
- package/dist/curation/runs.js +34 -17
- package/dist/curation/skill-tool.js +34 -9
- package/dist/curation/store.js +382 -254
- package/dist/curation/targets.js +21 -0
- package/dist/curation/wrap.js +1 -6
- package/dist/extension.d.ts +12 -3
- package/dist/harness/boot.js +143 -6
- package/dist/harness/event-bus.js +19 -0
- package/dist/harness/loader.js +22 -0
- package/dist/harness/packages.js +7 -0
- package/dist/harness/reserved-sections.js +2 -0
- package/dist/harness/seam-host.js +71 -16
- package/dist/harness/workspace.js +34 -1
- package/dist/history/git.js +707 -0
- package/dist/history/trailers.js +145 -0
- package/dist/history/workspace-history.js +47 -0
- package/dist/jobs/index.js +5 -3
- package/dist/jobs/reconcile.js +2 -3
- package/dist/jobs/state-dir.js +9 -0
- package/dist/jobs/transfer.js +6 -6
- package/dist/sandbox/grants.js +11 -0
- package/dist/sandbox/launch.js +19 -9
- package/dist/sandbox/policy.js +6 -1
- package/dist/serve.js +3 -0
- package/dist/server/auth/proxy-only.js +5 -4
- package/dist/server/core-views/index.js +26 -0
- package/dist/server/core-views/jobs.js +661 -0
- package/dist/server/core-views/skills-tree.js +628 -0
- package/dist/server/core-views/skills.js +1090 -0
- package/dist/server/index.js +88 -0
- package/dist/server/jobs-access.js +62 -0
- package/dist/server/notifications.js +24 -12
- package/dist/server/routes/extensions.js +9 -1
- package/dist/server/routes/jobs.js +19 -52
- package/dist/server/routes/meta.js +9 -0
- package/dist/server/routes/onboarding.js +13 -1
- package/dist/server/surfaces/service.js +38 -0
- package/dist/skills/index.js +1 -1
- package/dist/skills/roots.js +301 -0
- package/node_modules/@sigrun/shared/CHANGELOG.md +13 -0
- package/node_modules/@sigrun/shared/dist/auth.d.ts +22 -0
- package/node_modules/@sigrun/shared/dist/auth.js +26 -0
- package/node_modules/@sigrun/shared/dist/blocks/index.d.ts +1 -1
- package/node_modules/@sigrun/shared/dist/blocks/types.d.ts +15 -0
- package/node_modules/@sigrun/shared/dist/model-label.d.ts +29 -0
- package/node_modules/@sigrun/shared/dist/model-label.js +55 -0
- package/node_modules/@sigrun/shared/dist/views.d.ts +28 -0
- package/node_modules/@sigrun/shared/package.json +9 -1
- package/package.json +7 -7
- package/webui/dist/assets/index-BI5k-Oci.js +55 -0
- package/webui/dist/assets/index-EGluyRXk.css +1 -0
- package/webui/dist/favicon.svg +7 -5
- package/webui/dist/index.html +3 -3
- package/webui/dist/assets/index-BR0di0OG.css +0 -1
- package/webui/dist/assets/index-BzrA1T4g.js +0 -55
|
@@ -0,0 +1,661 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `core/jobs` view (tabs+skills Task 5): the jobs surface as a
|
|
3
|
+
* core blocks view — the JobsPanel's data, re-cast data-bound so the
|
|
4
|
+
* Task 4 bump contract (`job.changed → bumpAllInstances("core",
|
|
5
|
+
* "jobs")`) refreshes clients WITHOUT a new tree. Every row the tree
|
|
6
|
+
* shows binds `{$: ...}` against the `data()` document; nothing job-
|
|
7
|
+
* specific is inlined at render time (an inlined row could never
|
|
8
|
+
* refresh — `bumpAllInstances` advances `rev` only).
|
|
9
|
+
*
|
|
10
|
+
* - The SIDEBAR is a `collection` bound to `/jobs` — the caller-
|
|
11
|
+
* visible rows (the shared `jobs-access.ts` rule, so this view and
|
|
12
|
+
* `GET /v1/jobs` always agree): name, schedule summary, next run,
|
|
13
|
+
* last outcome (JobsPanel parity). `onActivate` navigates
|
|
14
|
+
* `{job: id}`; compact widths drill in through the page's
|
|
15
|
+
* `selection` (Task 3).
|
|
16
|
+
* - MAIN holds the selected job: a summary, the runs (newest
|
|
17
|
+
* {@link PAGE_SIZE}, *Load more* → `runCursor`) and the revisions
|
|
18
|
+
* (paged the same by `revCursor`). A run with a conversation carries
|
|
19
|
+
* a `navigate {target: {conversation}}` button (a LITERAL per page —
|
|
20
|
+
* navigate params are JSON, never bindings, and data can never ride
|
|
21
|
+
* into them). `?params={job, revision}` selects one revision: only
|
|
22
|
+
* it shows a `diff` block of its spec JSON against the previous
|
|
23
|
+
* revision's (the JobsPanel before/after rule, per-page literals for
|
|
24
|
+
* the same navigate-params reason).
|
|
25
|
+
* - `runNow`/`resetState` need WRITE access: the buttons never render
|
|
26
|
+
* for a read caller and the handlers answer the `{forbidden}` action
|
|
27
|
+
* error (mirroring the routes' 403). Access `none` hides the job
|
|
28
|
+
* (absent from the sidebar; an unknown or hidden `job` param renders
|
|
29
|
+
* the list with the main empty state "Job not found"). An anonymous
|
|
30
|
+
* principal has no view at all (`access: "none"` — the surface
|
|
31
|
+
* route's 404).
|
|
32
|
+
*/
|
|
33
|
+
import { jobAccess } from "../jobs-access.js";
|
|
34
|
+
import { isSuperuser } from "../auth/principal.js";
|
|
35
|
+
/** The runs page size and the revisions page size (the plan refinement). */
|
|
36
|
+
export const PAGE_SIZE = 25;
|
|
37
|
+
/** A schedule's summary text (the JobsPanel rule, verbatim). */
|
|
38
|
+
function scheduleSummary(schedule) {
|
|
39
|
+
if ("cron" in schedule) {
|
|
40
|
+
return schedule.timezone === undefined
|
|
41
|
+
? schedule.cron
|
|
42
|
+
: `${schedule.cron} (${schedule.timezone})`;
|
|
43
|
+
}
|
|
44
|
+
if ("every" in schedule)
|
|
45
|
+
return `every ${schedule.every}`;
|
|
46
|
+
return `at ${schedule.at}`;
|
|
47
|
+
}
|
|
48
|
+
/** One job's sidebar/header summary fields. */
|
|
49
|
+
function summaryFields(row) {
|
|
50
|
+
return {
|
|
51
|
+
scheduleSummary: scheduleSummary(row.schedule),
|
|
52
|
+
nextRun: row.nextRun ?? null,
|
|
53
|
+
lastOutcome: row.lastRun?.outcome ?? null,
|
|
54
|
+
lastError: row.lastError ?? row.lastRun?.error ?? null,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
/** Whether `payload` (an action's JSON argument) is a plain object. */
|
|
58
|
+
function isObject(payload) {
|
|
59
|
+
return typeof payload === "object" && payload !== null && !Array.isArray(payload);
|
|
60
|
+
}
|
|
61
|
+
/** The `{jobId}` a runNow/resetState dispatch carries. Channel caveat
|
|
62
|
+
* (tabs+skills Task 6 e2e find): a plain callout button dispatches with
|
|
63
|
+
* a NULL payload on BOTH channels (webui ActionButtonRow and the tui's
|
|
64
|
+
* runAction pass `null` — the {itemId} convention covers only a
|
|
65
|
+
* collection ACTIVATION), so the handlers resolve the job from the
|
|
66
|
+
* instance params (`params.job`) — the surface is principal-scoped, so
|
|
67
|
+
* the params ARE the instance key and one selected job never serves
|
|
68
|
+
* another's fire. The payload `{jobId}` stays accepted (an explicit
|
|
69
|
+
* override, e.g. a future per-row button), params take the fallback. */
|
|
70
|
+
function jobIdOf(payload) {
|
|
71
|
+
if (!isObject(payload))
|
|
72
|
+
return undefined;
|
|
73
|
+
const jobId = payload.jobId;
|
|
74
|
+
return typeof jobId === "string" && jobId !== "" ? jobId : undefined;
|
|
75
|
+
}
|
|
76
|
+
/** The `{itemId}` an `onActivate` (collection row activation) dispatch carries. */
|
|
77
|
+
function itemIdOf(payload) {
|
|
78
|
+
if (!isObject(payload))
|
|
79
|
+
return undefined;
|
|
80
|
+
const itemId = payload.itemId;
|
|
81
|
+
return typeof itemId === "string" && itemId !== "" ? itemId : undefined;
|
|
82
|
+
}
|
|
83
|
+
/** Parse the instance params (loose JSON → the typed shape; bad types drop). */
|
|
84
|
+
function paramsOf(params) {
|
|
85
|
+
const out = {};
|
|
86
|
+
if (typeof params.job === "string" && params.job !== "")
|
|
87
|
+
out.job = params.job;
|
|
88
|
+
if (Number.isInteger(params.revision) && params.revision > 0) {
|
|
89
|
+
out.revision = params.revision;
|
|
90
|
+
}
|
|
91
|
+
if (Number.isInteger(params.revCursor) && params.revCursor > 0) {
|
|
92
|
+
out.revCursor = params.revCursor;
|
|
93
|
+
}
|
|
94
|
+
if (typeof params.runCursor === "string" && params.runCursor !== "") {
|
|
95
|
+
out.runCursor = params.runCursor;
|
|
96
|
+
}
|
|
97
|
+
return out;
|
|
98
|
+
}
|
|
99
|
+
/** One run's stable cursor + row id (fireAt is the run's unique key). */
|
|
100
|
+
function runKey(run) {
|
|
101
|
+
return `${run.jobId}:${run.fireAt}`;
|
|
102
|
+
}
|
|
103
|
+
/** The run row (the JobsPanel run line: when, version, manual, outcome, error). */
|
|
104
|
+
function runRow(run) {
|
|
105
|
+
return {
|
|
106
|
+
id: runKey(run),
|
|
107
|
+
at: run.fireAt,
|
|
108
|
+
version: run.version,
|
|
109
|
+
manual: run.manual,
|
|
110
|
+
outcome: run.outcome,
|
|
111
|
+
error: run.error ?? null,
|
|
112
|
+
conversationId: run.conversationId ?? null,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
/** The revision row (version, changedAt, changedBy). */
|
|
116
|
+
function revisionRow(revision) {
|
|
117
|
+
return {
|
|
118
|
+
id: String(revision.version),
|
|
119
|
+
version: revision.version,
|
|
120
|
+
changedAt: revision.changedAt,
|
|
121
|
+
changedBy: revision.changedBy,
|
|
122
|
+
enabled: revision.enabled,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
/** The pretty-printed spec JSON the diff panes show (the JobsPanel print). */
|
|
126
|
+
function specJson(revision) {
|
|
127
|
+
return JSON.stringify(revision.spec, null, 2);
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Build the view's data document for one caller. The `job` header,
|
|
131
|
+
* runs and revisions only resolve when the caller can SEE the job —
|
|
132
|
+
* otherwise `found: false` (the main empty state), exactly the list's
|
|
133
|
+
* absence rule.
|
|
134
|
+
*/
|
|
135
|
+
async function readData(jobs, principal, params) {
|
|
136
|
+
const rows = [];
|
|
137
|
+
let selected;
|
|
138
|
+
for (const wire of await jobs.list()) {
|
|
139
|
+
const { access: _stamped, ...row } = wire;
|
|
140
|
+
const access = jobAccess(principal, row);
|
|
141
|
+
if (access === "none")
|
|
142
|
+
continue;
|
|
143
|
+
rows.push({ id: row.id, name: row.name, ...summaryFields(row) });
|
|
144
|
+
if (params.job !== undefined && row.id === params.job) {
|
|
145
|
+
selected = { ...row, access };
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
const main = {
|
|
149
|
+
found: false,
|
|
150
|
+
job: null,
|
|
151
|
+
runs: { items: [], hasMore: false, nextRunCursor: null },
|
|
152
|
+
revisions: { items: [], hasMore: false, nextRevCursor: null },
|
|
153
|
+
};
|
|
154
|
+
if (selected === undefined)
|
|
155
|
+
return { jobs: rows, main };
|
|
156
|
+
// Runs: newest first; the cursor IS a run key (opaque to clients).
|
|
157
|
+
// The service's list is bounded by the retention cap, so the view
|
|
158
|
+
// fetches it whole and pages in memory.
|
|
159
|
+
const allRuns = await jobs.runs(selected.id, Number.MAX_SAFE_INTEGER);
|
|
160
|
+
const runStart = params.runCursor === undefined
|
|
161
|
+
? 0
|
|
162
|
+
: Math.max(0, allRuns.findIndex((run) => runKey(run) === params.runCursor));
|
|
163
|
+
const runPage = allRuns.slice(runStart, runStart + PAGE_SIZE);
|
|
164
|
+
const nextRun = allRuns[runStart + PAGE_SIZE];
|
|
165
|
+
main.runs = {
|
|
166
|
+
items: runPage.map(runRow),
|
|
167
|
+
hasMore: nextRun !== undefined,
|
|
168
|
+
nextRunCursor: nextRun === undefined ? null : runKey(nextRun),
|
|
169
|
+
};
|
|
170
|
+
// Revisions: newest first, paged by PAGE_SIZE with a plain offset cursor.
|
|
171
|
+
const allRevisions = await jobs.revisions(selected.id);
|
|
172
|
+
const revStart = params.revCursor ?? 0;
|
|
173
|
+
const revPage = allRevisions.slice(revStart, revStart + PAGE_SIZE);
|
|
174
|
+
const nextRev = revStart + PAGE_SIZE;
|
|
175
|
+
main.revisions = {
|
|
176
|
+
items: revPage.map(revisionRow),
|
|
177
|
+
hasMore: nextRev < allRevisions.length,
|
|
178
|
+
nextRevCursor: nextRev < allRevisions.length ? nextRev : null,
|
|
179
|
+
};
|
|
180
|
+
main.found = true;
|
|
181
|
+
main.job = {
|
|
182
|
+
id: selected.id,
|
|
183
|
+
name: selected.name,
|
|
184
|
+
...summaryFields(selected),
|
|
185
|
+
state: selected.state,
|
|
186
|
+
enabled: selected.enabled,
|
|
187
|
+
version: selected.version,
|
|
188
|
+
// `none` is impossible here (selected came from the visible list);
|
|
189
|
+
// the fallback only satisfies the narrowing.
|
|
190
|
+
access: selected.access === "none" ? "read" : selected.access,
|
|
191
|
+
};
|
|
192
|
+
return { jobs: rows, main };
|
|
193
|
+
}
|
|
194
|
+
/** The one revision selected for the diff, plus its previous (baseline). */
|
|
195
|
+
function selectDiff(revisions, page, version) {
|
|
196
|
+
if (version === undefined)
|
|
197
|
+
return undefined;
|
|
198
|
+
const index = page.findIndex((revision) => revision.version === version);
|
|
199
|
+
if (index === -1)
|
|
200
|
+
return undefined;
|
|
201
|
+
const selected = page[index];
|
|
202
|
+
const previous = page[index + 1] ?? revisions[revisions.indexOf(selected) + 1];
|
|
203
|
+
if (previous === undefined)
|
|
204
|
+
return undefined;
|
|
205
|
+
return { version, before: specJson(previous), after: specJson(selected) };
|
|
206
|
+
}
|
|
207
|
+
/** The view surface (`SurfaceDef<"view">`), built over the jobs service. */
|
|
208
|
+
export function createJobsView(deps) {
|
|
209
|
+
const { jobs } = deps;
|
|
210
|
+
/** The caller's directory-aware principal (undefined user = anonymous).
|
|
211
|
+
* Whole-branch review I3: the USERLESS SUPERUSER (the naive
|
|
212
|
+
* single-operator loopback principal) is admitted — the /v1/jobs
|
|
213
|
+
* routes grant it write, and the view is parity (the access rule's
|
|
214
|
+
* superuser clause answers write for every row). */
|
|
215
|
+
const callerOf = (ctx) => {
|
|
216
|
+
if (ctx.principal.user === undefined && !isSuperuser(ctx.principal)) {
|
|
217
|
+
return undefined;
|
|
218
|
+
}
|
|
219
|
+
return deps.accessPrincipal(ctx.principal);
|
|
220
|
+
};
|
|
221
|
+
/** The tree's shared, data-bound skeleton (params/access independent). */
|
|
222
|
+
const baseTree = () => ({
|
|
223
|
+
v: 1,
|
|
224
|
+
root: {
|
|
225
|
+
type: "page",
|
|
226
|
+
id: "jobs-root",
|
|
227
|
+
title: "Jobs",
|
|
228
|
+
sidebar: [
|
|
229
|
+
{
|
|
230
|
+
type: "collection",
|
|
231
|
+
id: "jobs",
|
|
232
|
+
display: "list",
|
|
233
|
+
items: { $: "/jobs" },
|
|
234
|
+
// Per-row drill-in (review Critical 2): a `navigate`
|
|
235
|
+
// onActivate could never carry the ACTIVATED row's id
|
|
236
|
+
// (navigate params are static literals; the {itemId}
|
|
237
|
+
// payload is shell-dropped for view-only actions). An
|
|
238
|
+
// `edit` activation instead dispatches {itemId} to the
|
|
239
|
+
// `openJob` handler — the curator view's shape — whose
|
|
240
|
+
// accepted result carries `navigate` back through the
|
|
241
|
+
// wire (§6.3). Gate note: `edit` is not view-only, so the
|
|
242
|
+
// channels require surface access "write" to dispatch it
|
|
243
|
+
// (webui rows.tsx useActivate) — this view always returns
|
|
244
|
+
// "write" for an identified caller (the action rule is
|
|
245
|
+
// per-job, enforced in the handlers), so the gate passes.
|
|
246
|
+
onActivate: { type: "edit", name: "openJob" },
|
|
247
|
+
item: {
|
|
248
|
+
title: { $: "./name" },
|
|
249
|
+
subtitle: { $: "./scheduleSummary" },
|
|
250
|
+
meta: [
|
|
251
|
+
{ text: { $: "./nextRun" }, icon: "clock" },
|
|
252
|
+
{ text: { $: "./lastOutcome" }, icon: "info" },
|
|
253
|
+
],
|
|
254
|
+
},
|
|
255
|
+
},
|
|
256
|
+
],
|
|
257
|
+
main: [
|
|
258
|
+
{
|
|
259
|
+
type: "collection",
|
|
260
|
+
id: "main-empty",
|
|
261
|
+
display: "list",
|
|
262
|
+
items: { $: "/main/runs/items" },
|
|
263
|
+
item: { title: { $: "./at" } },
|
|
264
|
+
},
|
|
265
|
+
],
|
|
266
|
+
},
|
|
267
|
+
});
|
|
268
|
+
/** The anonymous/none-access tree (an empty page; the route 404s it). */
|
|
269
|
+
const noneTree = () =>
|
|
270
|
+
// SAFETY: the tree object is a JSON literal built here; `Tree`'s
|
|
271
|
+
// interfaces lack the index signature the wire type requires, so
|
|
272
|
+
// the cast only restores that erased identity.
|
|
273
|
+
({
|
|
274
|
+
v: 1,
|
|
275
|
+
root: {
|
|
276
|
+
type: "page",
|
|
277
|
+
id: "jobs-root",
|
|
278
|
+
title: "Jobs",
|
|
279
|
+
main: [{ type: "markdown", text: "Sign in to see jobs." }],
|
|
280
|
+
},
|
|
281
|
+
});
|
|
282
|
+
/** The full tree for one resolved page (params + access + diff). */
|
|
283
|
+
const buildTree = (params, data, diff) => {
|
|
284
|
+
const tree = baseTree();
|
|
285
|
+
// Blocks are built as loose JSON objects (the validator owns the
|
|
286
|
+
// wire shape; the service validates every tree before serving).
|
|
287
|
+
const main = [];
|
|
288
|
+
if (data.main.found && data.main.job !== null) {
|
|
289
|
+
const job = data.main.job;
|
|
290
|
+
// IMPORTANT 3 (review fix): per-run conversation buttons ride
|
|
291
|
+
// the card's `actions` as per-row LITERALS. The block contract's
|
|
292
|
+
// id-uniqueness walk (validate.ts:507-517) does NOT descend into
|
|
293
|
+
// card templates (checkCard only validates the card schema + its
|
|
294
|
+
// actions array), so a per-row literal id never collides. The
|
|
295
|
+
// pattern is the only way to carry per-row conversation ids into
|
|
296
|
+
// navigate targets (bindings cannot reach navigate params).
|
|
297
|
+
const runButtons = data.main.runs.items
|
|
298
|
+
.filter((run) => run.conversationId !== null)
|
|
299
|
+
.map((run) => ({
|
|
300
|
+
id: `run-open-${run.id}`,
|
|
301
|
+
label: "Conversation",
|
|
302
|
+
icon: "link",
|
|
303
|
+
action: {
|
|
304
|
+
type: "navigate",
|
|
305
|
+
target: { conversation: run.conversationId },
|
|
306
|
+
},
|
|
307
|
+
}));
|
|
308
|
+
const writeActions = job.access === "write"
|
|
309
|
+
? [
|
|
310
|
+
{
|
|
311
|
+
id: "run-now",
|
|
312
|
+
label: "Run now",
|
|
313
|
+
icon: "refresh",
|
|
314
|
+
action: { type: "submit", name: "runNow" },
|
|
315
|
+
},
|
|
316
|
+
{
|
|
317
|
+
id: "reset-state",
|
|
318
|
+
label: "Reset state",
|
|
319
|
+
icon: "delete",
|
|
320
|
+
action: {
|
|
321
|
+
type: "submit",
|
|
322
|
+
name: "resetState",
|
|
323
|
+
confirm: {
|
|
324
|
+
title: "Reset state?",
|
|
325
|
+
body: "Reset this job's state? Its state folder is emptied.",
|
|
326
|
+
},
|
|
327
|
+
},
|
|
328
|
+
},
|
|
329
|
+
]
|
|
330
|
+
: [];
|
|
331
|
+
main.push({
|
|
332
|
+
type: "section",
|
|
333
|
+
id: "job-summary",
|
|
334
|
+
title: { $: "/main/job/name" },
|
|
335
|
+
children: [
|
|
336
|
+
{
|
|
337
|
+
type: "fields",
|
|
338
|
+
rows: [
|
|
339
|
+
{ label: "Schedule", value: { $: "/main/job/scheduleSummary" } },
|
|
340
|
+
{ label: "Next run", value: { $: "/main/job/nextRun" } },
|
|
341
|
+
{ label: "Last outcome", value: { $: "/main/job/lastOutcome" } },
|
|
342
|
+
{ label: "Last error", value: { $: "/main/job/lastError" } },
|
|
343
|
+
{ label: "State", value: { $: "/main/job/state" } },
|
|
344
|
+
{ label: "Version", value: { $: "/main/job/version" } },
|
|
345
|
+
],
|
|
346
|
+
},
|
|
347
|
+
],
|
|
348
|
+
}, ...(writeActions.length === 0
|
|
349
|
+
? []
|
|
350
|
+
: [
|
|
351
|
+
{
|
|
352
|
+
type: "callout",
|
|
353
|
+
id: "job-actions",
|
|
354
|
+
tone: "neutral",
|
|
355
|
+
title: "Actions",
|
|
356
|
+
actions: writeActions,
|
|
357
|
+
},
|
|
358
|
+
]), {
|
|
359
|
+
type: "section",
|
|
360
|
+
id: "job-runs",
|
|
361
|
+
title: "Runs",
|
|
362
|
+
children: [
|
|
363
|
+
{
|
|
364
|
+
type: "collection",
|
|
365
|
+
id: "runs",
|
|
366
|
+
display: "list",
|
|
367
|
+
items: { $: "/main/runs/items" },
|
|
368
|
+
item: {
|
|
369
|
+
title: { $: "./at" },
|
|
370
|
+
subtitle: { $: "./outcome" },
|
|
371
|
+
body: { $: "./error" },
|
|
372
|
+
badges: [{ text: { $: "./manual" }, tone: "neutral" }],
|
|
373
|
+
...(runButtons.length === 0 ? {} : { actions: runButtons }),
|
|
374
|
+
},
|
|
375
|
+
},
|
|
376
|
+
],
|
|
377
|
+
}, ...(data.main.runs.hasMore && data.main.runs.nextRunCursor !== null
|
|
378
|
+
? [
|
|
379
|
+
{
|
|
380
|
+
type: "callout",
|
|
381
|
+
id: "runs-more",
|
|
382
|
+
tone: "neutral",
|
|
383
|
+
title: "More runs",
|
|
384
|
+
actions: [
|
|
385
|
+
{
|
|
386
|
+
id: "runs-more-load",
|
|
387
|
+
label: "Load more",
|
|
388
|
+
icon: "more",
|
|
389
|
+
action: {
|
|
390
|
+
type: "navigate",
|
|
391
|
+
params: {
|
|
392
|
+
job: job.id,
|
|
393
|
+
runCursor: data.main.runs.nextRunCursor,
|
|
394
|
+
},
|
|
395
|
+
},
|
|
396
|
+
},
|
|
397
|
+
],
|
|
398
|
+
},
|
|
399
|
+
]
|
|
400
|
+
: []), {
|
|
401
|
+
type: "section",
|
|
402
|
+
id: "job-revisions",
|
|
403
|
+
title: "Revisions",
|
|
404
|
+
children: [
|
|
405
|
+
{
|
|
406
|
+
type: "collection",
|
|
407
|
+
id: "revisions",
|
|
408
|
+
display: "list",
|
|
409
|
+
items: { $: "/main/revisions/items" },
|
|
410
|
+
// Per-row drill-in (the sidebar's openJob pattern,
|
|
411
|
+
// whole-branch review I1): a static `navigate` could
|
|
412
|
+
// only re-navigate to the ALREADY-selected revision —
|
|
413
|
+
// the diff would be unreachable by clicking. The
|
|
414
|
+
// `edit` activation carries {itemId} (the row's id,
|
|
415
|
+
// the revision VERSION) to `openRevision`, whose
|
|
416
|
+
// accepted result carries {job, revision} back (§6.3).
|
|
417
|
+
onActivate: { type: "edit", name: "openRevision" },
|
|
418
|
+
item: {
|
|
419
|
+
title: { $: "./version" },
|
|
420
|
+
subtitle: { $: "./changedBy" },
|
|
421
|
+
meta: [{ time: { $: "./changedAt" }, format: "relative" }],
|
|
422
|
+
},
|
|
423
|
+
},
|
|
424
|
+
],
|
|
425
|
+
}, ...(data.main.revisions.hasMore &&
|
|
426
|
+
data.main.revisions.nextRevCursor !== null
|
|
427
|
+
? [
|
|
428
|
+
{
|
|
429
|
+
type: "callout",
|
|
430
|
+
id: "revisions-more",
|
|
431
|
+
tone: "neutral",
|
|
432
|
+
title: "More revisions",
|
|
433
|
+
actions: [
|
|
434
|
+
{
|
|
435
|
+
id: "revisions-more-load",
|
|
436
|
+
label: "Load more",
|
|
437
|
+
icon: "more",
|
|
438
|
+
action: {
|
|
439
|
+
type: "navigate",
|
|
440
|
+
params: {
|
|
441
|
+
job: job.id,
|
|
442
|
+
revCursor: data.main.revisions.nextRevCursor,
|
|
443
|
+
},
|
|
444
|
+
},
|
|
445
|
+
},
|
|
446
|
+
],
|
|
447
|
+
},
|
|
448
|
+
]
|
|
449
|
+
: []));
|
|
450
|
+
if (diff !== undefined) {
|
|
451
|
+
main.push({
|
|
452
|
+
type: "section",
|
|
453
|
+
id: "revision-diff",
|
|
454
|
+
title: `Revision ${diff.version}`,
|
|
455
|
+
children: [
|
|
456
|
+
{
|
|
457
|
+
type: "diff",
|
|
458
|
+
id: "diff",
|
|
459
|
+
before: diff.before,
|
|
460
|
+
after: diff.after,
|
|
461
|
+
lang: "json",
|
|
462
|
+
},
|
|
463
|
+
],
|
|
464
|
+
});
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
else {
|
|
468
|
+
main.push({
|
|
469
|
+
type: "section",
|
|
470
|
+
id: "job-empty",
|
|
471
|
+
children: [{ type: "markdown", text: "Job not found" }],
|
|
472
|
+
});
|
|
473
|
+
}
|
|
474
|
+
tree.root.main = main;
|
|
475
|
+
// SAFETY: the tree object is a JSON literal built here; `Tree`'s
|
|
476
|
+
// interfaces lack the index signature the wire type requires, so
|
|
477
|
+
// the cast only restores that erased identity.
|
|
478
|
+
return {
|
|
479
|
+
...tree,
|
|
480
|
+
root: {
|
|
481
|
+
...tree.root,
|
|
482
|
+
selection: {
|
|
483
|
+
selected: params.job !== undefined,
|
|
484
|
+
back: {},
|
|
485
|
+
},
|
|
486
|
+
...(data.main.found
|
|
487
|
+
? {}
|
|
488
|
+
: {
|
|
489
|
+
empty: {
|
|
490
|
+
icon: "clock",
|
|
491
|
+
title: "Job not found",
|
|
492
|
+
},
|
|
493
|
+
}),
|
|
494
|
+
},
|
|
495
|
+
};
|
|
496
|
+
};
|
|
497
|
+
/** Resolve one caller's access to one job id for an action. */
|
|
498
|
+
const actionAccess = async (ctx, jobId) => {
|
|
499
|
+
const caller = callerOf(ctx);
|
|
500
|
+
if (caller === undefined) {
|
|
501
|
+
return { error: { ok: false, error: { message: "forbidden" } } };
|
|
502
|
+
}
|
|
503
|
+
const wire = await jobs.get(jobId);
|
|
504
|
+
if (wire === undefined) {
|
|
505
|
+
return { error: { ok: false, error: { message: "job_not_found" } } };
|
|
506
|
+
}
|
|
507
|
+
const { access: _stamped, ...row } = wire;
|
|
508
|
+
const access = jobAccess(caller, row);
|
|
509
|
+
if (access === "none") {
|
|
510
|
+
return { error: { ok: false, error: { message: "job_not_found" } } };
|
|
511
|
+
}
|
|
512
|
+
if (access === "read") {
|
|
513
|
+
return { error: { ok: false, error: { message: "forbidden" } } };
|
|
514
|
+
}
|
|
515
|
+
return { row, access };
|
|
516
|
+
};
|
|
517
|
+
return {
|
|
518
|
+
id: "jobs",
|
|
519
|
+
title: "Jobs",
|
|
520
|
+
icon: "clock",
|
|
521
|
+
placement: "view",
|
|
522
|
+
// A principal-owned surface (§7.3): the principal folds into the
|
|
523
|
+
// instance identity so two users' views are two instances.
|
|
524
|
+
instanceKey: (params, principal) => JSON.stringify([principal.user ?? "", params]),
|
|
525
|
+
render: async (ctx) => {
|
|
526
|
+
const caller = callerOf(ctx);
|
|
527
|
+
if (caller === undefined) {
|
|
528
|
+
return { tree: noneTree(), data: {}, access: "none" };
|
|
529
|
+
}
|
|
530
|
+
const params = paramsOf(ctx.params);
|
|
531
|
+
const data = await readData(jobs, caller, params);
|
|
532
|
+
const revisions = params.job === undefined || !data.main.found
|
|
533
|
+
? []
|
|
534
|
+
: await jobs.revisions(params.job);
|
|
535
|
+
const revStart = params.revCursor ?? 0;
|
|
536
|
+
const page = revisions.slice(revStart, revStart + PAGE_SIZE);
|
|
537
|
+
const diff = selectDiff(revisions, page, params.revision);
|
|
538
|
+
return {
|
|
539
|
+
tree: buildTree(params, data, diff),
|
|
540
|
+
// SAFETY: the data document is a JSON mirror (every field a
|
|
541
|
+
// literal); the interface lacks the index signature JsonObject
|
|
542
|
+
// requires, so the cast only restores that erased identity.
|
|
543
|
+
data: data,
|
|
544
|
+
// The surface access is ALWAYS "write" for an identified
|
|
545
|
+
// caller: the §6.3 action route 403s read-verdict surfaces
|
|
546
|
+
// outright, but this view's action rule is PER JOB (a caller
|
|
547
|
+
// may write one listed job and only read another) — the
|
|
548
|
+
// handlers enforce job-level write themselves. Read hiding
|
|
549
|
+
// is tree-level (the buttons never render), like the §6.5
|
|
550
|
+
// read rule's client half.
|
|
551
|
+
access: "write",
|
|
552
|
+
};
|
|
553
|
+
},
|
|
554
|
+
data: async (ctx) => {
|
|
555
|
+
const caller = callerOf(ctx);
|
|
556
|
+
if (caller === undefined)
|
|
557
|
+
return { data: {}, access: "none" };
|
|
558
|
+
const params = paramsOf(ctx.params);
|
|
559
|
+
const data = await readData(jobs, caller, params);
|
|
560
|
+
return {
|
|
561
|
+
// SAFETY: same JSON-mirror cast as `render`'s (the comment there).
|
|
562
|
+
data: data,
|
|
563
|
+
access: "write",
|
|
564
|
+
};
|
|
565
|
+
},
|
|
566
|
+
actions: {
|
|
567
|
+
// The sidebar row's drill-in (review Critical 2): the `edit`
|
|
568
|
+
// activation carries {itemId} = the row's id — the JOB id, the
|
|
569
|
+
// collection rows' own `id` field (the channels derive item ids
|
|
570
|
+
// from it, spec §5.5) — and the accepted result carries the
|
|
571
|
+
// navigate back (§6.3: SurfaceActOk.navigate passes the wire
|
|
572
|
+
// unchanged). The webui applies it (useSurface routes
|
|
573
|
+
// result.navigate through onNavigate); the TUI does NOT apply
|
|
574
|
+
// result.navigate yet — a PRE-EXISTING channel gap for every
|
|
575
|
+
// ext view's action-result navigation, out of scope here
|
|
576
|
+
// (whole-branch review ledger).
|
|
577
|
+
openJob: async (ctx, payload) => {
|
|
578
|
+
// Same admission as callerOf (review I3's P1 remainder):
|
|
579
|
+
// anonymous → forbidden; the userless superuser is admitted.
|
|
580
|
+
if (callerOf(ctx) === undefined) {
|
|
581
|
+
return { ok: false, error: { message: "forbidden" } };
|
|
582
|
+
}
|
|
583
|
+
const jobId = itemIdOf(payload);
|
|
584
|
+
if (jobId === undefined) {
|
|
585
|
+
return { ok: false, error: { message: "openJob expects {itemId}" } };
|
|
586
|
+
}
|
|
587
|
+
return {
|
|
588
|
+
ok: true,
|
|
589
|
+
navigate: { type: "navigate", params: { job: jobId } },
|
|
590
|
+
};
|
|
591
|
+
},
|
|
592
|
+
// A revision row's drill-in (whole-branch review I1): {itemId}
|
|
593
|
+
// is the row's id = the revision VERSION; the current job rides
|
|
594
|
+
// the instance params (the surface is principal-scoped, so the
|
|
595
|
+
// params identify the selected job). The accepted result carries
|
|
596
|
+
// {job, revision} back through the wire (§6.3), same as openJob.
|
|
597
|
+
openRevision: async (ctx, payload) => {
|
|
598
|
+
if (callerOf(ctx) === undefined) {
|
|
599
|
+
return { ok: false, error: { message: "forbidden" } };
|
|
600
|
+
}
|
|
601
|
+
const jobId = paramsOf(ctx.params).job;
|
|
602
|
+
if (jobId === undefined) {
|
|
603
|
+
return {
|
|
604
|
+
ok: false,
|
|
605
|
+
error: { message: "openRevision needs a selected job (params.job)" },
|
|
606
|
+
};
|
|
607
|
+
}
|
|
608
|
+
const itemId = itemIdOf(payload);
|
|
609
|
+
const revision = itemId === undefined ? NaN : Number(itemId);
|
|
610
|
+
if (!Number.isInteger(revision) || revision <= 0) {
|
|
611
|
+
return { ok: false, error: { message: "openRevision expects {itemId}" } };
|
|
612
|
+
}
|
|
613
|
+
return {
|
|
614
|
+
ok: true,
|
|
615
|
+
navigate: { type: "navigate", params: { job: jobId, revision } },
|
|
616
|
+
};
|
|
617
|
+
},
|
|
618
|
+
runNow: async (ctx, payload) => {
|
|
619
|
+
const jobId = jobIdOf(payload) ?? paramsOf(ctx.params).job;
|
|
620
|
+
if (jobId === undefined) {
|
|
621
|
+
return {
|
|
622
|
+
ok: false,
|
|
623
|
+
error: { message: "runNow needs a selected job (params.job)" },
|
|
624
|
+
};
|
|
625
|
+
}
|
|
626
|
+
const resolved = await actionAccess(ctx, jobId);
|
|
627
|
+
if ("error" in resolved)
|
|
628
|
+
return resolved.error;
|
|
629
|
+
const fired = await jobs.runNow(jobId);
|
|
630
|
+
return {
|
|
631
|
+
ok: true,
|
|
632
|
+
toast: `Run now: ${resolved.row.name} (${fired.outcome})`,
|
|
633
|
+
};
|
|
634
|
+
},
|
|
635
|
+
resetState: async (ctx, payload) => {
|
|
636
|
+
const jobId = jobIdOf(payload) ?? paramsOf(ctx.params).job;
|
|
637
|
+
if (jobId === undefined) {
|
|
638
|
+
return {
|
|
639
|
+
ok: false,
|
|
640
|
+
error: { message: "resetState needs a selected job (params.job)" },
|
|
641
|
+
};
|
|
642
|
+
}
|
|
643
|
+
const resolved = await actionAccess(ctx, jobId);
|
|
644
|
+
if ("error" in resolved)
|
|
645
|
+
return resolved.error;
|
|
646
|
+
try {
|
|
647
|
+
await jobs.resetState(jobId);
|
|
648
|
+
}
|
|
649
|
+
catch (error) {
|
|
650
|
+
return {
|
|
651
|
+
ok: false,
|
|
652
|
+
error: {
|
|
653
|
+
message: error instanceof Error ? error.message : String(error),
|
|
654
|
+
},
|
|
655
|
+
};
|
|
656
|
+
}
|
|
657
|
+
return { ok: true, toast: `Reset state: ${resolved.row.name}` };
|
|
658
|
+
},
|
|
659
|
+
},
|
|
660
|
+
};
|
|
661
|
+
}
|