@sigrun/sigrun 0.8.0 → 0.10.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 +55 -87
- package/dist/config.js +47 -0
- package/dist/curation/host.js +125 -21
- package/dist/curation/runs.js +15 -4
- package/dist/extension.d.ts +268 -3
- package/dist/harness/boot.js +113 -3
- package/dist/harness/complete.js +127 -0
- package/dist/harness/decision-tool.js +93 -0
- package/dist/harness/decisions.js +477 -0
- package/dist/harness/event-bus.js +12 -0
- package/dist/harness/interactions.js +81 -0
- package/dist/harness/interactive-tools.js +97 -0
- package/dist/harness/loader.js +243 -1
- package/dist/harness/packages.js +7 -0
- package/dist/harness/published-hooks.js +208 -0
- package/dist/harness/seam-host.js +136 -1
- package/dist/harness/settings.js +41 -0
- package/dist/history/git.js +6 -2
- package/dist/jobs/fire.js +10 -0
- package/dist/jobs/index.js +3 -0
- package/dist/jobs/observe.js +27 -2
- package/dist/jobs/spec.js +15 -1
- package/dist/server/conversation-ops.js +31 -0
- package/dist/server/core-views/jobs-form.js +335 -0
- package/dist/server/core-views/jobs.js +300 -15
- package/dist/server/core-views/skills-tree.js +3 -12
- package/dist/server/core-views/skills.js +11 -23
- package/dist/server/host-bridge.js +53 -1
- package/dist/server/index.js +2 -1
- package/dist/server/routes/extensions.js +5 -0
- package/dist/server/routes/interactions.js +48 -1
- package/dist/server/routes/meta.js +17 -0
- package/dist/server/routes/providers.js +65 -8
- package/dist/server/routes/submissions.js +3 -0
- package/dist/settings/registry.js +18 -0
- package/node_modules/@sigrun/shared/CHANGELOG.md +12 -0
- package/node_modules/@sigrun/shared/dist/decisions.d.ts +95 -0
- package/node_modules/@sigrun/shared/dist/decisions.js +16 -0
- package/node_modules/@sigrun/shared/dist/interactions.d.ts +14 -0
- package/node_modules/@sigrun/shared/dist/jobs.d.ts +4 -0
- package/node_modules/@sigrun/shared/dist/settings.d.ts +32 -0
- package/node_modules/@sigrun/shared/dist/views.d.ts +58 -0
- package/node_modules/@sigrun/shared/package.json +6 -2
- package/package.json +8 -8
- package/webui/dist/assets/index-By63QKOA.js +55 -0
- package/webui/dist/assets/index-D5KowS7k.css +1 -0
- package/webui/dist/index.html +2 -2
- package/webui/dist/assets/index-B3uMNF59.css +0 -1
- package/webui/dist/assets/index-BXsCyod4.js +0 -55
|
@@ -30,8 +30,10 @@
|
|
|
30
30
|
* principal has no view at all (`access: "none"` — the surface
|
|
31
31
|
* route's 404).
|
|
32
32
|
*/
|
|
33
|
+
import { JobDeclaredError, JobNotFoundError, } from "../../jobs/index.js";
|
|
33
34
|
import { jobAccess } from "../jobs-access.js";
|
|
34
35
|
import { isSuperuser } from "../auth/principal.js";
|
|
36
|
+
import { createSpecOf, emptyForm, formFromJob, formValuesOf, jobFormFields, savePatchOf, } from "./jobs-form.js";
|
|
35
37
|
/** The runs page size and the revisions page size (the plan refinement). */
|
|
36
38
|
export const PAGE_SIZE = 25;
|
|
37
39
|
/** A schedule's summary text (the JobsPanel rule, verbatim). */
|
|
@@ -45,6 +47,23 @@ function scheduleSummary(schedule) {
|
|
|
45
47
|
return `every ${schedule.every}`;
|
|
46
48
|
return `at ${schedule.at}`;
|
|
47
49
|
}
|
|
50
|
+
/**
|
|
51
|
+
* The provenance line for one job, derived from its id (the id prefix IS
|
|
52
|
+
* the source). Exported for the view's tests (jobs-editor Task 2).
|
|
53
|
+
*/
|
|
54
|
+
export function definedInOf(id, source) {
|
|
55
|
+
if (source === "file") {
|
|
56
|
+
return `settings.json (jobs → ${id.slice("file:".length)})`;
|
|
57
|
+
}
|
|
58
|
+
if (source === "extension") {
|
|
59
|
+
return `the ${extensionSlugOf(id)} extension`;
|
|
60
|
+
}
|
|
61
|
+
return "created in sigrun";
|
|
62
|
+
}
|
|
63
|
+
/** The declaring extension's slug (`ext:<slug>:<name>` — the middle). */
|
|
64
|
+
function extensionSlugOf(id) {
|
|
65
|
+
return id.slice("ext:".length).split(":")[0];
|
|
66
|
+
}
|
|
48
67
|
/** One job's sidebar/header summary fields. */
|
|
49
68
|
function summaryFields(row) {
|
|
50
69
|
return {
|
|
@@ -83,6 +102,8 @@ function itemIdOf(payload) {
|
|
|
83
102
|
/** Parse the instance params (loose JSON → the typed shape; bad types drop). */
|
|
84
103
|
function paramsOf(params) {
|
|
85
104
|
const out = {};
|
|
105
|
+
if (params.edit === true)
|
|
106
|
+
out.edit = true;
|
|
86
107
|
if (typeof params.job === "string" && params.job !== "")
|
|
87
108
|
out.job = params.job;
|
|
88
109
|
if (Number.isInteger(params.revision) && params.revision > 0) {
|
|
@@ -132,7 +153,7 @@ function specJson(revision) {
|
|
|
132
153
|
* otherwise `found: false` (the main empty state), exactly the list's
|
|
133
154
|
* absence rule.
|
|
134
155
|
*/
|
|
135
|
-
async function readData(jobs, principal, params) {
|
|
156
|
+
async function readData(jobs, principal, params, models) {
|
|
136
157
|
const rows = [];
|
|
137
158
|
let selected;
|
|
138
159
|
for (const wire of await jobs.list()) {
|
|
@@ -140,7 +161,12 @@ async function readData(jobs, principal, params) {
|
|
|
140
161
|
const access = jobAccess(principal, row);
|
|
141
162
|
if (access === "none")
|
|
142
163
|
continue;
|
|
143
|
-
rows.push({
|
|
164
|
+
rows.push({
|
|
165
|
+
id: row.id,
|
|
166
|
+
name: row.name,
|
|
167
|
+
description: row.description ?? null,
|
|
168
|
+
...summaryFields(row),
|
|
169
|
+
});
|
|
144
170
|
if (params.job !== undefined && row.id === params.job) {
|
|
145
171
|
selected = { ...row, access };
|
|
146
172
|
}
|
|
@@ -152,7 +178,16 @@ async function readData(jobs, principal, params) {
|
|
|
152
178
|
revisions: { items: [], hasMore: false, nextRevCursor: null },
|
|
153
179
|
};
|
|
154
180
|
if (selected === undefined)
|
|
155
|
-
return { jobs: rows, main };
|
|
181
|
+
return { jobs: rows, main, form: emptyForm(), modelOptions: models() };
|
|
182
|
+
// The Task 4 edit seed: `{job, edit}` on a WRITABLE api job seeds
|
|
183
|
+
// `/form` from the job's current spec (a declared or read job in edit
|
|
184
|
+
// mode renders the details instead — the details keep the plain
|
|
185
|
+
// empty form, which the tree never shows there).
|
|
186
|
+
const form = params.edit === true &&
|
|
187
|
+
selected.source === "api" &&
|
|
188
|
+
selected.access === "write"
|
|
189
|
+
? formFromJob(selected)
|
|
190
|
+
: emptyForm();
|
|
156
191
|
// Runs: newest first; the cursor IS a run key (opaque to clients).
|
|
157
192
|
// The service's list is bounded by the retention cap, so the view
|
|
158
193
|
// fetches it whole and pages in memory.
|
|
@@ -178,9 +213,30 @@ async function readData(jobs, principal, params) {
|
|
|
178
213
|
nextRevCursor: nextRev < allRevisions.length ? nextRev : null,
|
|
179
214
|
};
|
|
180
215
|
main.found = true;
|
|
216
|
+
const action = selected.action;
|
|
181
217
|
main.job = {
|
|
182
218
|
id: selected.id,
|
|
183
219
|
name: selected.name,
|
|
220
|
+
description: selected.description ?? null,
|
|
221
|
+
definedIn: definedInOf(selected.id, selected.source),
|
|
222
|
+
owner: selected.owner ?? null,
|
|
223
|
+
source: selected.source,
|
|
224
|
+
kind: action.kind,
|
|
225
|
+
prompt: action.kind === "prompt" ? action.prompt : null,
|
|
226
|
+
model: action.kind === "prompt" ? (action.model ?? null) : null,
|
|
227
|
+
thinking: action.kind === "prompt" ? (action.thinking ?? null) : null,
|
|
228
|
+
conversation: action.kind === "prompt"
|
|
229
|
+
? action.conversation === "new"
|
|
230
|
+
? "new"
|
|
231
|
+
: `#${action.conversation}`
|
|
232
|
+
: null,
|
|
233
|
+
deliver: action.kind === "prompt" && action.deliver !== undefined
|
|
234
|
+
? action.deliver.join(", ")
|
|
235
|
+
: null,
|
|
236
|
+
task: action.kind === "task" ? action.task : null,
|
|
237
|
+
taskInputMarkdown: action.kind === "task" && action.input !== undefined
|
|
238
|
+
? `\`\`\`json\n${JSON.stringify(action.input, null, 2)}\n\`\`\``
|
|
239
|
+
: null,
|
|
184
240
|
...summaryFields(selected),
|
|
185
241
|
state: selected.state,
|
|
186
242
|
enabled: selected.enabled,
|
|
@@ -189,7 +245,7 @@ async function readData(jobs, principal, params) {
|
|
|
189
245
|
// the fallback only satisfies the narrowing.
|
|
190
246
|
access: selected.access === "none" ? "read" : selected.access,
|
|
191
247
|
};
|
|
192
|
-
return { jobs: rows, main };
|
|
248
|
+
return { jobs: rows, main, form, modelOptions: models() };
|
|
193
249
|
}
|
|
194
250
|
/** The one revision selected for the diff, plus its previous (baseline). */
|
|
195
251
|
function selectDiff(revisions, page, version) {
|
|
@@ -207,6 +263,12 @@ function selectDiff(revisions, page, version) {
|
|
|
207
263
|
/** The view surface (`SurfaceDef<"view">`), built over the jobs service. */
|
|
208
264
|
export function createJobsView(deps) {
|
|
209
265
|
const { jobs } = deps;
|
|
266
|
+
/** The model options: Default first (id "default"), then the
|
|
267
|
+
* catalog rows (id is the full `provider/modelId`). */
|
|
268
|
+
const modelOptions = () => [
|
|
269
|
+
{ id: "default", label: "Default" },
|
|
270
|
+
...deps.models(),
|
|
271
|
+
];
|
|
210
272
|
/** The caller's directory-aware principal (undefined user = anonymous).
|
|
211
273
|
* Whole-branch review I3: the USERLESS SUPERUSER (the naive
|
|
212
274
|
* single-operator loopback principal) is admitted — the /v1/jobs
|
|
@@ -246,8 +308,9 @@ export function createJobsView(deps) {
|
|
|
246
308
|
onActivate: { type: "edit", name: "openJob" },
|
|
247
309
|
item: {
|
|
248
310
|
title: { $: "./name" },
|
|
249
|
-
subtitle: { $: "./
|
|
311
|
+
subtitle: { $: "./description" },
|
|
250
312
|
meta: [
|
|
313
|
+
{ text: { $: "./scheduleSummary" }, icon: "clock" },
|
|
251
314
|
{ text: { $: "./nextRun" }, icon: "clock" },
|
|
252
315
|
{ text: { $: "./lastOutcome" }, icon: "info" },
|
|
253
316
|
],
|
|
@@ -285,7 +348,29 @@ export function createJobsView(deps) {
|
|
|
285
348
|
// Blocks are built as loose JSON objects (the validator owns the
|
|
286
349
|
// wire shape; the service validates every tree before serving).
|
|
287
350
|
const main = [];
|
|
288
|
-
|
|
351
|
+
// `{edit: true}` alone is the New-job form; `{job, edit}` edits THAT
|
|
352
|
+
// job. A declared (file:/ext:) or read-access job in edit mode falls
|
|
353
|
+
// through to the DETAILS (the editor is api+write only); an unknown
|
|
354
|
+
// `job` param with edit keeps the Task 3 rule (the form wins over
|
|
355
|
+
// "Job not found") only when NO job is selected.
|
|
356
|
+
const editingJob = params.edit === true &&
|
|
357
|
+
params.job !== undefined &&
|
|
358
|
+
data.main.found &&
|
|
359
|
+
data.main.job !== null &&
|
|
360
|
+
data.main.job.source === "api" &&
|
|
361
|
+
data.main.job.access === "write";
|
|
362
|
+
if (params.edit === true && (params.job === undefined || editingJob)) {
|
|
363
|
+
main.push({
|
|
364
|
+
type: "form",
|
|
365
|
+
id: "job-form",
|
|
366
|
+
bind: "/form",
|
|
367
|
+
mode: "submit",
|
|
368
|
+
onSubmit: editingJob ? "saveJob" : "createJob",
|
|
369
|
+
submitLabel: editingJob ? "Save" : "Create",
|
|
370
|
+
children: jobFormFields(editingJob && data.main.job !== null ? data.main.job.kind : "prompt"),
|
|
371
|
+
});
|
|
372
|
+
}
|
|
373
|
+
else if (data.main.found && data.main.job !== null) {
|
|
289
374
|
const job = data.main.job;
|
|
290
375
|
// IMPORTANT 3 (review fix): per-run conversation buttons ride
|
|
291
376
|
// the card's `actions` as per-row LITERALS. The block contract's
|
|
@@ -326,6 +411,38 @@ export function createJobsView(deps) {
|
|
|
326
411
|
},
|
|
327
412
|
},
|
|
328
413
|
},
|
|
414
|
+
// Edit/Delete are api-only (declared jobs are read-only
|
|
415
|
+
// declarations — the `job-declared` callout says where to
|
|
416
|
+
// edit them). The confirm copy promises the run history
|
|
417
|
+
// survives (deleteApi retires the record; history routes
|
|
418
|
+
// answer for retired ids).
|
|
419
|
+
...(job.source === "api"
|
|
420
|
+
? [
|
|
421
|
+
{
|
|
422
|
+
id: "job-edit",
|
|
423
|
+
label: "Edit",
|
|
424
|
+
icon: "edit",
|
|
425
|
+
action: {
|
|
426
|
+
type: "navigate",
|
|
427
|
+
params: { job: job.id, edit: true },
|
|
428
|
+
},
|
|
429
|
+
},
|
|
430
|
+
{
|
|
431
|
+
id: "job-delete",
|
|
432
|
+
label: "Delete",
|
|
433
|
+
icon: "delete",
|
|
434
|
+
action: {
|
|
435
|
+
type: "submit",
|
|
436
|
+
name: "deleteJob",
|
|
437
|
+
confirm: {
|
|
438
|
+
title: "Delete this job?",
|
|
439
|
+
body: "Its run history is kept.",
|
|
440
|
+
tone: "bad",
|
|
441
|
+
},
|
|
442
|
+
},
|
|
443
|
+
},
|
|
444
|
+
]
|
|
445
|
+
: []),
|
|
329
446
|
]
|
|
330
447
|
: [];
|
|
331
448
|
main.push({
|
|
@@ -336,14 +453,34 @@ export function createJobsView(deps) {
|
|
|
336
453
|
{
|
|
337
454
|
type: "fields",
|
|
338
455
|
rows: [
|
|
456
|
+
{ label: "Description", value: { $: "/main/job/description" } },
|
|
457
|
+
{ label: "Defined in", value: { $: "/main/job/definedIn" } },
|
|
458
|
+
{ label: "Owner", value: { $: "/main/job/owner" } },
|
|
339
459
|
{ label: "Schedule", value: { $: "/main/job/scheduleSummary" } },
|
|
340
460
|
{ label: "Next run", value: { $: "/main/job/nextRun" } },
|
|
341
461
|
{ label: "Last outcome", value: { $: "/main/job/lastOutcome" } },
|
|
342
462
|
{ label: "Last error", value: { $: "/main/job/lastError" } },
|
|
343
463
|
{ label: "State", value: { $: "/main/job/state" } },
|
|
344
464
|
{ label: "Version", value: { $: "/main/job/version" } },
|
|
465
|
+
{ label: "Model", value: { $: "/main/job/model" } },
|
|
466
|
+
{ label: "Thinking", value: { $: "/main/job/thinking" } },
|
|
467
|
+
{ label: "Conversation", value: { $: "/main/job/conversation" } },
|
|
468
|
+
{ label: "Deliver", value: { $: "/main/job/deliver" } },
|
|
469
|
+
{ label: "Task", value: { $: "/main/job/task" } },
|
|
345
470
|
],
|
|
346
471
|
},
|
|
472
|
+
// The action body blocks: BOTH are emitted UNCONDITIONALLY.
|
|
473
|
+
// The earlier kind-conditional form rested on a false invariant —
|
|
474
|
+
// `bumpRev` advances `rev` only (treeRev is never written), so a
|
|
475
|
+
// REST PATCH that changes a job's action kind would leave an open
|
|
476
|
+
// client showing the stale body block until reload (the clients
|
|
477
|
+
// apply fresh DATA to the SAME tree). The data-bound fix is
|
|
478
|
+
// unconditional blocks: the off-kind binding resolves null, and
|
|
479
|
+
// both clients render a null/empty markdown text as NOTHING
|
|
480
|
+
// visible (webui boundString → "" in a zero-height div; pi-tui
|
|
481
|
+
// Markdown returns zero lines for empty text).
|
|
482
|
+
{ type: "markdown", text: { $: "/main/job/prompt" } },
|
|
483
|
+
{ type: "markdown", text: { $: "/main/job/taskInputMarkdown" } },
|
|
347
484
|
],
|
|
348
485
|
}, ...(writeActions.length === 0
|
|
349
486
|
? []
|
|
@@ -355,6 +492,21 @@ export function createJobsView(deps) {
|
|
|
355
492
|
title: "Actions",
|
|
356
493
|
actions: writeActions,
|
|
357
494
|
},
|
|
495
|
+
]),
|
|
496
|
+
// Declared jobs (file:/ext:) are READ-ONLY declarations: no edit
|
|
497
|
+
// affordances here (the editor, Tasks 3-4, is api-only) — a
|
|
498
|
+
// callout points the user at the declaring source instead.
|
|
499
|
+
...(job.source === "api"
|
|
500
|
+
? []
|
|
501
|
+
: [
|
|
502
|
+
{
|
|
503
|
+
type: "callout",
|
|
504
|
+
id: "job-declared",
|
|
505
|
+
tone: "neutral",
|
|
506
|
+
title: job.source === "file"
|
|
507
|
+
? "Defined in settings.json — edit it there"
|
|
508
|
+
: `Declared by the ${extensionSlugOf(job.id)} extension`,
|
|
509
|
+
},
|
|
358
510
|
]), {
|
|
359
511
|
type: "section",
|
|
360
512
|
id: "job-runs",
|
|
@@ -479,11 +631,21 @@ export function createJobsView(deps) {
|
|
|
479
631
|
...tree,
|
|
480
632
|
root: {
|
|
481
633
|
...tree.root,
|
|
634
|
+
// The New job affordance renders for ANY identified caller
|
|
635
|
+
// (createJob admits via callerOf; the form itself is inert).
|
|
636
|
+
actions: [
|
|
637
|
+
{
|
|
638
|
+
id: "job-new",
|
|
639
|
+
label: "New job",
|
|
640
|
+
icon: "add",
|
|
641
|
+
action: { type: "navigate", params: { edit: true } },
|
|
642
|
+
},
|
|
643
|
+
],
|
|
482
644
|
selection: {
|
|
483
|
-
selected: params.job !== undefined,
|
|
645
|
+
selected: params.job !== undefined || params.edit === true,
|
|
484
646
|
back: {},
|
|
485
647
|
},
|
|
486
|
-
...(data.main.found
|
|
648
|
+
...(data.main.found || params.edit === true
|
|
487
649
|
? {}
|
|
488
650
|
: {
|
|
489
651
|
empty: {
|
|
@@ -528,7 +690,7 @@ export function createJobsView(deps) {
|
|
|
528
690
|
return { tree: noneTree(), data: {}, access: "none" };
|
|
529
691
|
}
|
|
530
692
|
const params = paramsOf(ctx.params);
|
|
531
|
-
const data = await readData(jobs, caller, params);
|
|
693
|
+
const data = await readData(jobs, caller, params, modelOptions);
|
|
532
694
|
const revisions = params.job === undefined || !data.main.found
|
|
533
695
|
? []
|
|
534
696
|
: await jobs.revisions(params.job);
|
|
@@ -556,7 +718,7 @@ export function createJobsView(deps) {
|
|
|
556
718
|
if (caller === undefined)
|
|
557
719
|
return { data: {}, access: "none" };
|
|
558
720
|
const params = paramsOf(ctx.params);
|
|
559
|
-
const data = await readData(jobs, caller, params);
|
|
721
|
+
const data = await readData(jobs, caller, params, modelOptions);
|
|
560
722
|
return {
|
|
561
723
|
// SAFETY: same JSON-mirror cast as `render`'s (the comment there).
|
|
562
724
|
data: data,
|
|
@@ -564,16 +726,53 @@ export function createJobsView(deps) {
|
|
|
564
726
|
};
|
|
565
727
|
},
|
|
566
728
|
actions: {
|
|
729
|
+
// The New job form's submit (jobs-editor Task 3): callerOf
|
|
730
|
+
// admission (anonymous non-superuser → forbidden; the userless
|
|
731
|
+
// superuser creates with an undefined owner, matching
|
|
732
|
+
// POST /v1/jobs), then the form-owned spec assembly. Field
|
|
733
|
+
// errors come back field-scoped so the channels mark the field.
|
|
734
|
+
createJob: async (ctx, payload) => {
|
|
735
|
+
const caller = callerOf(ctx);
|
|
736
|
+
if (caller === undefined) {
|
|
737
|
+
return { ok: false, error: { message: "forbidden" } };
|
|
738
|
+
}
|
|
739
|
+
const assembled = createSpecOf(formValuesOf(payload));
|
|
740
|
+
if (!assembled.ok) {
|
|
741
|
+
return {
|
|
742
|
+
ok: false,
|
|
743
|
+
error: { message: assembled.message, field: assembled.field },
|
|
744
|
+
};
|
|
745
|
+
}
|
|
746
|
+
let created;
|
|
747
|
+
try {
|
|
748
|
+
created = await jobs.createApi(assembled.spec, caller.user);
|
|
749
|
+
}
|
|
750
|
+
catch (error) {
|
|
751
|
+
return {
|
|
752
|
+
ok: false,
|
|
753
|
+
error: {
|
|
754
|
+
message: error instanceof Error ? error.message : String(error),
|
|
755
|
+
},
|
|
756
|
+
};
|
|
757
|
+
}
|
|
758
|
+
return {
|
|
759
|
+
ok: true,
|
|
760
|
+
toast: `Created ${assembled.spec.name}`,
|
|
761
|
+
navigate: { type: "navigate", params: { job: created.id } },
|
|
762
|
+
};
|
|
763
|
+
},
|
|
567
764
|
// The sidebar row's drill-in (review Critical 2): the `edit`
|
|
568
765
|
// activation carries {itemId} = the row's id — the JOB id, the
|
|
569
766
|
// collection rows' own `id` field (the channels derive item ids
|
|
570
767
|
// from it, spec §5.5) — and the accepted result carries the
|
|
571
768
|
// navigate back (§6.3: SurfaceActOk.navigate passes the wire
|
|
572
|
-
// unchanged).
|
|
573
|
-
// result.navigate through onNavigate
|
|
574
|
-
//
|
|
575
|
-
//
|
|
576
|
-
//
|
|
769
|
+
// unchanged). Both channels apply it: the webui's useSurface
|
|
770
|
+
// routes result.navigate through onNavigate, and the TUI's
|
|
771
|
+
// SurfaceState does the same (jobs-editor Task 7) — its
|
|
772
|
+
// accepted-result branch fires onNavigate LAST, and the app's
|
|
773
|
+
// wiring reopens the view with the params. The compact page's
|
|
774
|
+
// Esc-back dispatch (tui/src/blocks/page.ts) is genuinely
|
|
775
|
+
// routed through the same seam.
|
|
577
776
|
openJob: async (ctx, payload) => {
|
|
578
777
|
// Same admission as callerOf (review I3's P1 remainder):
|
|
579
778
|
// anonymous → forbidden; the userless superuser is admitted.
|
|
@@ -615,6 +814,92 @@ export function createJobsView(deps) {
|
|
|
615
814
|
navigate: { type: "navigate", params: { job: jobId, revision } },
|
|
616
815
|
};
|
|
617
816
|
},
|
|
817
|
+
// The Edit form's submit (jobs-editor Task 4): the job rides
|
|
818
|
+
// the instance params (the surface is principal-scoped, so the
|
|
819
|
+
// params identify the selected job — the same shape runNow
|
|
820
|
+
// uses). The caller needs write access; a declared job is the
|
|
821
|
+
// 409-class refusal; the patch rules live in savePatchOf.
|
|
822
|
+
saveJob: async (ctx, payload) => {
|
|
823
|
+
const jobId = jobIdOf(payload) ?? paramsOf(ctx.params).job;
|
|
824
|
+
if (jobId === undefined) {
|
|
825
|
+
return {
|
|
826
|
+
ok: false,
|
|
827
|
+
error: { message: "saveJob needs a selected job (params.job)" },
|
|
828
|
+
};
|
|
829
|
+
}
|
|
830
|
+
const resolved = await actionAccess(ctx, jobId);
|
|
831
|
+
if ("error" in resolved)
|
|
832
|
+
return resolved.error;
|
|
833
|
+
if (resolved.row.source !== "api") {
|
|
834
|
+
return { ok: false, error: { message: "job_declared" } };
|
|
835
|
+
}
|
|
836
|
+
const patched = savePatchOf(formValuesOf(payload), resolved.row);
|
|
837
|
+
if (!patched.ok) {
|
|
838
|
+
return {
|
|
839
|
+
ok: false,
|
|
840
|
+
error: { message: patched.message, field: patched.field },
|
|
841
|
+
};
|
|
842
|
+
}
|
|
843
|
+
const caller = callerOf(ctx);
|
|
844
|
+
try {
|
|
845
|
+
await jobs.updateApi(jobId, patched.patch, caller?.user ?? "api");
|
|
846
|
+
}
|
|
847
|
+
catch (error) {
|
|
848
|
+
if (error instanceof JobDeclaredError ||
|
|
849
|
+
error instanceof JobNotFoundError) {
|
|
850
|
+
return { ok: false, error: { message: error.message } };
|
|
851
|
+
}
|
|
852
|
+
return {
|
|
853
|
+
ok: false,
|
|
854
|
+
error: {
|
|
855
|
+
message: error instanceof Error ? error.message : String(error),
|
|
856
|
+
},
|
|
857
|
+
};
|
|
858
|
+
}
|
|
859
|
+
return {
|
|
860
|
+
ok: true,
|
|
861
|
+
toast: `Saved ${patched.patch.name ?? resolved.row.name}`,
|
|
862
|
+
navigate: { type: "navigate", params: { job: jobId } },
|
|
863
|
+
};
|
|
864
|
+
},
|
|
865
|
+
// Delete: write access, api-only, then the retired job leaves
|
|
866
|
+
// the list — navigate back to the unselected view ({}).
|
|
867
|
+
deleteJob: async (ctx, payload) => {
|
|
868
|
+
const jobId = jobIdOf(payload) ?? paramsOf(ctx.params).job;
|
|
869
|
+
if (jobId === undefined) {
|
|
870
|
+
return {
|
|
871
|
+
ok: false,
|
|
872
|
+
error: { message: "deleteJob needs a selected job (params.job)" },
|
|
873
|
+
};
|
|
874
|
+
}
|
|
875
|
+
const resolved = await actionAccess(ctx, jobId);
|
|
876
|
+
if ("error" in resolved)
|
|
877
|
+
return resolved.error;
|
|
878
|
+
if (resolved.row.source !== "api") {
|
|
879
|
+
return { ok: false, error: { message: "job_declared" } };
|
|
880
|
+
}
|
|
881
|
+
const caller = callerOf(ctx);
|
|
882
|
+
try {
|
|
883
|
+
await jobs.deleteApi(jobId, caller?.user ?? "api");
|
|
884
|
+
}
|
|
885
|
+
catch (error) {
|
|
886
|
+
if (error instanceof JobDeclaredError ||
|
|
887
|
+
error instanceof JobNotFoundError) {
|
|
888
|
+
return { ok: false, error: { message: error.message } };
|
|
889
|
+
}
|
|
890
|
+
return {
|
|
891
|
+
ok: false,
|
|
892
|
+
error: {
|
|
893
|
+
message: error instanceof Error ? error.message : String(error),
|
|
894
|
+
},
|
|
895
|
+
};
|
|
896
|
+
}
|
|
897
|
+
return {
|
|
898
|
+
ok: true,
|
|
899
|
+
toast: `Deleted ${resolved.row.name}`,
|
|
900
|
+
navigate: { type: "navigate", params: {} },
|
|
901
|
+
};
|
|
902
|
+
},
|
|
618
903
|
runNow: async (ctx, payload) => {
|
|
619
904
|
const jobId = jobIdOf(payload) ?? paramsOf(ctx.params).job;
|
|
620
905
|
if (jobId === undefined) {
|
|
@@ -346,20 +346,11 @@ function skillActions(params, historyAvailable, deleted) {
|
|
|
346
346
|
}
|
|
347
347
|
return buttons;
|
|
348
348
|
}
|
|
349
|
-
/** The form fields (new skill vs edit: the name is read-only on an
|
|
349
|
+
/** The form fields (new skill vs edit: the name is read-only on an
|
|
350
|
+
* existing skill). A new skill always lands in the workspace: the
|
|
351
|
+
* learned root is the curator's, so the form offers no choice. */
|
|
350
352
|
function formFields(editingExisting) {
|
|
351
|
-
const whereChoice = {
|
|
352
|
-
type: "choice",
|
|
353
|
-
name: "where",
|
|
354
|
-
label: "Where",
|
|
355
|
-
required: true,
|
|
356
|
-
options: [
|
|
357
|
-
{ id: "workspace", label: "Workspace" },
|
|
358
|
-
{ id: "learned", label: "Learned" },
|
|
359
|
-
],
|
|
360
|
-
};
|
|
361
353
|
return [
|
|
362
|
-
...(editingExisting ? [] : [whereChoice]),
|
|
363
354
|
{
|
|
364
355
|
type: "text",
|
|
365
356
|
name: "name",
|
|
@@ -436,7 +436,7 @@ export function createSkillsView(deps) {
|
|
|
436
436
|
/** The form's pre-filled values (a new skill starts empty; an edit reads the current text). */
|
|
437
437
|
async function formDataOf(params, selection) {
|
|
438
438
|
if (params.skill === undefined || selection === undefined) {
|
|
439
|
-
return { name: "", description: "", body: ""
|
|
439
|
+
return { name: "", description: "", body: "" };
|
|
440
440
|
}
|
|
441
441
|
try {
|
|
442
442
|
const text = await readSkillFile(selection.skill.rootDir, selection.skill.name, "SKILL.md");
|
|
@@ -445,7 +445,6 @@ export function createSkillsView(deps) {
|
|
|
445
445
|
name: selection.skill.name,
|
|
446
446
|
description: parsed?.description ?? selection.skill.description,
|
|
447
447
|
body: parsed?.body ?? "",
|
|
448
|
-
where: "workspace",
|
|
449
448
|
};
|
|
450
449
|
}
|
|
451
450
|
catch {
|
|
@@ -453,7 +452,6 @@ export function createSkillsView(deps) {
|
|
|
453
452
|
name: selection.skill.name,
|
|
454
453
|
description: selection.skill.description,
|
|
455
454
|
body: "",
|
|
456
|
-
where: "workspace",
|
|
457
455
|
};
|
|
458
456
|
}
|
|
459
457
|
}
|
|
@@ -722,34 +720,24 @@ export function createSkillsView(deps) {
|
|
|
722
720
|
const user = userOf(ctx);
|
|
723
721
|
if (user === undefined)
|
|
724
722
|
return { ok: false, error: { message: "forbidden" } };
|
|
725
|
-
|
|
723
|
+
// New skills land in the workspace only: the learned root is
|
|
724
|
+
// the curator's (a user edits, pins or deletes learned skills,
|
|
725
|
+
// never creates one).
|
|
726
|
+
const where = fieldOf(payload, "where");
|
|
727
|
+
if (where !== undefined && where !== "workspace") {
|
|
728
|
+
return {
|
|
729
|
+
ok: false,
|
|
730
|
+
error: { message: "new skills go in the workspace; learned skills come from the curator", field: "where" },
|
|
731
|
+
};
|
|
732
|
+
}
|
|
726
733
|
const name = fieldOf(payload, "name");
|
|
727
734
|
const description = fieldOf(payload, "description");
|
|
728
|
-
const body = fieldOf(payload, "body");
|
|
729
735
|
if (name === undefined || !validSkillName(name)) {
|
|
730
736
|
return { ok: false, error: { message: "a valid skill name is required (lowercase letters, digits, dashes)", field: "name" } };
|
|
731
737
|
}
|
|
732
738
|
if (description === undefined || description.trim() === "") {
|
|
733
739
|
return { ok: false, error: { message: "a description is required", field: "description" } };
|
|
734
740
|
}
|
|
735
|
-
if (where === "learned") {
|
|
736
|
-
const curation = deps.curationOf(user);
|
|
737
|
-
if (curation === undefined) {
|
|
738
|
-
return { ok: false, error: { message: "learned skills are unavailable" } };
|
|
739
|
-
}
|
|
740
|
-
try {
|
|
741
|
-
await curation.store.apply(name, { kind: "create", content: skillFileText(name, description.trim(), body ?? "") }, { actor: "user" });
|
|
742
|
-
}
|
|
743
|
-
catch (error) {
|
|
744
|
-
return failure(error);
|
|
745
|
-
}
|
|
746
|
-
deps.bump();
|
|
747
|
-
return {
|
|
748
|
-
ok: true,
|
|
749
|
-
toast: `Created ${name}`,
|
|
750
|
-
navigate: { type: "navigate", params: { skill: name, root: "learned" } },
|
|
751
|
-
};
|
|
752
|
-
}
|
|
753
741
|
return workspaceWrite(ctx, payload, { mode: "create" });
|
|
754
742
|
},
|
|
755
743
|
save: async (ctx, payload) => {
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
*/
|
|
18
18
|
import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
|
|
19
19
|
import { accessOf as accessOfShared } from "@sigrun/shared/access";
|
|
20
|
+
import { completeOnce } from "../harness/complete.js";
|
|
20
21
|
import { effectiveAdmin } from "../harness/identity/groups.js";
|
|
21
22
|
import { userWire } from "./routes/users.js";
|
|
22
23
|
import { accessPrincipalOf } from "./auth/access-principal.js";
|
|
@@ -24,6 +25,7 @@ import { abortOp, compactOp, configureOp, ConversationOpError, createConversatio
|
|
|
24
25
|
import { listModels } from "./helpers.js";
|
|
25
26
|
import { notifyExtension } from "./notifications.js";
|
|
26
27
|
import { SessionMetaDoc } from "./session-meta.js";
|
|
28
|
+
import { isJobRunConversation } from "../harness/interactive-tools.js";
|
|
27
29
|
import { accountLinkWire, serializeConversationView } from "./views.js";
|
|
28
30
|
/**
|
|
29
31
|
* Host-side callers see effective admin derived from stored groups;
|
|
@@ -70,6 +72,9 @@ export function createHostBridge(ctx) {
|
|
|
70
72
|
...(opts?.whenBusy === undefined
|
|
71
73
|
? {}
|
|
72
74
|
: { whenBusy: opts.whenBusy }),
|
|
75
|
+
// Ask-user Task 2: a host submit counts as a human message
|
|
76
|
+
// (declines pending questions) only when it carries a user.
|
|
77
|
+
declinesQuestions: opts?.user !== undefined,
|
|
73
78
|
});
|
|
74
79
|
},
|
|
75
80
|
async watch(id, listener) {
|
|
@@ -193,6 +198,32 @@ export function createHostBridge(ctx) {
|
|
|
193
198
|
state: job.state,
|
|
194
199
|
};
|
|
195
200
|
},
|
|
201
|
+
async activeRun(conversationId) {
|
|
202
|
+
const jobs = await opened.jobs.list();
|
|
203
|
+
let best;
|
|
204
|
+
for (const job of jobs) {
|
|
205
|
+
const run = job.lastRun;
|
|
206
|
+
if (run === undefined)
|
|
207
|
+
continue;
|
|
208
|
+
if (run.outcome !== "running")
|
|
209
|
+
continue;
|
|
210
|
+
if (run.conversationId !== Number(conversationId))
|
|
211
|
+
continue;
|
|
212
|
+
if (best !== undefined && run.startedAt <= best.startedAt)
|
|
213
|
+
continue;
|
|
214
|
+
best = { jobId: job.id, owner: job.owner, startedAt: run.startedAt };
|
|
215
|
+
}
|
|
216
|
+
// startedAt is the tiebreak only — the wire shape is {jobId, owner}.
|
|
217
|
+
return best === undefined ? undefined : { jobId: best.jobId, owner: best.owner };
|
|
218
|
+
},
|
|
219
|
+
async isJobRun(conversationId) {
|
|
220
|
+
// The detection reads COMMITTED durable state (session meta,
|
|
221
|
+
// the live doc, submission records) — it never participates in
|
|
222
|
+
// the caller's transaction, so the bridge's own background
|
|
223
|
+
// context is the honest one (the HandlerContext arg is a
|
|
224
|
+
// cancellation surface only).
|
|
225
|
+
return await isJobRunConversation(opened.harness, conversationId, BACKGROUND_CONTEXT);
|
|
226
|
+
},
|
|
196
227
|
},
|
|
197
228
|
users: {
|
|
198
229
|
// The directory in wire form (emails are admin material; the
|
|
@@ -252,11 +283,32 @@ export function createHostBridge(ctx) {
|
|
|
252
283
|
},
|
|
253
284
|
},
|
|
254
285
|
// Gateway C4b: the model catalog — the SAME source function the
|
|
255
|
-
// `GET /v1/models` route serves (one construction, never two)
|
|
286
|
+
// `GET /v1/models` route serves (one construction, never two) —
|
|
287
|
+
// plus one-shot completions (warden Task 4), read-live settings
|
|
288
|
+
// per call so a `namingModel` change applies without a restart.
|
|
289
|
+
// Warden Task 6: the decision registry — the seam host's
|
|
290
|
+
// `decisions` members delegate here; `slug` is the CALLING
|
|
291
|
+
// extension's identity (read at call time), which the registry
|
|
292
|
+
// stamps as `caller` on the provider's context.
|
|
293
|
+
decisions: {
|
|
294
|
+
async evaluate(slug, request, opts = {}) {
|
|
295
|
+
return opened.decisions.evaluate(slug, request, opts);
|
|
296
|
+
},
|
|
297
|
+
async list() {
|
|
298
|
+
return opened.decisions.list();
|
|
299
|
+
},
|
|
300
|
+
},
|
|
256
301
|
models: {
|
|
257
302
|
async list() {
|
|
258
303
|
return listModels(opened);
|
|
259
304
|
},
|
|
305
|
+
async complete(input) {
|
|
306
|
+
return completeOnce({
|
|
307
|
+
models: opened.models,
|
|
308
|
+
defaultModel: opened.defaultModel,
|
|
309
|
+
readSettings: () => opened.settings.read(),
|
|
310
|
+
}, input);
|
|
311
|
+
},
|
|
260
312
|
},
|
|
261
313
|
commands: {
|
|
262
314
|
// The merged catalog rows (`GET /v1/commands`' core + extension
|
package/dist/server/index.js
CHANGED
|
@@ -42,7 +42,7 @@ import { effectiveMethods } from "../config.js";
|
|
|
42
42
|
import { registerAuthPipeline } from "./auth/pipeline.js";
|
|
43
43
|
import { isLoopbackHost } from "./auth/loopback.js";
|
|
44
44
|
import { SseRegistry } from "./sse-registry.js";
|
|
45
|
-
import { storeAgentChange } from "./helpers.js";
|
|
45
|
+
import { listModels, storeAgentChange } from "./helpers.js";
|
|
46
46
|
import { registerCoreOwnershipParticipant } from "./ownership.js";
|
|
47
47
|
import { createJobsView } from "./core-views/jobs.js";
|
|
48
48
|
import { createSkillsView } from "./core-views/skills.js";
|
|
@@ -324,6 +324,7 @@ export async function createServer(opened, config, contributedBundles = [], opti
|
|
|
324
324
|
createJobsView({
|
|
325
325
|
jobs: opened.jobs,
|
|
326
326
|
now: () => new Date(),
|
|
327
|
+
models: () => listModels(opened).map((m) => ({ id: m.id, label: m.name })),
|
|
327
328
|
accessPrincipal: (principal) => accessPrincipalOf(principal, opened.identity.users),
|
|
328
329
|
}),
|
|
329
330
|
createSkillsView({
|