@sigrun/sigrun 0.4.2 → 0.6.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.
Files changed (68) hide show
  1. package/CHANGELOG.md +92 -1
  2. package/README.md +63 -33
  3. package/dist/commands/expand.js +37 -5
  4. package/dist/curation/attribution.js +135 -0
  5. package/dist/curation/deletion.js +32 -0
  6. package/dist/curation/gate.js +50 -0
  7. package/dist/curation/host.js +342 -0
  8. package/dist/curation/internal.js +54 -0
  9. package/dist/curation/names.js +5 -0
  10. package/dist/curation/overlays.js +325 -0
  11. package/dist/curation/paths.js +17 -0
  12. package/dist/curation/run-doc.js +64 -0
  13. package/dist/curation/runs.js +579 -0
  14. package/dist/curation/scan.js +30 -0
  15. package/dist/curation/skill-tool.js +308 -0
  16. package/dist/curation/store.js +444 -0
  17. package/dist/curation/targets.js +77 -0
  18. package/dist/curation/usage.js +124 -0
  19. package/dist/curation/wrap.js +212 -0
  20. package/dist/extension.d.ts +336 -1
  21. package/dist/harness/boot.js +236 -13
  22. package/dist/harness/builtins.js +19 -12
  23. package/dist/harness/event-bus.js +64 -0
  24. package/dist/harness/identity/primary-admin.js +19 -0
  25. package/dist/harness/loader.js +114 -1
  26. package/dist/harness/naming.js +6 -0
  27. package/dist/harness/packages.js +14 -9
  28. package/dist/harness/reserved-sections.js +4 -0
  29. package/dist/harness/seam-host.js +154 -26
  30. package/dist/harness/sections/context-files.js +6 -3
  31. package/dist/harness/sections/job-state.js +24 -0
  32. package/dist/harness/settings.js +2 -2
  33. package/dist/harness/tools.js +70 -0
  34. package/dist/harness/workspace.js +30 -3
  35. package/dist/jobs/fire.js +37 -14
  36. package/dist/jobs/index.js +34 -7
  37. package/dist/jobs/reconcile.js +9 -0
  38. package/dist/jobs/skills-refs.js +40 -0
  39. package/dist/jobs/state-dir.js +16 -0
  40. package/dist/jobs/transfer.js +14 -0
  41. package/dist/sandbox/policy.js +27 -4
  42. package/dist/serve.js +5 -3
  43. package/dist/server/blocks/answers.js +9 -3
  44. package/dist/server/conversation-ops.js +34 -3
  45. package/dist/server/helpers.js +1 -1
  46. package/dist/server/host-bridge.js +31 -1
  47. package/dist/server/notifications.js +108 -16
  48. package/dist/server/routes/jobs.js +31 -0
  49. package/dist/server/routes/meta.js +8 -3
  50. package/dist/server/routes/submissions.js +5 -0
  51. package/dist/server/routes/surfaces.js +3 -1
  52. package/dist/server/routes/users.js +7 -0
  53. package/dist/server/schema-fixture.js +168 -0
  54. package/dist/server/session-meta.js +12 -10
  55. package/dist/settings/core-sections.js +18 -0
  56. package/dist/settings/registry.js +12 -0
  57. package/dist/skills/index.js +88 -19
  58. package/node_modules/@sigrun/shared/CHANGELOG.md +6 -1
  59. package/node_modules/@sigrun/shared/dist/access.d.ts +3 -0
  60. package/node_modules/@sigrun/shared/dist/access.js +8 -0
  61. package/node_modules/@sigrun/shared/dist/session-meta.d.ts +15 -4
  62. package/node_modules/@sigrun/shared/dist/settings.d.ts +7 -5
  63. package/node_modules/@sigrun/shared/package.json +1 -1
  64. package/package.json +9 -7
  65. package/webui/dist/assets/index-BR0di0OG.css +1 -0
  66. package/webui/dist/assets/{index-DWmsAQl2.js → index-BzrA1T4g.js} +5 -5
  67. package/webui/dist/index.html +2 -2
  68. package/webui/dist/assets/index-h7zDY9vD.css +0 -1
@@ -29,6 +29,7 @@
29
29
  import { Type } from "@earendil-works/pi-ai";
30
30
  import { createBashTool, createEditTool, createReadTool, createWriteTool, } from "@earendil-works/pi-durable/tools";
31
31
  import { defineExtension, defineTool } from "@earendil-works/pi-durable";
32
+ import { SessionMetaDoc } from "../server/session-meta.js";
32
33
  // ---------------------------------------------------------------------------
33
34
  // Coding tools (read/write/edit/bash)
34
35
  // ---------------------------------------------------------------------------
@@ -77,11 +78,80 @@ export const imageReadTool = defineTool({
77
78
  return { output: [image] };
78
79
  },
79
80
  });
81
+ /**
82
+ * Wrap the read tool with learned-root usage counting. The wrapper
83
+ * resolves the conversation owner through the api's document reader
84
+ * (the SessionMeta doc — the A3 pattern), and the read's absolute path
85
+ * against the owner's learned root; a path inside it (a SKILL.md or a
86
+ * support file) bumps `read` for the owning skill. The wrapped result
87
+ * passes through unchanged.
88
+ */
89
+ export function countReadUsage(seam, base = imageReadTool) {
90
+ return {
91
+ ...base,
92
+ execute: async (args, api, context) => {
93
+ const result = await base.execute(args, api, context);
94
+ void countLearnedRead(seam, args.path, api, context);
95
+ return result;
96
+ },
97
+ };
98
+ }
99
+ /** Bump `read` for a learned-root path, when it lands inside one. */
100
+ async function countLearnedRead(seam, path, api, context) {
101
+ try {
102
+ const meta = await api.snapshot(SessionMetaDoc, api.conversationId, context);
103
+ const owner = meta?.owner;
104
+ const counters = seam.countersOf(owner);
105
+ const root = seam.learnedRootOf(owner);
106
+ if (counters === undefined || root === undefined || api.env === undefined)
107
+ return;
108
+ const resolved = await api.env.absolutePath(path, context);
109
+ if (!resolved.ok)
110
+ return;
111
+ const skill = learnedSkillOf(resolved.value, root);
112
+ if (skill !== undefined) {
113
+ counters.bump(skill, "read");
114
+ void counters.flush();
115
+ }
116
+ }
117
+ catch {
118
+ // Counting is advisory: no read ever fails because usage telemetry
119
+ // could not resolve the owner or the path.
120
+ }
121
+ }
122
+ /** The `skills/<name>/…` segment of a path inside a learned root; `undefined` outside. */
123
+ function learnedSkillOf(absolute, root) {
124
+ if (absolute === root || !absolute.startsWith(`${root}/`))
125
+ return undefined;
126
+ const rest = absolute.slice(root.length + 1).split("/");
127
+ const name = rest[0];
128
+ if (name === undefined || name === "")
129
+ return undefined;
130
+ return name;
131
+ }
80
132
  /** `read`, `write`, `edit`, `bash` — core-installed for every conversation. */
81
133
  export const codingToolsExtension = defineExtension({
82
134
  name: "sigrun.coding-tools",
83
135
  tools: [imageReadTool, createWriteTool(), createEditTool(), createBashTool()],
84
136
  });
137
+ /**
138
+ * The coding tools with learned-root usage counting (curator A6, P4):
139
+ * with a {@link ReadUsageSeam} the `read` tool is wrapped so reads
140
+ * inside the conversation owner's learned root bump the owner's
141
+ * counters. Without one, the plain surface — the boot default until
142
+ * the curation wiring hands both seam halves.
143
+ */
144
+ export function createCodingToolsExtension(options) {
145
+ return defineExtension({
146
+ name: "sigrun.coding-tools",
147
+ tools: [
148
+ options === undefined ? imageReadTool : countReadUsage(options),
149
+ createWriteTool(),
150
+ createEditTool(),
151
+ createBashTool(),
152
+ ],
153
+ });
154
+ }
85
155
  // ---------------------------------------------------------------------------
86
156
  // Search tools (find/grep/ls) — pure TS over ExecutionEnv
87
157
  // ---------------------------------------------------------------------------
@@ -1,15 +1,43 @@
1
1
  /** Resolve each conversation's ExecutionEnv: single-user, jailed, or fail-closed. */
2
- import { isAbsolute, resolve } from "node:path";
2
+ import { isAbsolute, join, resolve } from "node:path";
3
3
  import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
4
4
  import { buildUserPolicy } from "../sandbox/policy.js";
5
5
  import { SandboxedExecutionEnv } from "../sandbox/sandboxed-env.js";
6
6
  import { UnavailableExecutionEnv } from "../sandbox/unavailable-env.js";
7
7
  import { selfPackageRoot } from "./packages.js";
8
8
  import { defaultModuleTrees } from "../sandbox/policy.js";
9
+ import { primaryAdmin } from "./identity/primary-admin.js";
9
10
  import { AgentExecutionEnv } from "./agent-env.js";
10
11
  import { lookupOsAccount } from "../sandbox/os-account.js";
12
+ /** The live owner's record, falling back to the primary admin. */
13
+ export function selectRecord(records, owner) {
14
+ return records.find((record) => record.user === owner) ?? primaryAdmin(records);
15
+ }
11
16
  export function createWorkspaceService(deps) {
12
17
  return {
18
+ jobsRoot(owner) {
19
+ if (deps === undefined)
20
+ return undefined;
21
+ const { directory, managed } = deps;
22
+ const records = directory.list();
23
+ const record = selectRecord(records, owner);
24
+ if (record === undefined)
25
+ return join(managed.roots.dataDir, "jobs");
26
+ if (record.osUser !== undefined) {
27
+ if (deps.osUsers?.available !== true)
28
+ return undefined;
29
+ try {
30
+ const account = (deps.lookupAccount ?? lookupOsAccount)(record.osUser);
31
+ return account === undefined
32
+ ? undefined
33
+ : join(account.home, ".local", "state", "sigrun", "jobs");
34
+ }
35
+ catch {
36
+ return undefined;
37
+ }
38
+ }
39
+ return join(managed.userAreas(record.user), "jobs");
40
+ },
13
41
  async forConversation(conversation, prefs, read, ctx) {
14
42
  const workspace = prefs.defaultWorkspace;
15
43
  if (workspace.type !== "local") {
@@ -22,8 +50,7 @@ export function createWorkspaceService(deps) {
22
50
  const owner = read === undefined
23
51
  ? undefined
24
52
  : await deps.ownerOf(conversation.id, read, ctx ?? BACKGROUND_CONTEXT);
25
- const record = records.find((candidate) => candidate.user === owner)
26
- ?? records.find((candidate) => candidate.admin);
53
+ const record = selectRecord(records, owner);
27
54
  let osAccount;
28
55
  if (record?.osUser !== undefined) {
29
56
  if (deps.osUsers?.available !== true) {
package/dist/jobs/fire.js CHANGED
@@ -39,6 +39,7 @@ import { parseModelSpec } from "../config.js";
39
39
  import { expandInput } from "../commands/expand.js";
40
40
  import { SessionMetaDoc } from "../server/session-meta.js";
41
41
  import { noteThen } from "../server/turns/submit-turn.js";
42
+ import { recordAuthor } from "../curation/attribution.js";
42
43
  import { JobRunDoc, patchJob, readJob } from "./docs.js";
43
44
  /**
44
45
  * Fire one job under `fireKey`. `fireAt` is the epoch ms the key pins
@@ -122,7 +123,9 @@ async function jobPromptText(deps, conversationId, prompt, context) {
122
123
  if (conversation === undefined)
123
124
  return prompt;
124
125
  const env = await conversationEnv(deps, conversation);
125
- const expansion = await expandInput([{ type: "text", text: prompt }], deps.commandExpansion, env, context);
126
+ // Curator A3: the learned root is the conversation owner's.
127
+ const meta = await deps.harness.snapshot(SessionMetaDoc, conversationId, context);
128
+ const expansion = await expandInput([{ type: "text", text: prompt }], deps.commandExpansion, env, context, meta?.owner);
126
129
  const part = expansion.parts[0];
127
130
  return part !== undefined && part.type === "text" ? part.text : prompt;
128
131
  }
@@ -175,11 +178,21 @@ async function firePrompt(deps, id, record, fireKey, baseRun, prior, context) {
175
178
  }, context);
176
179
  }
177
180
  const prompt = await jobPromptText(deps, conversationId, action.prompt, context);
178
- await noteThen(deps.harness, conversation, () => conversation.submit({
179
- type: "input",
180
- content: [{ type: "text", text: prompt }],
181
- requestId: `job:${fireKey}`,
182
- }, context), context);
181
+ await noteThen(deps.harness, conversation,
182
+ // Attribution (curator P1): the job's input is authored by the
183
+ // job's owner — `byRequest` before the admission, `bySubmission`
184
+ // after, exactly like submitOp (an ownerless job records nothing).
185
+ async () => {
186
+ const requestId = `job:${fireKey}`;
187
+ await recordAuthor(conversation, { requestId }, record.owner, context);
188
+ const submission = await conversation.submit({
189
+ type: "input",
190
+ content: [{ type: "text", text: prompt }],
191
+ requestId,
192
+ }, context);
193
+ await recordAuthor(conversation, { submissionId: submission.id }, record.owner, context);
194
+ return submission;
195
+ }, context);
183
196
  return { outcome: "running", conversationId };
184
197
  }
185
198
  // conversation: "new" — the run record + conversation in ONE commit.
@@ -203,6 +216,7 @@ async function firePrompt(deps, id, record, fireKey, baseRun, prior, context) {
203
216
  : { thinkingLevel: action.thinking }),
204
217
  },
205
218
  init: async (tx, newId) => {
219
+ const owner = deps.effectiveOwner(record.owner);
206
220
  const run = {
207
221
  ...baseRun,
208
222
  conversationId: newId,
@@ -216,9 +230,9 @@ async function firePrompt(deps, id, record, fireKey, baseRun, prior, context) {
216
230
  meta.jobId = id;
217
231
  if (action.deliver !== undefined)
218
232
  meta.deliver = [...action.deliver];
219
- if (record.owner !== undefined)
220
- meta.owner = record.owner;
221
- await deps.rewriteUserSelection?.(tx, newId, undefined, record.owner);
233
+ if (owner !== undefined)
234
+ meta.owner = owner;
235
+ await deps.rewriteUserSelection?.(tx, newId, undefined, owner);
222
236
  if (action.runSharing !== undefined)
223
237
  meta.sharing = action.runSharing;
224
238
  meta.name = runName(record.spec.name, deps.now(), record, deps);
@@ -237,11 +251,20 @@ async function firePrompt(deps, id, record, fireKey, baseRun, prior, context) {
237
251
  return { outcome: "blocked" };
238
252
  }
239
253
  const prompt = await jobPromptText(deps, conversationId, action.prompt, context);
240
- await noteThen(deps.harness, conversation, () => conversation.submit({
241
- type: "input",
242
- content: [{ type: "text", text: prompt }],
243
- requestId: `job:${fireKey}`,
244
- }, context), context);
254
+ await noteThen(deps.harness, conversation,
255
+ // Attribution (curator P1), the twin of the fixed-conversation site
256
+ // above: the job's owner authors the job's input.
257
+ async () => {
258
+ const requestId = `job:${fireKey}`;
259
+ await recordAuthor(conversation, { requestId }, record.owner, context);
260
+ const submission = await conversation.submit({
261
+ type: "input",
262
+ content: [{ type: "text", text: prompt }],
263
+ requestId,
264
+ }, context);
265
+ await recordAuthor(conversation, { submissionId: submission.id }, record.owner, context);
266
+ return submission;
267
+ }, context);
245
268
  return { outcome: "running", conversationId };
246
269
  }
247
270
  /** `<job name> · <YYYY-MM-DD HH:mm in the job's timezone>` (the plan's name). */
@@ -28,6 +28,7 @@ import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
28
28
  import { JobDoc, listRevisions, listRuns, pruneHistory, readJob, scanJobs, writeJobChange, } from "./docs.js";
29
29
  import { fireJob } from "./fire.js";
30
30
  import { reconcileJobs, } from "./reconcile.js";
31
+ import { jobStateDir, removeJobState } from "./state-dir.js";
31
32
  import { specHash } from "./spec.js";
32
33
  /** Thrown when an API write targets a `file:`/`ext:` job id (409 `job_declared`). */
33
34
  export class JobDeclaredError extends Error {
@@ -53,13 +54,15 @@ function historyLimits(settings) {
53
54
  }
54
55
  /** The wire row of a record (access left for the caller: the ROUTE
55
56
  * computes it per principal; the raw row carries the neutral default).
56
- * Exported for boot's `job.changed` publication (W4 Task 4). */
57
- export function jobWireOf(id, record) {
57
+ * Exported for boot's `job.changed` publication (W4 Task 4). The
58
+ * effective owner is resolved by the caller; the stored record and its
59
+ * spec hash remain untouched (spec §6.5). */
60
+ export function jobWireOf(id, record, owner) {
58
61
  return {
59
62
  id,
60
63
  name: record.spec.name,
61
64
  source: record.source,
62
- ...(record.owner === undefined ? {} : { owner: record.owner }),
65
+ ...(owner === undefined ? {} : { owner }),
63
66
  ...(record.sharing === undefined
64
67
  ? {}
65
68
  : { sharing: JSON.parse(JSON.stringify(record.sharing)) }),
@@ -82,6 +85,17 @@ export function jobWireOf(id, record) {
82
85
  export function createJobsService(deps) {
83
86
  const ctx = BACKGROUND_CONTEXT;
84
87
  const limits = () => historyLimits(deps.settings);
88
+ const wireOf = (id, record) => jobWireOf(id, record, deps.effectiveOwner(record.owner));
89
+ const stateRoot = (owner) => deps.jobsRoot?.(owner);
90
+ const removeStateBestEffort = async (root, id) => {
91
+ try {
92
+ await removeJobState(root, id);
93
+ }
94
+ catch (error) {
95
+ const path = root === undefined ? id : jobStateDir(root, id);
96
+ console.warn(`jobs: could not remove job state ${path}: ${error instanceof Error ? error.message : String(error)}`);
97
+ }
98
+ };
85
99
  // `job.changed` observation (W4 Task 4): ONE commit subscription over
86
100
  // the `sigrun.job` family invokes the injected hook after EVERY record
87
101
  // change — the service's own API writes, the executor's status patches
@@ -114,15 +128,16 @@ export function createJobsService(deps) {
114
128
  runtime: deps.runtime,
115
129
  rootId: deps.rootId,
116
130
  sources: deps.sources,
131
+ ...(deps.jobsRoot === undefined ? {} : { jobsRoot: deps.jobsRoot }),
117
132
  }, ctx);
118
133
  return {
119
134
  async list() {
120
135
  const records = await scanJobs(deps.storage, ctx);
121
- return records.map(({ id, record }) => jobWireOf(id, record));
136
+ return records.map(({ id, record }) => wireOf(id, record));
122
137
  },
123
138
  async get(id) {
124
139
  const record = await readJob(deps.harness, id, ctx);
125
- return record === undefined ? undefined : jobWireOf(id, record);
140
+ return record === undefined ? undefined : wireOf(id, record);
126
141
  },
127
142
  async createApi(spec, owner) {
128
143
  const id = `api:${crypto.randomUUID()}`;
@@ -150,7 +165,7 @@ export function createJobsService(deps) {
150
165
  const record = await readJob(deps.harness, id, ctx);
151
166
  if (record === undefined)
152
167
  throw new JobNotFoundError(id);
153
- return jobWireOf(id, record);
168
+ return wireOf(id, record);
154
169
  },
155
170
  async updateApi(id, patch, changedBy) {
156
171
  const prior = await readJob(deps.harness, id, ctx);
@@ -191,7 +206,7 @@ export function createJobsService(deps) {
191
206
  const record = await readJob(deps.harness, id, ctx);
192
207
  if (record === undefined)
193
208
  throw new JobNotFoundError(id);
194
- return jobWireOf(id, record);
209
+ return wireOf(id, record);
195
210
  },
196
211
  async runs(id, limit) {
197
212
  return await listRuns(deps.storage, id, Math.max(0, limit), ctx);
@@ -205,14 +220,26 @@ export function createJobsService(deps) {
205
220
  throw new JobNotFoundError(id);
206
221
  if (prior.source !== "api")
207
222
  throw new JobDeclaredError(id);
223
+ // Resolve against the prior owner: the retiring commit removes the
224
+ // record, and state cleanup must not fall back to another user.
225
+ const root = stateRoot(prior.owner);
208
226
  const nowIso = new Date(deps.runtime.now()).toISOString();
209
227
  await deps.harness.commit(async (tx) => {
210
228
  // The final `enabled:false` revision, then the retire.
211
229
  await writeJobChange(tx, id, { ...prior, enabled: false }, changedBy, nowIso);
212
230
  await tx.retireDoc(JobDoc, id);
213
231
  }, ctx);
232
+ await removeStateBestEffort(root, id);
214
233
  await reconcileOne();
215
234
  },
235
+ async resetState(id) {
236
+ const record = await readJob(deps.harness, id, ctx);
237
+ if (record === undefined)
238
+ throw new JobNotFoundError(id);
239
+ // Unlike delete/transfer/reconcile, an explicit reset surfaces I/O
240
+ // failure to its caller; it never rewrites or retires the job.
241
+ await removeJobState(stateRoot(record.owner), id);
242
+ },
216
243
  async runNow(id) {
217
244
  // ONE clock reading: the manual fire key and the run's fireAt must
218
245
  // agree — the settle path re-derives the run-record key from
@@ -38,6 +38,7 @@
38
38
  import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
39
39
  import { JobDoc, patchJob, scanJobs, writeJobChange, } from "./docs.js";
40
40
  import { nextFireAt } from "./schedule.js";
41
+ import { jobStateDir, removeJobState } from "./state-dir.js";
41
42
  import { specHash } from "./spec.js";
42
43
  import { createScheduleTask } from "./task.js";
43
44
  const SCAN_PAGE = 500;
@@ -153,6 +154,14 @@ export async function reconcileJobs(deps, ctx = BACKGROUND_CONTEXT) {
153
154
  if (declaredIds.has(id))
154
155
  continue;
155
156
  await retireRecord(deps.harness, id, record, nowIso, ctx);
157
+ const root = deps.jobsRoot?.(record.owner);
158
+ try {
159
+ await removeJobState(root, id);
160
+ }
161
+ catch (error) {
162
+ const path = root === undefined ? id : jobStateDir(root, id);
163
+ console.warn(`jobs: could not remove retired job state ${path}: ${error instanceof Error ? error.message : String(error)}`);
164
+ }
156
165
  }
157
166
  // ── Pass 2: records → tasks ─────────────────────────────────────
158
167
  const after = new Map();
@@ -0,0 +1,40 @@
1
+ /**
2
+ * The core-side job skill-reference reader (curator P6, A9): the skill
3
+ * names a user's job PROMPTS reference — `/skill:name` tokens in every
4
+ * prompt action's text, from job records the durable scan sees (api,
5
+ * file, and extension sources alike), ENABLED AND PAUSED alike.
6
+ *
7
+ * The curator's diet step (Part B) needs this as the "job depends on
8
+ * this skill" signal: archiving a skill a paused job still references
9
+ * would break the job the moment it is re-enabled, so paused jobs count
10
+ * exactly as much as live ones.
11
+ *
12
+ * Pure read: one storage scan over the `sigrun.job` family (the same
13
+ * `scanJobs` primitive the jobs service's own reads use), filtered by
14
+ * owner, then a text scan for `/skill:name` tokens. No mutation, no
15
+ * task enumeration.
16
+ */
17
+ import { scanJobs } from "./docs.js";
18
+ /** The `/skill:name` token grammar (the expansion path's own prefix). */
19
+ const SKILL_TOKEN = /(?:^|\s)\/skill:([A-Za-z0-9][A-Za-z0-9._-]*)/g;
20
+ /**
21
+ * Every skill name the user's job prompts reference (`/skill:name`
22
+ * tokens), paused jobs included. A user with no jobs (or an unknown
23
+ * user) reads an empty set — the scan is the truth, never a directory
24
+ * check.
25
+ */
26
+ export async function jobSkillRefs(storage, user, ctx) {
27
+ const jobs = await scanJobs(storage, ctx);
28
+ const refs = new Set();
29
+ for (const { record } of jobs) {
30
+ if (record.owner !== user)
31
+ continue;
32
+ const action = record.spec.action;
33
+ if (action.kind !== "prompt")
34
+ continue;
35
+ for (const match of action.prompt.matchAll(SKILL_TOKEN)) {
36
+ refs.add(match[1]);
37
+ }
38
+ }
39
+ return refs;
40
+ }
@@ -0,0 +1,16 @@
1
+ /** Resolve and remove one job's on-disk state directory. */
2
+ import { rm } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ /** The complete job id is encoded into one path segment. */
5
+ export function jobStateDir(root, jobId) {
6
+ return join(root, encodeURIComponent(jobId));
7
+ }
8
+ /**
9
+ * Remove job state, or do nothing when the selected owner has no jobs root.
10
+ * Deletion failures reject so each job operation can apply its own policy.
11
+ */
12
+ export async function removeJobState(root, jobId) {
13
+ if (root === undefined)
14
+ return;
15
+ await rm(jobStateDir(root, jobId), { recursive: true, force: true });
16
+ }
@@ -17,8 +17,10 @@
17
17
  * ownerless until the source is edited — spec §8.3's posture).
18
18
  */
19
19
  import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
20
+ import { join } from "node:path";
20
21
  import { registerOwnershipParticipant } from "../server/ownership.js";
21
22
  import { scanJobs, writeJobChange } from "./docs.js";
23
+ import { jobStateDir } from "./state-dir.js";
22
24
  import { specHash } from "./spec.js";
23
25
  /**
24
26
  * Register the jobs transfer participant on `ctx` (createServer calls
@@ -36,6 +38,18 @@ export function registerJobsOwnershipParticipant(ctx) {
36
38
  continue;
37
39
  if (record.owner !== from)
38
40
  continue;
41
+ try {
42
+ // Before the owner rewrite: resetState resolves the old owner's
43
+ // root. The recipient's folder is deliberately never created.
44
+ await jobs.resetState(id);
45
+ }
46
+ catch (error) {
47
+ // Directory-user api jobs use their managed user area. (OS-user
48
+ // jobs cannot enter this transfer path — user deletion refuses
49
+ // osUser workspaces before any participant runs.)
50
+ const root = join(ctx.managedPaths.userAreas(from), "jobs");
51
+ console.warn(`jobs: could not remove transferred job state ${jobStateDir(root, id)}: ${error instanceof Error ? error.message : String(error)}`);
52
+ }
39
53
  const spec = { ...record.spec, owner: to };
40
54
  const next = {
41
55
  ...record,
@@ -3,6 +3,7 @@ import { execFileSync } from "node:child_process";
3
3
  import { existsSync, mkdirSync, realpathSync } from "node:fs";
4
4
  import { dirname, isAbsolute, join, resolve, sep } from "node:path";
5
5
  import { agentEnv } from "../harness/agent-env.js";
6
+ import { learnedSkillsDir } from "../curation/paths.js";
6
7
  import { revalidate, validateGrantPath } from "./grants.js";
7
8
  function canonical(path) {
8
9
  try {
@@ -161,24 +162,35 @@ export function buildUserPolicy(record, ctx) {
161
162
  };
162
163
  }
163
164
  // EVERY per-user path passes the same validator before entering policy:
164
- // stored workspace/grants via revalidate, and workspace/home again below.
165
+ // stored workspace/grants via revalidate, and workspace/home/jobs again below.
165
166
  // An osAccount home must validate BEFORE any directory is created.
166
167
  // tmp is the ONE sanctioned stateDir exception: it is derived only from
167
168
  // stateDir/sandbox/<user>/tmp and never silently routed around validation.
168
169
  const userArea = ctx.managed.userAreas(record.user);
169
170
  const requestedWorkspace = record.workspace ?? ctx.osAccount?.home ?? join(userArea, "workspace");
170
171
  const requestedHome = ctx.osAccount?.home ?? join(userArea, "home");
172
+ const requestedJobs = join(userArea, "jobs");
171
173
  if (ctx.osAccount !== undefined) {
172
174
  const validation = validateUserPath(requestedHome, record, ctx.managed);
173
175
  if (!validation.ok)
174
176
  return invalidReason(requestedHome, validation.reason);
175
177
  }
176
178
  const tmp = join(ctx.managed.roots.stateDir, "sandbox", record.user, "tmp");
177
- for (const path of [requestedWorkspace, requestedHome, tmp]) {
178
- if (ctx.osAccount !== undefined && path === tmp)
179
+ for (const path of [requestedWorkspace, requestedHome, tmp, requestedJobs]) {
180
+ if (ctx.osAccount !== undefined && (path === tmp || path === requestedJobs))
179
181
  continue;
180
182
  mkdirSync(path, { recursive: true, mode: 0o700 });
181
183
  }
184
+ // Curator A3: the owner's learned skills root exists and is granted
185
+ // read-only below. Created here (the designed empty case is an absent
186
+ // dir), validated like every other per-user path before entering
187
+ // policy — never another user's root.
188
+ const learned = learnedSkillsDir(userArea);
189
+ mkdirSync(learned, { recursive: true, mode: 0o700 });
190
+ const learnedValidation = validateUserPath(learned, record, ctx.managed);
191
+ if (!learnedValidation.ok) {
192
+ return invalidReason(learned, learnedValidation.reason);
193
+ }
182
194
  const workspaceValidation = validateUserPath(requestedWorkspace, record, ctx.managed);
183
195
  if (!workspaceValidation.ok) {
184
196
  return invalidReason(requestedWorkspace, workspaceValidation.reason);
@@ -186,6 +198,12 @@ export function buildUserPolicy(record, ctx) {
186
198
  const homeValidation = validateUserPath(requestedHome, record, ctx.managed);
187
199
  if (!homeValidation.ok)
188
200
  return invalidReason(requestedHome, homeValidation.reason);
201
+ const jobsValidation = ctx.osAccount === undefined
202
+ ? validateUserPath(requestedJobs, record, ctx.managed)
203
+ : undefined;
204
+ if (jobsValidation !== undefined && !jobsValidation.ok) {
205
+ return invalidReason(requestedJobs, jobsValidation.reason);
206
+ }
189
207
  const realTmp = canonical(tmp) ?? (ctx.osAccount === undefined
190
208
  ? undefined
191
209
  : resolve(tmp));
@@ -193,6 +211,7 @@ export function buildUserPolicy(record, ctx) {
193
211
  return invalidReason(tmp, "path cannot be resolved");
194
212
  const realWorkspace = workspaceValidation.realpath;
195
213
  const realHome = homeValidation.realpath;
214
+ const realJobs = jobsValidation?.realpath;
196
215
  // A source checkout that has ever been built has BOTH src/ and dist/;
197
216
  // defaultModuleTrees picks one while the worker entry may use the other
198
217
  // (dist worker.js when present, src worker.ts via tsx otherwise).
@@ -216,9 +235,10 @@ export function buildUserPolicy(record, ctx) {
216
235
  ...grants.flatMap((grant) => (grant.access === "rw" ? [grant.path] : [])),
217
236
  realHome,
218
237
  realTmp,
238
+ ...(realJobs === undefined ? [] : [realJobs]),
219
239
  // Device creation belongs to rw, but the selected devices' ordinary
220
240
  // read/write I/O survives a read-only Landlock rule. rw stays empty
221
- // except for workspace/home/tmp/grants so /dev/shm remains denied.
241
+ // except for workspace/home/jobs/tmp/grants so /dev/shm remains denied.
222
242
  ]);
223
243
  const ro = sortedUnique([
224
244
  "/dev",
@@ -243,6 +263,9 @@ export function buildUserPolicy(record, ctx) {
243
263
  ...moduleTrees.paths,
244
264
  defaultPackageConfig(ctx.managed.coreRoot),
245
265
  ...grants.flatMap((grant) => (grant.access === "ro" ? [grant.path] : [])),
266
+ // Curator A3: read access to the owner's own learned skills (support
267
+ // files are read with ordinary file tools); writes go through P3.
268
+ learnedValidation.realpath,
246
269
  ]);
247
270
  const env = agentEnv();
248
271
  delete env.SSH_AUTH_SOCK;
package/dist/serve.js CHANGED
@@ -175,9 +175,11 @@ export async function serve(env, options) {
175
175
  const envModel = env.SIGRUN_DEFAULT_MODEL?.trim() || env.SIGRUN_MODEL?.trim() || undefined;
176
176
  const settingsStore = new SettingsStore({ dir: defaultSettingsDir() });
177
177
  settingsStore.bootstrap(envServerSection(env), {
178
- extensions: env.SIGRUN_EXTENSIONS?.trim() === undefined
179
- ? undefined
180
- : envConfig.extensions,
178
+ // E8 smoke: SIGRUN_EXTENSIONS without any model env crashed boot —
179
+ // envConfig is only resolved when a model env is set (see envHasModel
180
+ // above), so envConfig!.extensions dereferenced undefined here.
181
+ // parseExtensionList is the env-side source of truth; use it directly.
182
+ extensions: parseExtensionList(env.SIGRUN_EXTENSIONS),
181
183
  defaultModel: envModel === undefined
182
184
  ? undefined
183
185
  : `${envConfig.models.provider}/${envConfig.models.modelId}`,
@@ -13,7 +13,9 @@
13
13
  * The surface's unnoted change records fold into `content` first and
14
14
  * `notedThrough` advances BEFORE the §8.3 note step runs — so the turn
15
15
  * sends no separate note for this surface, while changes on OTHER
16
- * surfaces still note. `requestId` makes a double click one turn.
16
+ * surfaces still note. `requestId` makes a double click one turn. The
17
+ * acting `user` rides through to `submitOp` as the answer's author
18
+ * (curator P1).
17
19
  */
18
20
  import { BACKGROUND_CONTEXT } from "@earendil-works/chord/context";
19
21
  import { toText } from "@sigrun/shared/blocks";
@@ -30,14 +32,15 @@ import { BlocksStateDoc } from "../../harness/blocks/state.js";
30
32
  * `skipNoteFor` (this surface notes no further this turn) and the
31
33
  * answer entry. The route owns access gating and actionId dedup; this
32
34
  * caller runs only for a located, write-accessible block whose handler
33
- * starts a turn.
35
+ * starts a turn. `user` is the acting user (curator P1): recorded as the
36
+ * answer input's author, `undefined` for an unidentified caller.
34
37
  *
35
38
  * The fold and the `notedThrough` advance run in ONE commit BEFORE the
36
39
  * note step (§8.4: "The surface's unnoted change records are folded into
37
40
  * `content` first, and `notedThrough` advances, before the §8.3 note
38
41
  * step runs"), so the answer turn never double-reports its surface.
39
42
  */
40
- export async function answerBlock(ctx, conversationId, entryId, action, payload, actionId) {
43
+ export async function answerBlock(ctx, conversationId, entryId, action, payload, actionId, user) {
41
44
  const { opened } = ctx;
42
45
  const block = await readChatBlock(opened.harness, conversationId, entryId);
43
46
  if (block === undefined) {
@@ -80,6 +83,9 @@ export async function answerBlock(ctx, conversationId, entryId, action, payload,
80
83
  }, BACKGROUND_CONTEXT);
81
84
  await submitOp(ctx, conversation, folded.content, {
82
85
  ...(actionId === undefined ? {} : { requestId: `blocks:${actionId}` }),
86
+ // Attribution (curator P1): the answering user authors the answer's
87
+ // input turn.
88
+ ...(user === undefined ? {} : { user }),
83
89
  skipNoteFor: entryId,
84
90
  entry: {
85
91
  kind: BlocksAnswerEntry.kind,