@sigrun/sigrun 0.9.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 +20 -0
- package/dist/jobs/index.js +3 -0
- package/dist/jobs/spec.js +15 -1
- package/dist/server/core-views/jobs-form.js +335 -0
- package/dist/server/core-views/jobs.js +300 -15
- package/dist/server/index.js +2 -1
- package/node_modules/@sigrun/shared/CHANGELOG.md +5 -0
- package/node_modules/@sigrun/shared/dist/jobs.d.ts +4 -0
- package/node_modules/@sigrun/shared/package.json +1 -1
- package/package.json +8 -8
- package/webui/dist/assets/{index-DGIrLC6F.js → index-By63QKOA.js} +1 -1
- package/webui/dist/index.html +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,26 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [0.10.0] - 2026-11-06
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- Jobs editor in the `core/jobs` view: a New job form (prompt jobs, schedule cron/every/once,
|
|
13
|
+
model + thinking pickers from the served catalogue), Edit/Delete for api jobs (edit preserves
|
|
14
|
+
conversation/deliver/runSharing/task targets; clearing the description removes it), details
|
|
15
|
+
and provenance rows (Defined in / Owner / action fields) with a read-only callout for declared
|
|
16
|
+
(`file:`/`ext:`) jobs, and the job description as the sidebar row subtitle.
|
|
17
|
+
- `description` on `JobSpec` (optional, trimmed, ≤ 500 chars) for every job source — settings.json
|
|
18
|
+
boot-gates an invalid one; an extension's declaration quarantines.
|
|
19
|
+
- TUI: extension views (Jobs, Skills, every extension surface) now render in the main pane with
|
|
20
|
+
key routing, Esc back-to-list/close, overlay-aware Esc, notice flashes on the status bar, and
|
|
21
|
+
block navigation (row activation, page actions like New job, post-action navigation) — plus a
|
|
22
|
+
fix for the TUI ext-transport URL builder (suffix routes were appended after `?params=`).
|
|
23
|
+
- Live refresh for principal-scoped views (`core/jobs`, `core/skills`, curator): both clients'
|
|
24
|
+
surface hubs now dispatch `surface.changed` by (slug, id) instead of a client-side params hash
|
|
25
|
+
that never matched the server's principal-scoped key — background job runs now appear in an
|
|
26
|
+
open view without a reload.
|
|
27
|
+
|
|
8
28
|
## [0.9.0] - 2026-11-05
|
|
9
29
|
|
|
10
30
|
### Added
|
package/dist/jobs/index.js
CHANGED
|
@@ -62,6 +62,9 @@ export function jobWireOf(id, record, owner) {
|
|
|
62
62
|
id,
|
|
63
63
|
name: record.spec.name,
|
|
64
64
|
source: record.source,
|
|
65
|
+
...(record.spec.description === undefined
|
|
66
|
+
? {}
|
|
67
|
+
: { description: record.spec.description }),
|
|
65
68
|
...(owner === undefined ? {} : { owner }),
|
|
66
69
|
...(record.sharing === undefined
|
|
67
70
|
? {}
|
package/dist/jobs/spec.js
CHANGED
|
@@ -22,7 +22,9 @@ import { createHash } from "node:crypto";
|
|
|
22
22
|
import { isSharing } from "@sigrun/shared/access";
|
|
23
23
|
import { cronFor, parseEvery } from "./schedule.js";
|
|
24
24
|
/** Job names double as the file/extension id suffix (Global Constraints). */
|
|
25
|
-
const NAME_PATTERN = /^[a-z0-9][a-z0-9._-]*$/;
|
|
25
|
+
export const NAME_PATTERN = /^[a-z0-9][a-z0-9._-]*$/;
|
|
26
|
+
/** The longest a job description may be (spec §8.2). */
|
|
27
|
+
export const DESCRIPTION_MAX = 500;
|
|
26
28
|
/**
|
|
27
29
|
* Refine `unknown` (a request body, a settings block, an extension
|
|
28
30
|
* declaration) into a `JobSpec`, or reject with the first offending
|
|
@@ -59,11 +61,23 @@ export function validateJobSpec(value) {
|
|
|
59
61
|
if (record.sharing !== undefined && !isSharing(record.sharing)) {
|
|
60
62
|
return invalid("sharing", "sharing must be a Sharing value");
|
|
61
63
|
}
|
|
64
|
+
if (record.description !== undefined && typeof record.description !== "string") {
|
|
65
|
+
return invalid("description", "description must be a string");
|
|
66
|
+
}
|
|
67
|
+
const description = typeof record.description === "string"
|
|
68
|
+
? record.description.trim()
|
|
69
|
+
: undefined;
|
|
70
|
+
if (description !== undefined && description.length > DESCRIPTION_MAX) {
|
|
71
|
+
return invalid("description", `description must be at most ${DESCRIPTION_MAX} characters`);
|
|
72
|
+
}
|
|
62
73
|
const spec = {
|
|
63
74
|
name: record.name,
|
|
64
75
|
schedule: schedule.schedule,
|
|
65
76
|
action: action.action,
|
|
66
77
|
};
|
|
78
|
+
if (description !== undefined && description.length > 0) {
|
|
79
|
+
spec.description = description;
|
|
80
|
+
}
|
|
67
81
|
if (record.overlap !== undefined)
|
|
68
82
|
spec.overlap = record.overlap;
|
|
69
83
|
if (record.catchUp !== undefined)
|
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Jobs-editor Task 3: the pure form logic behind the jobs view's
|
|
3
|
+
* "New job" form — the values shape, the field list, the submit
|
|
4
|
+
* envelope read, and the spec assembly. No I/O here: `jobs.ts` owns
|
|
5
|
+
* the surface (the `{edit: true}` params branch, the `createJob`
|
|
6
|
+
* action) and this module owns the form's content.
|
|
7
|
+
*
|
|
8
|
+
* - `JobFormData.model`/`thinking` carry `"default"` (NOT `""`) — it
|
|
9
|
+
* is the choice field's option id AND the omission sentinel
|
|
10
|
+
* (`createSpecOf` drops the spec keys for it).
|
|
11
|
+
* - The schedule is wholly form-owned: `scheduleType` picks the shape
|
|
12
|
+
* (`{cron, timezone?}` / `{every}` / `{at}`) and `schedule` carries
|
|
13
|
+
* the single text value; `timezone` rides cron only.
|
|
14
|
+
* - New jobs are prompt-only with `conversation: "new"` (the binding
|
|
15
|
+
* ruling) — the kind param exists for the edit shape (Task 4), whose
|
|
16
|
+
* field list differs only in the action column.
|
|
17
|
+
*/
|
|
18
|
+
import { NAME_PATTERN, validateJobSpec } from "../../jobs/spec.js";
|
|
19
|
+
/** The thinking choice's options ("default" labels as Default). */
|
|
20
|
+
export const THINKING_OPTIONS = [
|
|
21
|
+
"default",
|
|
22
|
+
"off",
|
|
23
|
+
"minimal",
|
|
24
|
+
"low",
|
|
25
|
+
"medium",
|
|
26
|
+
"high",
|
|
27
|
+
"xhigh",
|
|
28
|
+
"max",
|
|
29
|
+
];
|
|
30
|
+
/** A new job's seeded values (bound at `/form`). */
|
|
31
|
+
export function emptyForm() {
|
|
32
|
+
return {
|
|
33
|
+
name: "",
|
|
34
|
+
description: "",
|
|
35
|
+
scheduleType: "cron",
|
|
36
|
+
schedule: "",
|
|
37
|
+
timezone: "",
|
|
38
|
+
prompt: "",
|
|
39
|
+
model: "default",
|
|
40
|
+
thinking: "default",
|
|
41
|
+
enabled: true,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* An existing job's form values (the Task-4 edit seed; Task 3 creates
|
|
46
|
+
* only, but the shape ships here). Name stays editable on the field
|
|
47
|
+
* list; model/thinking absent ⇒ `"default"`.
|
|
48
|
+
*/
|
|
49
|
+
export function formFromJob(job) {
|
|
50
|
+
const schedule = job.schedule;
|
|
51
|
+
const action = job.action;
|
|
52
|
+
return {
|
|
53
|
+
name: job.name,
|
|
54
|
+
description: job.description ?? "",
|
|
55
|
+
scheduleType: "cron" in schedule ? "cron" : "every" in schedule ? "every" : "at",
|
|
56
|
+
schedule: "cron" in schedule
|
|
57
|
+
? schedule.cron
|
|
58
|
+
: "every" in schedule
|
|
59
|
+
? schedule.every
|
|
60
|
+
: schedule.at,
|
|
61
|
+
timezone: "cron" in schedule ? (schedule.timezone ?? "") : "",
|
|
62
|
+
prompt: action.kind === "prompt" ? action.prompt : "",
|
|
63
|
+
model: action.kind === "prompt" && action.model !== undefined
|
|
64
|
+
? action.model
|
|
65
|
+
: "default",
|
|
66
|
+
thinking: action.kind === "prompt" && action.thinking !== undefined
|
|
67
|
+
? action.thinking
|
|
68
|
+
: "default",
|
|
69
|
+
enabled: job.enabled,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
/** Whether `value` is a plain object. */
|
|
73
|
+
function isObject(value) {
|
|
74
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
75
|
+
}
|
|
76
|
+
function stringOf(value) {
|
|
77
|
+
return typeof value === "string" ? value : "";
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* The submitted form values: form dispatches wrap the fields as
|
|
81
|
+
* `{values: {...}}` (the webui FormBlock contract — the `fieldOf`
|
|
82
|
+
* convention in skills.ts); a plain object resolves too. An ABSENT
|
|
83
|
+
* `enabled` reads `true` (the toggle defaults on; a disabled/absent
|
|
84
|
+
* field never rides the envelope).
|
|
85
|
+
*/
|
|
86
|
+
export function formValuesOf(payload) {
|
|
87
|
+
const source = isObject(payload) && isObject(payload.values) ? payload.values : payload;
|
|
88
|
+
const record = isObject(source) ? source : {};
|
|
89
|
+
const scheduleType = record.scheduleType;
|
|
90
|
+
return {
|
|
91
|
+
name: stringOf(record.name),
|
|
92
|
+
description: stringOf(record.description),
|
|
93
|
+
scheduleType: scheduleType === "every" || scheduleType === "at" ? scheduleType : "cron",
|
|
94
|
+
schedule: stringOf(record.schedule),
|
|
95
|
+
timezone: stringOf(record.timezone),
|
|
96
|
+
prompt: stringOf(record.prompt),
|
|
97
|
+
model: stringOf(record.model) || "default",
|
|
98
|
+
thinking: stringOf(record.thinking) || "default",
|
|
99
|
+
enabled: typeof record.enabled === "boolean" ? record.enabled : true,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Map a `validateJobSpec` field path onto the form's field (the
|
|
104
|
+
* field-scoped action error). Unknown paths are FORM-LEVEL (no field).
|
|
105
|
+
*/
|
|
106
|
+
export function formFieldOf(path) {
|
|
107
|
+
switch (path) {
|
|
108
|
+
case "name":
|
|
109
|
+
return "name";
|
|
110
|
+
case "description":
|
|
111
|
+
return "description";
|
|
112
|
+
case "schedule":
|
|
113
|
+
case "schedule.cron":
|
|
114
|
+
case "schedule.every":
|
|
115
|
+
case "schedule.at":
|
|
116
|
+
return "schedule";
|
|
117
|
+
case "schedule.timezone":
|
|
118
|
+
return "timezone";
|
|
119
|
+
case "action.prompt":
|
|
120
|
+
return "prompt";
|
|
121
|
+
case "action.model":
|
|
122
|
+
return "model";
|
|
123
|
+
case "action.thinking":
|
|
124
|
+
return "thinking";
|
|
125
|
+
default:
|
|
126
|
+
return undefined;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* The schedule assembly shared by create and edit-save (review fix:
|
|
131
|
+
* extracted so the rule can never drift between the two) — wholly
|
|
132
|
+
* form-owned: `scheduleType` picks the shape (`{cron, timezone?}` /
|
|
133
|
+
* `{every}` / `{at}`) and `schedule` carries the single text value;
|
|
134
|
+
* `timezone` rides cron only.
|
|
135
|
+
*/
|
|
136
|
+
function scheduleOf(values) {
|
|
137
|
+
return values.scheduleType === "every"
|
|
138
|
+
? { every: values.schedule.trim() }
|
|
139
|
+
: values.scheduleType === "at"
|
|
140
|
+
? { at: values.schedule.trim() }
|
|
141
|
+
: values.timezone.trim() === ""
|
|
142
|
+
? { cron: values.schedule.trim() }
|
|
143
|
+
: { cron: values.schedule.trim(), timezone: values.timezone.trim() };
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Assemble the `JobSpec` for `createApi` from the form values, or
|
|
147
|
+
* reject with the field-scoped failure. New jobs are prompt-only with
|
|
148
|
+
* `conversation: "new"`; the `"default"` model/thinking omit the spec
|
|
149
|
+
* keys (the server default applies).
|
|
150
|
+
*/
|
|
151
|
+
export function createSpecOf(values) {
|
|
152
|
+
const schedule = scheduleOf(values);
|
|
153
|
+
const spec = {
|
|
154
|
+
name: values.name,
|
|
155
|
+
...(values.description.trim() === ""
|
|
156
|
+
? {}
|
|
157
|
+
: { description: values.description.trim() }),
|
|
158
|
+
schedule,
|
|
159
|
+
action: {
|
|
160
|
+
kind: "prompt",
|
|
161
|
+
prompt: values.prompt,
|
|
162
|
+
...(values.model === "default" ? {} : { model: values.model }),
|
|
163
|
+
...(values.thinking === "default" ? {} : { thinking: values.thinking }),
|
|
164
|
+
conversation: "new",
|
|
165
|
+
},
|
|
166
|
+
enabled: values.enabled,
|
|
167
|
+
};
|
|
168
|
+
const validated = validateJobSpec(spec);
|
|
169
|
+
if (!validated.ok) {
|
|
170
|
+
return {
|
|
171
|
+
ok: false,
|
|
172
|
+
field: formFieldOf(validated.field) ?? validated.field,
|
|
173
|
+
message: validated.message,
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
return { ok: true, spec: validated.spec };
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* The edit-save patch (jobs-editor Task 4, spec §4 save rules).
|
|
180
|
+
*
|
|
181
|
+
* `updateApi` merges ONE level deep (`{...prior.spec, ...patch}`), so
|
|
182
|
+
* every key the patch carries must be the WHOLE desired value:
|
|
183
|
+
*
|
|
184
|
+
* - the schedule is wholly form-owned (assembled from `scheduleType`
|
|
185
|
+
* + `schedule` + `timezone`; switching type leaves no second
|
|
186
|
+
* discriminant — cron→every drops the timezone);
|
|
187
|
+
* - a PROMPT job's action is `{...prior.action, prompt}` with
|
|
188
|
+
* `model`/`thinking` set from the form or DELETED for `"default"`
|
|
189
|
+
* (never a fresh literal — `conversation`, `runSharing`, `deliver`
|
|
190
|
+
* carry over untouched);
|
|
191
|
+
* - a TASK job's patch carries NO `action` key at all (the editor
|
|
192
|
+
* hides prompt/model/thinking; task + input carry over);
|
|
193
|
+
* - an emptied description becomes an OWN KEY `description: undefined`
|
|
194
|
+
* after validation, so the spread overrides the prior value and the
|
|
195
|
+
* stored record + hash drop it.
|
|
196
|
+
*
|
|
197
|
+
* For field-scoped errors the patch is spread over the prior spec
|
|
198
|
+
* (rebuilt from the flattened wire row — owner is not part of a spec)
|
|
199
|
+
* and the MERGED object is validated with `validateJobSpec`.
|
|
200
|
+
*/
|
|
201
|
+
export function savePatchOf(values, prior) {
|
|
202
|
+
const schedule = scheduleOf(values);
|
|
203
|
+
const patch = {
|
|
204
|
+
name: values.name,
|
|
205
|
+
schedule,
|
|
206
|
+
enabled: values.enabled,
|
|
207
|
+
};
|
|
208
|
+
if (prior.action.kind === "prompt") {
|
|
209
|
+
const action = { ...prior.action, prompt: values.prompt };
|
|
210
|
+
if (values.model === "default") {
|
|
211
|
+
delete action.model;
|
|
212
|
+
}
|
|
213
|
+
else {
|
|
214
|
+
action.model = values.model;
|
|
215
|
+
}
|
|
216
|
+
if (values.thinking === "default") {
|
|
217
|
+
delete action.thinking;
|
|
218
|
+
}
|
|
219
|
+
else {
|
|
220
|
+
action.thinking = values.thinking;
|
|
221
|
+
}
|
|
222
|
+
patch.action = action;
|
|
223
|
+
}
|
|
224
|
+
// Field-scoped validation on the MERGED spec (the wire is
|
|
225
|
+
// flattened; owner lives on the record, not the spec).
|
|
226
|
+
const priorSpec = {
|
|
227
|
+
name: prior.name,
|
|
228
|
+
...(prior.description === undefined
|
|
229
|
+
? {}
|
|
230
|
+
: { description: prior.description }),
|
|
231
|
+
schedule: prior.schedule,
|
|
232
|
+
action: prior.action,
|
|
233
|
+
...(prior.sharing === undefined ? {} : { sharing: prior.sharing }),
|
|
234
|
+
enabled: prior.enabled,
|
|
235
|
+
};
|
|
236
|
+
const merged = {
|
|
237
|
+
...priorSpec,
|
|
238
|
+
...patch,
|
|
239
|
+
...(values.description.trim() === ""
|
|
240
|
+
? {}
|
|
241
|
+
: { description: values.description.trim() }),
|
|
242
|
+
};
|
|
243
|
+
const validated = validateJobSpec(merged);
|
|
244
|
+
if (!validated.ok) {
|
|
245
|
+
return {
|
|
246
|
+
ok: false,
|
|
247
|
+
field: formFieldOf(validated.field),
|
|
248
|
+
message: validated.message,
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
// Only AFTER validation does the cleared description land as the
|
|
252
|
+
// own key whose undefined value the spread + JSON round-trip drop.
|
|
253
|
+
if (values.description.trim() === "") {
|
|
254
|
+
patch.description = undefined;
|
|
255
|
+
}
|
|
256
|
+
else {
|
|
257
|
+
patch.description = values.description.trim();
|
|
258
|
+
}
|
|
259
|
+
return { ok: true, patch };
|
|
260
|
+
}
|
|
261
|
+
/**
|
|
262
|
+
* The form's field list (the same for new and edit; Name stays
|
|
263
|
+
* editable). For `kind: "task"` the prompt/model/thinking fields are
|
|
264
|
+
* omitted (a task job names a task id instead — Task 4's edit shape).
|
|
265
|
+
*/
|
|
266
|
+
export function jobFormFields(kind) {
|
|
267
|
+
const fields = [
|
|
268
|
+
{
|
|
269
|
+
type: "text",
|
|
270
|
+
name: "name",
|
|
271
|
+
label: "Name",
|
|
272
|
+
required: true,
|
|
273
|
+
pattern: NAME_PATTERN.source,
|
|
274
|
+
help: "lowercase letters, digits, dot, dash, underscore",
|
|
275
|
+
},
|
|
276
|
+
{
|
|
277
|
+
type: "text",
|
|
278
|
+
name: "description",
|
|
279
|
+
label: "Description",
|
|
280
|
+
},
|
|
281
|
+
{
|
|
282
|
+
type: "choice",
|
|
283
|
+
name: "scheduleType",
|
|
284
|
+
label: "Schedule type",
|
|
285
|
+
options: [
|
|
286
|
+
{ id: "cron", label: "Cron" },
|
|
287
|
+
{ id: "every", label: "Every" },
|
|
288
|
+
{ id: "at", label: "Once" },
|
|
289
|
+
],
|
|
290
|
+
},
|
|
291
|
+
{
|
|
292
|
+
type: "text",
|
|
293
|
+
name: "schedule",
|
|
294
|
+
label: "Schedule",
|
|
295
|
+
required: true,
|
|
296
|
+
help: "Cron: 0 9 * * 1-5 · Every: 15m or 2h (60s minimum) · Once: an ISO date-time",
|
|
297
|
+
},
|
|
298
|
+
{
|
|
299
|
+
type: "text",
|
|
300
|
+
name: "timezone",
|
|
301
|
+
label: "Timezone",
|
|
302
|
+
help: "Cron only, e.g. America/Toronto; empty = server timezone",
|
|
303
|
+
},
|
|
304
|
+
];
|
|
305
|
+
if (kind === "prompt") {
|
|
306
|
+
fields.push({
|
|
307
|
+
type: "longtext",
|
|
308
|
+
name: "prompt",
|
|
309
|
+
label: "Prompt",
|
|
310
|
+
required: true,
|
|
311
|
+
syntax: "markdown",
|
|
312
|
+
rows: 10,
|
|
313
|
+
}, {
|
|
314
|
+
type: "choice",
|
|
315
|
+
name: "model",
|
|
316
|
+
label: "Model",
|
|
317
|
+
options: { $: "/modelOptions" },
|
|
318
|
+
}, {
|
|
319
|
+
type: "choice",
|
|
320
|
+
name: "thinking",
|
|
321
|
+
label: "Thinking",
|
|
322
|
+
options: THINKING_OPTIONS.map((level) => ({
|
|
323
|
+
id: level,
|
|
324
|
+
label: level === "default" ? "Default" : level,
|
|
325
|
+
})),
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
fields.push({
|
|
329
|
+
type: "toggle",
|
|
330
|
+
name: "enabled",
|
|
331
|
+
label: "Enabled",
|
|
332
|
+
default: true,
|
|
333
|
+
});
|
|
334
|
+
return fields;
|
|
335
|
+
}
|