@superjackfruitlabs/salaam 0.0.3 → 0.0.5
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 +86 -0
- package/README.md +12 -11
- package/dist/src/acp-desk.js +140 -0
- package/dist/src/acp-outcome.js +182 -18
- package/dist/src/acp.js +296 -15
- package/dist/src/agent-profiles.js +45 -0
- package/dist/src/agentpod-authority.js +169 -11
- package/dist/src/agentpod-default-config.js +12 -0
- package/dist/src/agentpod-work-decision-protocol.js +40 -6
- package/dist/src/agentpod-work-decisions.js +108 -4
- package/dist/src/agentpod-work.js +1214 -127
- package/dist/src/approval-rules.js +287 -0
- package/dist/src/attach.js +3 -1
- package/dist/src/autonomy.js +80 -9
- package/dist/src/board-reads.js +106 -0
- package/dist/src/children.js +83 -0
- package/dist/src/cli-help.js +61 -6
- package/dist/src/cli.js +140 -4
- package/dist/src/command-danger.js +797 -0
- package/dist/src/command-network.js +200 -0
- package/dist/src/command-sandbox.js +327 -0
- package/dist/src/custom-commands.js +159 -0
- package/dist/src/danger-classifier.js +286 -0
- package/dist/src/execution-evidence.js +33 -1
- package/dist/src/git-helpers.js +160 -0
- package/dist/src/hooks.js +351 -0
- package/dist/src/host-bind.js +111 -0
- package/dist/src/host-breaker.js +88 -0
- package/dist/src/host-start.js +41 -1
- package/dist/src/host.js +284 -6
- package/dist/src/house-extension.js +292 -61
- package/dist/src/local-coding.js +726 -35
- package/dist/src/local-edit-helper.js +25 -0
- package/dist/src/local-edits.js +168 -6
- package/dist/src/local-undo.js +25 -16
- package/dist/src/managed.js +1 -1
- package/dist/src/mentions.js +277 -0
- package/dist/src/operator-mcp-cli.js +107 -0
- package/dist/src/operator-mcp.js +342 -0
- package/dist/src/persona.js +31 -0
- package/dist/src/pi-sessions.js +5 -1
- package/dist/src/pi.js +22 -1
- package/dist/src/project-instructions.js +195 -0
- package/dist/src/protocol.js +9 -3
- package/dist/src/rate-limit-retries.js +62 -0
- package/dist/src/recurring-work.js +1 -0
- package/dist/src/repository-snapshots.js +5 -2
- package/dist/src/run-grants.js +62 -0
- package/dist/src/service.js +482 -10
- package/dist/src/session-pick.js +77 -0
- package/dist/src/shell-passthrough.js +39 -0
- package/dist/src/station-checkpoints.js +139 -0
- package/dist/src/station-config.js +8 -1
- package/dist/src/station-files.js +20 -1
- package/dist/src/station-task-verification.js +6 -3
- package/dist/src/station-work.js +315 -22
- package/dist/src/store.js +70 -4
- package/dist/src/supervisor.js +408 -25
- package/dist/src/task-budget.js +39 -3
- package/dist/src/task-context.js +8 -2
- package/dist/src/task-critic.js +115 -0
- package/dist/src/task-driver.js +46 -2
- package/dist/src/task-policy.js +44 -12
- package/dist/src/task-verification.js +42 -9
- package/dist/src/task-work.js +47 -0
- package/dist/src/terminal-status.js +88 -0
- package/dist/src/tool-identity.js +69 -0
- package/dist/src/tui-activity.js +99 -0
- package/dist/src/tui-classifier.js +25 -0
- package/dist/src/tui-client.js +186 -5
- package/dist/src/tui-commands.js +23 -5
- package/dist/src/tui-composer.js +113 -0
- package/dist/src/tui-keymap.js +116 -0
- package/dist/src/tui-mentions.js +38 -0
- package/dist/src/tui-modals.js +157 -34
- package/dist/src/tui-notify.js +62 -0
- package/dist/src/tui-review.js +55 -0
- package/dist/src/tui-turn-keys.js +52 -0
- package/dist/src/tui-view.js +243 -69
- package/dist/src/tui.js +875 -118
- package/dist/src/turn-checkpoints.js +221 -0
- package/dist/src/turn-rewind.js +168 -0
- package/dist/src/usage.js +1 -1
- package/dist/src/web-fetch.js +114 -0
- package/dist/src/web-guard.js +163 -0
- package/dist/src/web-search.js +302 -0
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
- package/src/acp-desk.ts +155 -0
- package/src/acp-outcome.ts +174 -20
- package/src/acp.ts +386 -30
- package/src/agent-profiles.ts +48 -0
- package/src/agentpod-authority.ts +234 -15
- package/src/agentpod-default-config.ts +16 -0
- package/src/agentpod-work-decision-protocol.ts +69 -8
- package/src/agentpod-work-decisions.ts +123 -5
- package/src/agentpod-work.ts +1455 -152
- package/src/approval-rules.ts +360 -0
- package/src/attach.ts +4 -1
- package/src/autonomy.ts +111 -10
- package/src/board-reads.ts +148 -0
- package/src/children.ts +112 -0
- package/src/cli-help.ts +60 -6
- package/src/cli.ts +174 -4
- package/src/command-danger.ts +886 -0
- package/src/command-network.ts +215 -0
- package/src/command-sandbox.ts +393 -0
- package/src/custom-commands.ts +175 -0
- package/src/danger-classifier.ts +321 -0
- package/src/execution-evidence.ts +31 -1
- package/src/git-helpers.ts +177 -0
- package/src/hooks.ts +425 -0
- package/src/host-bind.ts +156 -0
- package/src/host-breaker.ts +89 -0
- package/src/host-start.ts +51 -1
- package/src/host.ts +365 -6
- package/src/house-extension.ts +313 -60
- package/src/local-coding.ts +894 -43
- package/src/local-edit-helper.ts +26 -0
- package/src/local-edits.ts +191 -8
- package/src/local-undo.ts +29 -14
- package/src/managed.ts +2 -2
- package/src/mentions.ts +304 -0
- package/src/operator-mcp-cli.ts +108 -0
- package/src/operator-mcp.ts +420 -0
- package/src/persona.ts +37 -0
- package/src/pi-sessions.ts +9 -1
- package/src/pi.ts +23 -1
- package/src/project-instructions.ts +229 -0
- package/src/protocol.ts +25 -3
- package/src/rate-limit-retries.ts +69 -0
- package/src/recurring-work.ts +1 -0
- package/src/repository-snapshots.ts +5 -2
- package/src/run-grants.ts +107 -0
- package/src/service.ts +709 -10
- package/src/session-pick.ts +102 -0
- package/src/shell-passthrough.ts +38 -0
- package/src/station-checkpoints.ts +183 -0
- package/src/station-config.ts +10 -1
- package/src/station-files.ts +20 -1
- package/src/station-task-verification.ts +6 -3
- package/src/station-work.ts +369 -20
- package/src/store.ts +70 -3
- package/src/supervisor.ts +463 -26
- package/src/task-budget.ts +48 -3
- package/src/task-context.ts +9 -2
- package/src/task-critic.ts +171 -0
- package/src/task-driver.ts +49 -2
- package/src/task-policy.ts +65 -13
- package/src/task-verification.ts +53 -9
- package/src/task-work.ts +75 -0
- package/src/terminal-status.ts +103 -0
- package/src/tool-identity.ts +110 -0
- package/src/tui-activity.ts +133 -0
- package/src/tui-classifier.ts +38 -0
- package/src/tui-client.ts +239 -8
- package/src/tui-commands.ts +49 -10
- package/src/tui-composer.ts +124 -0
- package/src/tui-keymap.ts +152 -0
- package/src/tui-mentions.ts +43 -0
- package/src/tui-modals.ts +173 -43
- package/src/tui-notify.ts +77 -0
- package/src/tui-review.ts +76 -0
- package/src/tui-turn-keys.ts +77 -0
- package/src/tui-view.ts +341 -88
- package/src/tui.ts +1047 -137
- package/src/turn-checkpoints.ts +253 -0
- package/src/turn-rewind.ts +258 -0
- package/src/usage.ts +1 -1
- package/src/web-fetch.ts +140 -0
- package/src/web-guard.ts +195 -0
- package/src/web-search.ts +402 -0
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { randomUUID } from 'node:crypto';
|
|
5
|
+
import { object, ServiceError } from './protocol.js';
|
|
6
|
+
const SHELL_SPECIAL = /[;&|<>`$(){}\\\n\r*?[\]~!#]/;
|
|
7
|
+
const PRIVATE_PART = /^(\.|credentials$|secrets$)/;
|
|
8
|
+
/** The command's words, or null when a shell would read it as more than plain words. */
|
|
9
|
+
export function commandArgv(command) {
|
|
10
|
+
const words = [];
|
|
11
|
+
let word = '', inWord = false;
|
|
12
|
+
for (let i = 0; i < command.length; i++) {
|
|
13
|
+
const ch = command[i];
|
|
14
|
+
if (ch === "'") {
|
|
15
|
+
const end = command.indexOf("'", i + 1);
|
|
16
|
+
if (end < 0)
|
|
17
|
+
return null;
|
|
18
|
+
word += command.slice(i + 1, end);
|
|
19
|
+
inWord = true;
|
|
20
|
+
i = end;
|
|
21
|
+
}
|
|
22
|
+
else if (ch === '"') {
|
|
23
|
+
const end = command.indexOf('"', i + 1);
|
|
24
|
+
if (end < 0)
|
|
25
|
+
return null;
|
|
26
|
+
const inner = command.slice(i + 1, end);
|
|
27
|
+
if (/[$`\\]/.test(inner))
|
|
28
|
+
return null;
|
|
29
|
+
word += inner;
|
|
30
|
+
inWord = true;
|
|
31
|
+
i = end;
|
|
32
|
+
}
|
|
33
|
+
else if (ch === ' ' || ch === '\t') {
|
|
34
|
+
if (inWord)
|
|
35
|
+
words.push(word);
|
|
36
|
+
word = '';
|
|
37
|
+
inWord = false;
|
|
38
|
+
}
|
|
39
|
+
else if (SHELL_SPECIAL.test(ch))
|
|
40
|
+
return null;
|
|
41
|
+
else {
|
|
42
|
+
word += ch;
|
|
43
|
+
inWord = true;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
if (inWord)
|
|
47
|
+
words.push(word);
|
|
48
|
+
// A leading NAME=value is an environment assignment, not a command word.
|
|
49
|
+
if (!words.length || /^[A-Za-z_][A-Za-z0-9_]*=/.test(words[0]))
|
|
50
|
+
return null;
|
|
51
|
+
return words;
|
|
52
|
+
}
|
|
53
|
+
function patternWords(pattern) {
|
|
54
|
+
const words = pattern.trim().split(/\s+/);
|
|
55
|
+
if (!pattern.trim() || words.some((w) => /[;&|<>`$(){}\\'"!#~?[\]]/.test(w)))
|
|
56
|
+
throw new ServiceError('invalid_rule', 'A command rule is plain words; a word may use *, and a final * takes any further words');
|
|
57
|
+
return words;
|
|
58
|
+
}
|
|
59
|
+
const wordRe = (w) => new RegExp('^' +
|
|
60
|
+
w
|
|
61
|
+
.split('*')
|
|
62
|
+
.map((p) => p.replace(/[.+^${}()|[\]\\]/g, '\\$&'))
|
|
63
|
+
.join('.*') +
|
|
64
|
+
'$');
|
|
65
|
+
function commandMatches(pattern, argv) {
|
|
66
|
+
const words = patternWords(pattern);
|
|
67
|
+
const rest = words[words.length - 1] === '*';
|
|
68
|
+
const fixed = rest ? words.slice(0, -1) : words;
|
|
69
|
+
if (rest ? argv.length < fixed.length : argv.length !== fixed.length)
|
|
70
|
+
return false;
|
|
71
|
+
return fixed.every((w, i) => wordRe(w).test(argv[i]));
|
|
72
|
+
}
|
|
73
|
+
/** A project-relative glob as a regex: `**` spans segments, `*` stays within one. */
|
|
74
|
+
function globRe(glob) {
|
|
75
|
+
let re = '';
|
|
76
|
+
for (let i = 0; i < glob.length; i++) {
|
|
77
|
+
if (glob.startsWith('**/', i)) {
|
|
78
|
+
re += '(?:.*/)?';
|
|
79
|
+
i += 2;
|
|
80
|
+
}
|
|
81
|
+
else if (glob.startsWith('/**', i) && i + 3 === glob.length) {
|
|
82
|
+
re += '(?:/.*)?';
|
|
83
|
+
i += 2;
|
|
84
|
+
}
|
|
85
|
+
else if (glob.startsWith('**', i)) {
|
|
86
|
+
re += '.*';
|
|
87
|
+
i += 1;
|
|
88
|
+
}
|
|
89
|
+
else if (glob[i] === '*')
|
|
90
|
+
re += '[^/]*';
|
|
91
|
+
else
|
|
92
|
+
re += glob[i].replace(/[.+^${}()|[\]\\?]/g, '\\$&');
|
|
93
|
+
}
|
|
94
|
+
return new RegExp('^' + re + '$');
|
|
95
|
+
}
|
|
96
|
+
const pathMatches = (globs, path) => globs.some((g) => globRe(g).test(path));
|
|
97
|
+
export function validateRule(value) {
|
|
98
|
+
const input = object(value);
|
|
99
|
+
if (Object.keys(input).some((k) => !['effect', 'tool', 'command', 'paths'].includes(k)))
|
|
100
|
+
throw new ServiceError('invalid_rule', 'A rule has effect, tool, and command or paths only');
|
|
101
|
+
if (!['allow', 'ask', 'deny'].includes(String(input.effect)))
|
|
102
|
+
throw new ServiceError('invalid_rule', 'A rule effect is allow, ask or deny');
|
|
103
|
+
if (!['command', 'edit', 'any'].includes(String(input.tool)))
|
|
104
|
+
throw new ServiceError('invalid_rule', 'A rule tool is command, edit or any');
|
|
105
|
+
const rule = { effect: input.effect, tool: input.tool };
|
|
106
|
+
if (input.command !== undefined) {
|
|
107
|
+
if (typeof input.command !== 'string' || input.command.length > 512 || input.tool === 'edit')
|
|
108
|
+
throw new ServiceError('invalid_rule', 'Only command rules take a command pattern');
|
|
109
|
+
patternWords(input.command);
|
|
110
|
+
rule.command = input.command.trim().split(/\s+/).join(' ');
|
|
111
|
+
}
|
|
112
|
+
if (input.paths !== undefined) {
|
|
113
|
+
const paths = input.paths;
|
|
114
|
+
if (input.tool === 'command' ||
|
|
115
|
+
!Array.isArray(paths) ||
|
|
116
|
+
!paths.length ||
|
|
117
|
+
paths.length > 32 ||
|
|
118
|
+
paths.some((p) => typeof p !== 'string' ||
|
|
119
|
+
p.length > 512 ||
|
|
120
|
+
p.startsWith('/') ||
|
|
121
|
+
/[\x00-\x1f\x7f\\]/.test(p) ||
|
|
122
|
+
p.split('/').some((part) => !part || part === '..' || PRIVATE_PART.test(part))))
|
|
123
|
+
throw new ServiceError('invalid_rule', 'Edit rules take up to 32 project-relative globs; no absolute paths, traversal or private paths');
|
|
124
|
+
rule.paths = [...paths];
|
|
125
|
+
}
|
|
126
|
+
if (rule.tool === 'edit' && !rule.paths)
|
|
127
|
+
throw new ServiceError('invalid_rule', 'An edit rule names the paths it covers');
|
|
128
|
+
return rule;
|
|
129
|
+
}
|
|
130
|
+
/** The deciding rule for a request: deny beats ask beats allow; no match asks. */
|
|
131
|
+
export function evaluateRules(rules, request) {
|
|
132
|
+
const matching = rules.filter((r) => {
|
|
133
|
+
if (r.tool !== 'any' && r.tool !== request.tool)
|
|
134
|
+
return false;
|
|
135
|
+
if (request.tool === 'command') {
|
|
136
|
+
if (r.paths)
|
|
137
|
+
return false;
|
|
138
|
+
if (!r.command)
|
|
139
|
+
return r.effect === 'deny' || request.argv !== null;
|
|
140
|
+
return request.argv !== null && commandMatches(r.command, request.argv);
|
|
141
|
+
}
|
|
142
|
+
if (!r.paths)
|
|
143
|
+
return r.tool === 'any' && !r.command;
|
|
144
|
+
return r.effect === 'allow'
|
|
145
|
+
? request.paths.length > 0 && request.paths.every((p) => pathMatches(r.paths, p))
|
|
146
|
+
: request.paths.some((p) => pathMatches(r.paths, p));
|
|
147
|
+
});
|
|
148
|
+
for (const effect of ['deny', 'ask', 'allow']) {
|
|
149
|
+
const rule = matching.find((r) => r.effect === effect);
|
|
150
|
+
if (rule)
|
|
151
|
+
return { effect, rule };
|
|
152
|
+
}
|
|
153
|
+
return { effect: 'ask' };
|
|
154
|
+
}
|
|
155
|
+
export function defaultUserRulesFile(env = process.env) {
|
|
156
|
+
return (env.SALAAM_PERMISSIONS_FILE ||
|
|
157
|
+
join(env.XDG_CONFIG_HOME || join(homedir(), '.config'), 'salaam', 'permissions.json'));
|
|
158
|
+
}
|
|
159
|
+
export class ApprovalRules {
|
|
160
|
+
store;
|
|
161
|
+
userFile;
|
|
162
|
+
constructor(store, userFile = defaultUserRulesFile()) {
|
|
163
|
+
this.store = store;
|
|
164
|
+
this.userFile = userFile;
|
|
165
|
+
store.db.exec(`CREATE TABLE IF NOT EXISTS approval_rules(
|
|
166
|
+
id TEXT PRIMARY KEY NOT NULL,
|
|
167
|
+
scope TEXT NOT NULL CHECK(scope IN ('session','repo')),
|
|
168
|
+
session_id TEXT,
|
|
169
|
+
root TEXT NOT NULL,
|
|
170
|
+
rule TEXT NOT NULL,
|
|
171
|
+
created_at INTEGER NOT NULL)`);
|
|
172
|
+
}
|
|
173
|
+
/** Whether rules apply: only desk sessions, never host-managed (AgentPod) or child work. */
|
|
174
|
+
applies(session) {
|
|
175
|
+
if (!this.store.localCoding(session))
|
|
176
|
+
return false;
|
|
177
|
+
const has = (sql) => {
|
|
178
|
+
try {
|
|
179
|
+
return !!this.store.db.prepare(sql).get(session);
|
|
180
|
+
}
|
|
181
|
+
catch {
|
|
182
|
+
return false;
|
|
183
|
+
}
|
|
184
|
+
};
|
|
185
|
+
return (!has('SELECT 1 FROM agentpod_admissions WHERE session_id=?') &&
|
|
186
|
+
!has('SELECT 1 FROM child_tasks WHERE child_session=?'));
|
|
187
|
+
}
|
|
188
|
+
userRules() {
|
|
189
|
+
let text;
|
|
190
|
+
try {
|
|
191
|
+
text = readFileSync(this.userFile, 'utf8');
|
|
192
|
+
}
|
|
193
|
+
catch {
|
|
194
|
+
return { rules: [] };
|
|
195
|
+
}
|
|
196
|
+
try {
|
|
197
|
+
const doc = object(JSON.parse(text));
|
|
198
|
+
if (doc.version !== 1 || !Array.isArray(doc.rules))
|
|
199
|
+
throw new Error('expected {"version": 1, "rules": [...]}');
|
|
200
|
+
return {
|
|
201
|
+
rules: doc.rules.map((r, i) => ({
|
|
202
|
+
...validateRule(r),
|
|
203
|
+
id: 'user:' + i,
|
|
204
|
+
source: 'user',
|
|
205
|
+
})),
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
catch (error) {
|
|
209
|
+
return {
|
|
210
|
+
rules: [],
|
|
211
|
+
problem: `${this.userFile} is ignored: ${error instanceof Error ? error.message : 'unreadable'}`,
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
list(session) {
|
|
216
|
+
const root = this.store.session(session).cwd;
|
|
217
|
+
const stored = this.store.db
|
|
218
|
+
.prepare("SELECT id,scope,rule FROM approval_rules WHERE (scope='session' AND session_id=?) OR (scope='repo' AND root=?) ORDER BY created_at,rowid")
|
|
219
|
+
.all(session, root);
|
|
220
|
+
return [
|
|
221
|
+
...stored.map((r) => ({
|
|
222
|
+
...JSON.parse(r.rule),
|
|
223
|
+
id: r.id,
|
|
224
|
+
source: r.scope,
|
|
225
|
+
})),
|
|
226
|
+
...this.userRules().rules,
|
|
227
|
+
];
|
|
228
|
+
}
|
|
229
|
+
problems(session) {
|
|
230
|
+
void session;
|
|
231
|
+
const problem = this.userRules().problem;
|
|
232
|
+
return problem ? [problem] : [];
|
|
233
|
+
}
|
|
234
|
+
add(session, scope, value, request) {
|
|
235
|
+
const rule = validateRule(value);
|
|
236
|
+
if (!this.applies(session))
|
|
237
|
+
throw new ServiceError('rules_unavailable', 'Approval rules apply only to desk coding sessions');
|
|
238
|
+
return this.store.request('local-operator', request, { kind: 'approval-rule', session, scope, rule }, () => {
|
|
239
|
+
const id = randomUUID();
|
|
240
|
+
this.store.db
|
|
241
|
+
.prepare('INSERT INTO approval_rules(id,scope,session_id,root,rule,created_at) VALUES(?,?,?,?,?,?)')
|
|
242
|
+
.run(id, scope, scope === 'session' ? session : null, this.store.session(session).cwd, JSON.stringify(rule), Date.now());
|
|
243
|
+
this.store.event(session, 'approval_rule.added', { id, scope, rule });
|
|
244
|
+
return { ...rule, id, source: scope };
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
remove(session, id) {
|
|
248
|
+
const root = this.store.session(session).cwd;
|
|
249
|
+
const done = this.store.db
|
|
250
|
+
.prepare("DELETE FROM approval_rules WHERE id=? AND ((scope='session' AND session_id=?) OR (scope='repo' AND root=?))")
|
|
251
|
+
.run(id, session, root);
|
|
252
|
+
if (!done.changes)
|
|
253
|
+
throw new ServiceError('rule_not_found', 'No such rule for this session or repository');
|
|
254
|
+
this.store.event(session, 'approval_rule.removed', { id });
|
|
255
|
+
}
|
|
256
|
+
/** The rule an approved operation stands for: the exact command words, or the exact edited paths. */
|
|
257
|
+
ruleFor(args) {
|
|
258
|
+
if (args.action === 'command') {
|
|
259
|
+
const argv = commandArgv(args.command ?? '');
|
|
260
|
+
if (!argv)
|
|
261
|
+
throw new ServiceError('invalid_rule', 'This command uses shell features a rule cannot match safely; approve it once instead');
|
|
262
|
+
const quote = (w) => /^[A-Za-z0-9_@%+=:,./-]+$/.test(w) && !w.includes('*') ? w : null;
|
|
263
|
+
if (argv.some((w) => quote(w) === null))
|
|
264
|
+
throw new ServiceError('invalid_rule', 'This command has words a rule cannot spell exactly; approve it once instead');
|
|
265
|
+
return validateRule({ effect: 'allow', tool: 'command', command: argv.join(' ') });
|
|
266
|
+
}
|
|
267
|
+
if (args.action === 'edit' && args.edits?.length)
|
|
268
|
+
return validateRule({ effect: 'allow', tool: 'edit', paths: args.edits.map((e) => e.path) });
|
|
269
|
+
throw new ServiceError('invalid_rule', 'Only local commands and edits can become rules');
|
|
270
|
+
}
|
|
271
|
+
addFromOperation(session, operation, scope, request) {
|
|
272
|
+
const row = this.store.db
|
|
273
|
+
.prepare('SELECT args,session_id FROM local_calls WHERE operation_id=?')
|
|
274
|
+
.get(operation);
|
|
275
|
+
if (!row || row.session_id !== session)
|
|
276
|
+
throw new ServiceError('rule_not_found', 'That request is not a local command or edit in this session');
|
|
277
|
+
return this.add(session, scope, this.ruleFor(JSON.parse(row.args)), request);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
/** Why a reviewed request can only be approved once: a command with shell features (pipes,
|
|
281
|
+
* redirects, substitutions) can never become an approval rule. Undefined when a rule is possible. */
|
|
282
|
+
export function onceOnlyReason(value) {
|
|
283
|
+
const command = /\nCommand:\n([\s\S]*)$/.exec(value)?.[1];
|
|
284
|
+
return command !== undefined && commandArgv(command) === null
|
|
285
|
+
? 'This command uses shell features (pipes, redirects, substitutions), so it cannot become a rule. Approve it once or deny it.'
|
|
286
|
+
: undefined;
|
|
287
|
+
}
|
package/dist/src/attach.js
CHANGED
|
@@ -41,7 +41,7 @@ export class Outbox {
|
|
|
41
41
|
renameSync(temporary, path);
|
|
42
42
|
syncDirectory(this.dir);
|
|
43
43
|
}
|
|
44
|
-
enqueue(text, revision, continues) {
|
|
44
|
+
enqueue(text, revision, continues, confirmSensitive = false) {
|
|
45
45
|
if (!text.trim() || text.length > 256 * 1024 || text.trimStart().startsWith('/'))
|
|
46
46
|
throw new Error('Enter a plain instruction of at most 256 Ki characters');
|
|
47
47
|
const created = this.records().reduce((latest, record) => Math.max(latest, record.created_at + 1), Date.now());
|
|
@@ -51,6 +51,7 @@ export class Outbox {
|
|
|
51
51
|
expected_revision: revision,
|
|
52
52
|
text,
|
|
53
53
|
...(continues ? { continues_attempt_id: continues } : {}),
|
|
54
|
+
...(confirmSensitive ? { confirm_sensitive: true } : {}),
|
|
54
55
|
};
|
|
55
56
|
this.save(record);
|
|
56
57
|
return record;
|
|
@@ -174,6 +175,7 @@ export class Outbox {
|
|
|
174
175
|
'approval_mismatch',
|
|
175
176
|
'binding_changed',
|
|
176
177
|
'attempt_settled',
|
|
178
|
+
'sensitive_attachment',
|
|
177
179
|
].includes(error.code) ||
|
|
178
180
|
(record.command && error.code === 'not_found') ||
|
|
179
181
|
(['task', 'job'].includes(record.command?.kind ?? '') &&
|
package/dist/src/autonomy.js
CHANGED
|
@@ -1,5 +1,20 @@
|
|
|
1
1
|
import { realpathSync } from 'node:fs';
|
|
2
2
|
import { canonical, digest, object, ServiceError } from './protocol.js';
|
|
3
|
+
const MODES = [
|
|
4
|
+
'inspect',
|
|
5
|
+
'review',
|
|
6
|
+
'scoped-edit',
|
|
7
|
+
'accept-edits',
|
|
8
|
+
'auto',
|
|
9
|
+
];
|
|
10
|
+
/** Modes in which the service performs no local mutation, however it was approved. */
|
|
11
|
+
const READ_ONLY_MODES = ['inspect'];
|
|
12
|
+
/** Shift+Tab: review → accept edits → auto → review. Full access and YOLO are never in the cycle. */
|
|
13
|
+
const CYCLE = ['review', 'accept-edits', 'auto'];
|
|
14
|
+
export function nextMode(mode) {
|
|
15
|
+
const at = CYCLE.indexOf(mode);
|
|
16
|
+
return at < 0 ? 'review' : CYCLE[(at + 1) % CYCLE.length];
|
|
17
|
+
}
|
|
3
18
|
export function permissionPolicy(store, session) {
|
|
4
19
|
const root = store.session(session).cwd;
|
|
5
20
|
const row = store.db
|
|
@@ -17,9 +32,14 @@ export function permissionPolicy(store, session) {
|
|
|
17
32
|
if (!Number.isSafeInteger(saved.revision) ||
|
|
18
33
|
Number(saved.revision) < 1 ||
|
|
19
34
|
saved.root !== root ||
|
|
20
|
-
Object.keys(saved).some((key) => !['revision', 'root', 'mode', 'paths'].includes(key)))
|
|
35
|
+
Object.keys(saved).some((key) => !['revision', 'root', 'mode', 'paths', 'access'].includes(key)))
|
|
21
36
|
throw new Error('Invalid saved policy');
|
|
22
|
-
const policy = validatePermissions({
|
|
37
|
+
const policy = validatePermissions({
|
|
38
|
+
// Plan mode was removed (operator ruling 2026-10-31): a session saved in it reads as review.
|
|
39
|
+
mode: saved.mode === 'plan' ? 'review' : saved.mode,
|
|
40
|
+
paths: saved.paths,
|
|
41
|
+
...(saved.access === undefined ? {} : { access: saved.access }),
|
|
42
|
+
});
|
|
23
43
|
return { ...policy, revision: Number(saved.revision), root };
|
|
24
44
|
}
|
|
25
45
|
catch {
|
|
@@ -28,9 +48,10 @@ export function permissionPolicy(store, session) {
|
|
|
28
48
|
}
|
|
29
49
|
export function validatePermissions(value) {
|
|
30
50
|
const input = object(value);
|
|
31
|
-
if (Object.keys(input).some((k) => !['mode', 'paths'].includes(k)) ||
|
|
32
|
-
!
|
|
33
|
-
|
|
51
|
+
if (Object.keys(input).some((k) => !['mode', 'paths', 'access'].includes(k)) ||
|
|
52
|
+
!MODES.includes(String(input.mode)) ||
|
|
53
|
+
(input.access !== undefined && input.access !== 'full' && input.access !== 'yolo'))
|
|
54
|
+
throw new ServiceError('invalid_permissions', 'Choose inspect, review, scoped-edit, accept-edits or auto, and access "full", "yolo" or none; unknown policy fields are rejected');
|
|
34
55
|
const paths = input.paths ?? [];
|
|
35
56
|
if (!Array.isArray(paths) ||
|
|
36
57
|
paths.length > 32 ||
|
|
@@ -48,16 +69,31 @@ export function validatePermissions(value) {
|
|
|
48
69
|
throw new ServiceError('invalid_permissions', 'Use up to 32 distinct project-relative files or directories ending in /; no wildcards, traversal or private paths');
|
|
49
70
|
if ((input.mode === 'scoped-edit') !== paths.length > 0)
|
|
50
71
|
throw new ServiceError('invalid_permissions', 'Only scoped-edit takes paths, and requires at least one');
|
|
51
|
-
return {
|
|
72
|
+
return {
|
|
73
|
+
mode: input.mode,
|
|
74
|
+
paths: [...paths].sort(),
|
|
75
|
+
...(input.access === 'full' || input.access === 'yolo'
|
|
76
|
+
? { access: input.access }
|
|
77
|
+
: {}),
|
|
78
|
+
};
|
|
52
79
|
}
|
|
53
|
-
export function configurePermissions(store, session, request, expected, value, actor
|
|
80
|
+
export function configurePermissions(store, session, request, expected, value, actor,
|
|
81
|
+
/** The operator's settings: may an interactive session choose full access or YOLO, and is this a
|
|
82
|
+
* desk session (YOLO is never for host-managed, board or child work)? */
|
|
83
|
+
opts = {}) {
|
|
54
84
|
const policy = validatePermissions(value);
|
|
85
|
+
if (policy.access === 'full' && opts.allowFullAccess === false)
|
|
86
|
+
throw new ServiceError('full_access_denied', 'The operator does not allow full access on this host; commands stay in the OS sandbox');
|
|
87
|
+
if (policy.access === 'yolo' && (opts.allowYolo === false || opts.deskSession !== true))
|
|
88
|
+
throw new ServiceError('yolo_denied', opts.deskSession !== true
|
|
89
|
+
? 'YOLO is only for your own desk sessions, never host-managed, board or child work'
|
|
90
|
+
: 'The operator does not allow YOLO on this host; commands keep their approvals');
|
|
55
91
|
return store.request('local-operator', request, { kind: 'configure-permissions', session, expected, policy }, () => {
|
|
56
92
|
store.authorizeControl(session, actor);
|
|
57
93
|
store.execution.assert(session);
|
|
58
94
|
if (!store.localWorkspace(session))
|
|
59
95
|
throw new ServiceError('permissions_unavailable', 'This policy applies only to local workspace sessions, not managed stations or child workers');
|
|
60
|
-
if (policy.mode
|
|
96
|
+
if (!READ_ONLY_MODES.includes(policy.mode) && !store.localCoding(session))
|
|
61
97
|
throw new ServiceError('local_denied', 'Create a coding session first; policy cannot grant missing coding capability');
|
|
62
98
|
const current = permissionPolicy(store, session);
|
|
63
99
|
if (current.revision !== expected)
|
|
@@ -80,7 +116,7 @@ export function matchesEditScope(policy, paths) {
|
|
|
80
116
|
}
|
|
81
117
|
export function assertPermissionBinding(store, session, operation, root) {
|
|
82
118
|
const policy = permissionPolicy(store, session);
|
|
83
|
-
if (policy.root !== root || policy.mode
|
|
119
|
+
if (policy.root !== root || READ_ONLY_MODES.includes(policy.mode))
|
|
84
120
|
throw new ServiceError('permission_denied', 'Current policy does not permit local mutations');
|
|
85
121
|
const binding = store.db
|
|
86
122
|
.prepare("SELECT payload FROM events WHERE session_id=? AND kind='permission.auto-approved' AND json_extract(payload,'$.operation_id')=? ORDER BY seq DESC LIMIT 1")
|
|
@@ -88,4 +124,39 @@ export function assertPermissionBinding(store, session, operation, root) {
|
|
|
88
124
|
if (binding && JSON.parse(binding.payload).policy_digest !== digest(policy))
|
|
89
125
|
throw new ServiceError('permission_changed', 'Auto-approval belongs to an older policy; prepare and review a fresh operation');
|
|
90
126
|
}
|
|
127
|
+
/** A project-relative path the session's modes may edit without review: not private or hidden. */
|
|
128
|
+
function ordinaryProjectPath(path) {
|
|
129
|
+
return (path.length > 0 &&
|
|
130
|
+
!path.startsWith('/') &&
|
|
131
|
+
path
|
|
132
|
+
.split('/')
|
|
133
|
+
.every((part) => part !== '' &&
|
|
134
|
+
part !== '..' &&
|
|
135
|
+
!part.startsWith('.') &&
|
|
136
|
+
!['credentials', 'secrets', 'node_modules', 'vendor'].includes(part)));
|
|
137
|
+
}
|
|
138
|
+
/** Whether this policy approves these edits by itself: scoped edits, accept-edits, auto and full
|
|
139
|
+
* access approve ordinary project edits; YOLO approves every edit. */
|
|
140
|
+
export function autoApprovesEdits(policy, paths) {
|
|
141
|
+
if (policy.access === 'yolo')
|
|
142
|
+
return paths.length > 0;
|
|
143
|
+
if (policy.mode === 'scoped-edit' && !policy.access)
|
|
144
|
+
return matchesEditScope(policy, paths);
|
|
145
|
+
return ((policy.mode === 'accept-edits' || policy.mode === 'auto' || policy.access === 'full') &&
|
|
146
|
+
paths.length > 0 &&
|
|
147
|
+
paths.every(ordinaryProjectPath));
|
|
148
|
+
}
|
|
149
|
+
/** Whether this policy approves a command by itself: auto inside the OS sandbox; full access unless
|
|
150
|
+
* the danger check calls the command dangerous; YOLO always. */
|
|
151
|
+
export function autoApprovesCommands(policy, sandboxed, dangerous,
|
|
152
|
+
/** Pushes or opens a pull request: asks in every mode but YOLO (operator ruling 2026-10-31). */
|
|
153
|
+
publishes = false) {
|
|
154
|
+
if (policy.access === 'yolo')
|
|
155
|
+
return true;
|
|
156
|
+
if (publishes)
|
|
157
|
+
return false;
|
|
158
|
+
if (policy.access === 'full')
|
|
159
|
+
return !dangerous;
|
|
160
|
+
return policy.mode === 'auto' && sandboxed;
|
|
161
|
+
}
|
|
91
162
|
export const permissionDigest = (policy) => digest(JSON.parse(canonical(policy)));
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { ServiceError } from './protocol.js';
|
|
3
|
+
const ITEM = /^itm_[0-9a-f]{16}$/;
|
|
4
|
+
/** The most a hub answer may carry back to the agent. */
|
|
5
|
+
export const BOARD_READ_MAX_BYTES = 256 * 1024;
|
|
6
|
+
const bad = (message) => new ServiceError('invalid_board_read', message, 400);
|
|
7
|
+
/** Closed arguments per action; anything else is refused. */
|
|
8
|
+
export function boardReadArgs(input) {
|
|
9
|
+
const { action, ...rest } = input;
|
|
10
|
+
const keys = Object.keys(rest).filter((k) => rest[k] !== undefined);
|
|
11
|
+
if (action === 'card' || action === 'attempts') {
|
|
12
|
+
if (keys.length)
|
|
13
|
+
throw bad(`${action} takes no arguments: it is always this run's own card`);
|
|
14
|
+
return { action, args: {} };
|
|
15
|
+
}
|
|
16
|
+
if (action === 'library_search') {
|
|
17
|
+
const q = rest.query;
|
|
18
|
+
if (keys.join() !== 'query' || typeof q !== 'string' || !q.trim() || q.length > 500)
|
|
19
|
+
throw bad('library_search takes one query of 1 to 500 characters');
|
|
20
|
+
return { action, args: { query: q.trim() } };
|
|
21
|
+
}
|
|
22
|
+
if (action === 'library_get') {
|
|
23
|
+
const id = rest.item_id;
|
|
24
|
+
if (keys.join() !== 'item_id' || typeof id !== 'string' || !ITEM.test(id))
|
|
25
|
+
throw bad('library_get takes one item_id (itm_ and 16 hex characters)');
|
|
26
|
+
return { action, args: { item_id: id } };
|
|
27
|
+
}
|
|
28
|
+
throw bad('Board reads are card, attempts, library_search or library_get');
|
|
29
|
+
}
|
|
30
|
+
export class BoardReads {
|
|
31
|
+
store;
|
|
32
|
+
constructor(store) {
|
|
33
|
+
this.store = store;
|
|
34
|
+
store.db.exec(`CREATE TABLE IF NOT EXISTS board_reads(
|
|
35
|
+
id TEXT PRIMARY KEY, session_id TEXT NOT NULL, tool_call_id TEXT NOT NULL, action TEXT NOT NULL,
|
|
36
|
+
args TEXT NOT NULL, state TEXT NOT NULL, ok INTEGER, text TEXT, error TEXT,
|
|
37
|
+
created_at TEXT NOT NULL, answered_at TEXT, UNIQUE(session_id, tool_call_id))`);
|
|
38
|
+
}
|
|
39
|
+
view(row) {
|
|
40
|
+
return {
|
|
41
|
+
id: String(row.id),
|
|
42
|
+
action: String(row.action),
|
|
43
|
+
args: JSON.parse(String(row.args)),
|
|
44
|
+
state: String(row.state),
|
|
45
|
+
...(row.state === 'answered'
|
|
46
|
+
? {
|
|
47
|
+
ok: row.ok === 1,
|
|
48
|
+
...(row.text !== null ? { text: String(row.text) } : {}),
|
|
49
|
+
...(row.error !== null ? { error: String(row.error) } : {}),
|
|
50
|
+
}
|
|
51
|
+
: {}),
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
/** Record a read for this session, once per tool call. */
|
|
55
|
+
request(session, toolCallId, input) {
|
|
56
|
+
const { action, args } = boardReadArgs(input);
|
|
57
|
+
const existing = this.store.db
|
|
58
|
+
.prepare('SELECT * FROM board_reads WHERE session_id=? AND tool_call_id=?')
|
|
59
|
+
.get(session, toolCallId);
|
|
60
|
+
if (existing)
|
|
61
|
+
return this.view(existing);
|
|
62
|
+
const id = randomUUID();
|
|
63
|
+
this.store.db
|
|
64
|
+
.prepare("INSERT INTO board_reads(id,session_id,tool_call_id,action,args,state,created_at) VALUES (?,?,?,?,?,'pending',?)")
|
|
65
|
+
.run(id, session, toolCallId, action, JSON.stringify(args), new Date().toISOString());
|
|
66
|
+
return this.get(session, id);
|
|
67
|
+
}
|
|
68
|
+
get(session, id) {
|
|
69
|
+
const row = this.store.db
|
|
70
|
+
.prepare('SELECT * FROM board_reads WHERE id=? AND session_id=?')
|
|
71
|
+
.get(id, session);
|
|
72
|
+
if (!row)
|
|
73
|
+
throw new ServiceError('board_read_not_found', 'No such board read', 404);
|
|
74
|
+
return this.view(row);
|
|
75
|
+
}
|
|
76
|
+
/** What the hub should serve for this session, oldest first. */
|
|
77
|
+
pending(session) {
|
|
78
|
+
return this.store.db
|
|
79
|
+
.prepare("SELECT * FROM board_reads WHERE session_id=? AND state='pending' ORDER BY rowid LIMIT 8")
|
|
80
|
+
.all(session)
|
|
81
|
+
.map((r) => {
|
|
82
|
+
const v = this.view(r);
|
|
83
|
+
return { id: v.id, action: v.action, args: v.args };
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
/** The hub's answer: once, for a read of this session, bounded. */
|
|
87
|
+
answer(session, value) {
|
|
88
|
+
const a = (value ?? {});
|
|
89
|
+
const keys = Object.keys(a).sort().join();
|
|
90
|
+
const ok = a.ok === true;
|
|
91
|
+
if (typeof a.read_id !== 'string' ||
|
|
92
|
+
(ok
|
|
93
|
+
? keys !== 'ok,read_id,text' || typeof a.text !== 'string'
|
|
94
|
+
: keys !== 'error,ok,read_id' || a.ok !== false || typeof a.error !== 'string'))
|
|
95
|
+
throw bad('Closed board read answer required');
|
|
96
|
+
const body = ok ? a.text : a.error;
|
|
97
|
+
if (Buffer.byteLength(body) > (ok ? BOARD_READ_MAX_BYTES : 2048))
|
|
98
|
+
throw bad('Board read answer too large');
|
|
99
|
+
const done = this.store.db
|
|
100
|
+
.prepare("UPDATE board_reads SET state='answered',ok=?,text=?,error=?,answered_at=? WHERE id=? AND session_id=? AND state='pending'")
|
|
101
|
+
.run(ok ? 1 : 0, ok ? body : null, ok ? null : body, new Date().toISOString(), a.read_id, session);
|
|
102
|
+
if (done.changes !== 1)
|
|
103
|
+
throw new ServiceError('board_read_settled', 'That board read is not pending for this run', 409);
|
|
104
|
+
return this.get(session, a.read_id);
|
|
105
|
+
}
|
|
106
|
+
}
|
package/dist/src/children.js
CHANGED
|
@@ -169,6 +169,89 @@ export class ChildWork {
|
|
|
169
169
|
this.start(child);
|
|
170
170
|
return child;
|
|
171
171
|
}
|
|
172
|
+
/**
|
|
173
|
+
* The task critic (item 8): a reviewer child over an immutable snapshot of the verified
|
|
174
|
+
* deliverables. No parent attempt is active at completion, so it is linked to the task's last
|
|
175
|
+
* step attempt and charged to its own bounded budget, never to an attempt's reservation. The
|
|
176
|
+
* grant must be the built-in reviewer; the child can read the snapshot and report findings only.
|
|
177
|
+
*/
|
|
178
|
+
spawnCritic(parent, parentAttempt, step, prompt, budget, grant, snapshot) {
|
|
179
|
+
if (!Number.isSafeInteger(budget) || budget < 1 || budget > 8)
|
|
180
|
+
throw new ServiceError('invalid_budget', 'Critic request budget must be 1–8');
|
|
181
|
+
if (grant.profile.id !== 'salaam/reviewer' || grant.owner_session !== parent)
|
|
182
|
+
throw new ServiceError('authority_denied', 'The critic must be the built-in reviewer');
|
|
183
|
+
if (!Object.keys(snapshot).length)
|
|
184
|
+
throw new ServiceError('invalid_child', 'A critic needs a snapshot of the verified deliverables');
|
|
185
|
+
if (!prompt.trim() || prompt.length > 32768)
|
|
186
|
+
throw new ServiceError('invalid_child', 'Invalid critic prompt');
|
|
187
|
+
const authority = [...grant.profile.tools].sort();
|
|
188
|
+
const input = digest({
|
|
189
|
+
prompt,
|
|
190
|
+
budget,
|
|
191
|
+
critic: grant.profile_digest,
|
|
192
|
+
snapshot: digest(snapshot),
|
|
193
|
+
});
|
|
194
|
+
const prepare = () => {
|
|
195
|
+
const old = this.store.db
|
|
196
|
+
.prepare('SELECT * FROM child_tasks WHERE parent_session=? AND step_key=?')
|
|
197
|
+
.get(parent, step);
|
|
198
|
+
if (old) {
|
|
199
|
+
if (old.input_digest !== input)
|
|
200
|
+
throw new ServiceError('spawn_conflict', 'Critic step already binds a different review');
|
|
201
|
+
return { child: old, fresh: false };
|
|
202
|
+
}
|
|
203
|
+
if (this.paused)
|
|
204
|
+
throw new ServiceError('stopping', 'Child dispatch is stopping');
|
|
205
|
+
this.store.execution.assert(parent);
|
|
206
|
+
if (this.activeCount() >= 8)
|
|
207
|
+
throw new ServiceError('child_limit', 'Daemon worker admission limit reached');
|
|
208
|
+
const effectivePrompt = `${grant.profile.prompt}\n\nGoal:\n${prompt}`;
|
|
209
|
+
const id = randomUUID(), session = randomUUID(), submission = randomUUID(), childAttempt = randomUUID();
|
|
210
|
+
this.store.db
|
|
211
|
+
.prepare('INSERT INTO sessions(id,cwd,pi_session_id) VALUES (?,?,?)')
|
|
212
|
+
.run(session, this.store.session(parent).cwd, session);
|
|
213
|
+
this.store.db
|
|
214
|
+
.prepare('INSERT INTO submissions(id,session_id,text,attempt_id) VALUES (?,?,?,?)')
|
|
215
|
+
.run(submission, session, effectivePrompt, childAttempt);
|
|
216
|
+
this.store.db
|
|
217
|
+
.prepare("INSERT INTO attempts(id,session_id,submission_id,state) VALUES (?,?,?,'accepted')")
|
|
218
|
+
.run(childAttempt, session, submission);
|
|
219
|
+
this.store.db
|
|
220
|
+
.prepare("INSERT INTO child_tasks(id,parent_session,parent_attempt,step_key,input_digest,child_session,child_attempt,authority,budget,state) VALUES (?,?,?,?,?,?,?,?,?,'accepted')")
|
|
221
|
+
.run(id, parent, parentAttempt, step, input, session, childAttempt, JSON.stringify(authority), budget);
|
|
222
|
+
this.store.taskChildReserve?.(parent, childAttempt, budget);
|
|
223
|
+
this.store.usage.budget(childAttempt, budget);
|
|
224
|
+
const artifact = this.artifacts.prepare(session, effectivePrompt);
|
|
225
|
+
artifact.commit();
|
|
226
|
+
// The reviewed snapshot is the verified bytes; its base is empty, so its diff is all of it.
|
|
227
|
+
const current = this.artifacts.prepare(session, JSON.stringify(snapshot)), base = this.artifacts.prepare(session, JSON.stringify({}));
|
|
228
|
+
current.commit();
|
|
229
|
+
base.commit();
|
|
230
|
+
this.store.workerBoundary.freeze(this.owned(session), artifact.id, false, grant, {
|
|
231
|
+
artifact_id: current.id,
|
|
232
|
+
base_artifact: base.id,
|
|
233
|
+
digest: digest(snapshot),
|
|
234
|
+
});
|
|
235
|
+
this.store.event(session, 'session.created', { parent_session: parent, child_id: id });
|
|
236
|
+
this.store.event(session, 'submission.accepted', {
|
|
237
|
+
attempt_id: childAttempt,
|
|
238
|
+
submission_id: submission,
|
|
239
|
+
});
|
|
240
|
+
this.store.event(parent, 'child.spawned', {
|
|
241
|
+
child_id: id,
|
|
242
|
+
child_session: session,
|
|
243
|
+
step_key: step,
|
|
244
|
+
budget,
|
|
245
|
+
authority,
|
|
246
|
+
critic: true,
|
|
247
|
+
});
|
|
248
|
+
return { child: this.inspect(parent, id), fresh: true };
|
|
249
|
+
};
|
|
250
|
+
const { child, fresh } = this.store.transaction(prepare);
|
|
251
|
+
if (fresh)
|
|
252
|
+
this.start(child);
|
|
253
|
+
return child;
|
|
254
|
+
}
|
|
172
255
|
activeCount(parent) {
|
|
173
256
|
return Number(this.store.db
|
|
174
257
|
.prepare(`SELECT count(*) AS n FROM child_tasks c WHERE (? IS NULL OR c.parent_session=?) AND (c.state='accepted' OR EXISTS(SELECT 1 FROM workers w WHERE w.session_id=c.child_session AND w.state!='exited') OR EXISTS(SELECT 1 FROM external_runs e WHERE e.child_id=c.id AND e.stopped=0))`)
|