@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.
- package/CHANGELOG.md +92 -1
- package/README.md +63 -33
- package/dist/commands/expand.js +37 -5
- package/dist/curation/attribution.js +135 -0
- package/dist/curation/deletion.js +32 -0
- package/dist/curation/gate.js +50 -0
- package/dist/curation/host.js +342 -0
- package/dist/curation/internal.js +54 -0
- package/dist/curation/names.js +5 -0
- package/dist/curation/overlays.js +325 -0
- package/dist/curation/paths.js +17 -0
- package/dist/curation/run-doc.js +64 -0
- package/dist/curation/runs.js +579 -0
- package/dist/curation/scan.js +30 -0
- package/dist/curation/skill-tool.js +308 -0
- package/dist/curation/store.js +444 -0
- package/dist/curation/targets.js +77 -0
- package/dist/curation/usage.js +124 -0
- package/dist/curation/wrap.js +212 -0
- package/dist/extension.d.ts +336 -1
- package/dist/harness/boot.js +236 -13
- package/dist/harness/builtins.js +19 -12
- package/dist/harness/event-bus.js +64 -0
- package/dist/harness/identity/primary-admin.js +19 -0
- package/dist/harness/loader.js +114 -1
- package/dist/harness/naming.js +6 -0
- package/dist/harness/packages.js +14 -9
- package/dist/harness/reserved-sections.js +4 -0
- package/dist/harness/seam-host.js +154 -26
- package/dist/harness/sections/context-files.js +6 -3
- package/dist/harness/sections/job-state.js +24 -0
- package/dist/harness/settings.js +2 -2
- package/dist/harness/tools.js +70 -0
- package/dist/harness/workspace.js +30 -3
- package/dist/jobs/fire.js +37 -14
- package/dist/jobs/index.js +34 -7
- package/dist/jobs/reconcile.js +9 -0
- package/dist/jobs/skills-refs.js +40 -0
- package/dist/jobs/state-dir.js +16 -0
- package/dist/jobs/transfer.js +14 -0
- package/dist/sandbox/policy.js +27 -4
- package/dist/serve.js +5 -3
- package/dist/server/blocks/answers.js +9 -3
- package/dist/server/conversation-ops.js +34 -3
- package/dist/server/helpers.js +1 -1
- package/dist/server/host-bridge.js +31 -1
- package/dist/server/notifications.js +108 -16
- package/dist/server/routes/jobs.js +31 -0
- package/dist/server/routes/meta.js +8 -3
- package/dist/server/routes/submissions.js +5 -0
- package/dist/server/routes/surfaces.js +3 -1
- package/dist/server/routes/users.js +7 -0
- package/dist/server/schema-fixture.js +168 -0
- package/dist/server/session-meta.js +12 -10
- package/dist/settings/core-sections.js +18 -0
- package/dist/settings/registry.js +12 -0
- package/dist/skills/index.js +88 -19
- package/node_modules/@sigrun/shared/CHANGELOG.md +6 -1
- package/node_modules/@sigrun/shared/dist/access.d.ts +3 -0
- package/node_modules/@sigrun/shared/dist/access.js +8 -0
- package/node_modules/@sigrun/shared/dist/session-meta.d.ts +15 -4
- package/node_modules/@sigrun/shared/dist/settings.d.ts +7 -5
- package/node_modules/@sigrun/shared/package.json +1 -1
- package/package.json +9 -7
- package/webui/dist/assets/index-BR0di0OG.css +1 -0
- package/webui/dist/assets/{index-DWmsAQl2.js → index-BzrA1T4g.js} +5 -5
- package/webui/dist/index.html +2 -2
- package/webui/dist/assets/index-h7zDY9vD.css +0 -1
package/dist/harness/tools.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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,
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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 (
|
|
220
|
-
meta.owner =
|
|
221
|
-
await deps.rewriteUserSelection?.(tx, newId, undefined,
|
|
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,
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
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). */
|
package/dist/jobs/index.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
...(
|
|
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 }) =>
|
|
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 :
|
|
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
|
|
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
|
|
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
|
package/dist/jobs/reconcile.js
CHANGED
|
@@ -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
|
+
}
|
package/dist/jobs/transfer.js
CHANGED
|
@@ -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,
|
package/dist/sandbox/policy.js
CHANGED
|
@@ -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
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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,
|