@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 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
@@ -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
+ }