@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
package/src/station-work.ts
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import { spawn } from 'node:child_process';
|
|
2
|
+
import type { FileNow, LiveTree } from './station-checkpoints.js';
|
|
2
3
|
import {
|
|
3
4
|
mkdirSync,
|
|
4
5
|
mkdtempSync,
|
|
5
6
|
writeFileSync,
|
|
6
7
|
readFileSync,
|
|
8
|
+
readdirSync,
|
|
7
9
|
lstatSync,
|
|
8
10
|
rmSync,
|
|
9
11
|
chmodSync,
|
|
@@ -33,6 +35,7 @@ import {
|
|
|
33
35
|
type StationConfig,
|
|
34
36
|
} from './station-config.js';
|
|
35
37
|
import { stationFileResult } from './station-files.js';
|
|
38
|
+
import { type RunGrants, runGrantCovers, runGrantPrefix } from './run-grants.js';
|
|
36
39
|
import { StationSystemd, type StationUnit } from './station-systemd.js';
|
|
37
40
|
|
|
38
41
|
export interface StationBinding {
|
|
@@ -77,17 +80,40 @@ interface UnitRow {
|
|
|
77
80
|
stop_json: string | null;
|
|
78
81
|
}
|
|
79
82
|
const hashes = /^[a-f0-9]{64}$/;
|
|
80
|
-
class StationFilesDefiniteFailure extends ServiceError {
|
|
81
|
-
constructor() {
|
|
82
|
-
super(
|
|
83
|
+
export class StationFilesDefiniteFailure extends ServiceError {
|
|
84
|
+
constructor(code: string) {
|
|
85
|
+
super(code, 'Protected file primitive refused before mutation');
|
|
83
86
|
}
|
|
84
87
|
}
|
|
88
|
+
/** A helper refusal that provably changed nothing, as the code recorded for it, or null. The
|
|
89
|
+
* helper writes station_request_invalid as exactly {version, ok, code} and only while decoding,
|
|
90
|
+
* before it executes anything; any other shape (an effect_state, extra fields) is not definite. */
|
|
91
|
+
export function definiteHelperRefusal(
|
|
92
|
+
exitCode: number | null,
|
|
93
|
+
transportFailed: boolean,
|
|
94
|
+
result: Record<string, unknown>,
|
|
95
|
+
): string | null {
|
|
96
|
+
if (transportFailed || exitCode !== 1 || result.version !== 1 || result.ok !== false) return null;
|
|
97
|
+
if (result.code === 'station_files_unavailable' && result.effect_state === 'not_started')
|
|
98
|
+
return 'station_files_changed';
|
|
99
|
+
if (
|
|
100
|
+
result.code === 'station_request_invalid' &&
|
|
101
|
+
Object.keys(result).sort().join(',') === 'code,ok,version'
|
|
102
|
+
)
|
|
103
|
+
return 'station_request_invalid';
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
85
106
|
const terminal = (s: string) => ['returned', 'failed', 'outcome_unknown', 'cancelled'].includes(s);
|
|
86
107
|
|
|
87
108
|
/** Root-only station controller. Writable source access stays behind the private
|
|
88
109
|
* traversal-resistant node primitive; arbitrary checks get a separate OS identity,
|
|
89
110
|
* mount/root/network boundary and bounded, owned systemd cgroup. */
|
|
90
111
|
export class StationWork {
|
|
112
|
+
/** "Approve for this run" grants and the task each session's run belongs to (service wiring). */
|
|
113
|
+
grants?: RunGrants;
|
|
114
|
+
grantScope?: (
|
|
115
|
+
session: string,
|
|
116
|
+
) => { task_id: string; plan_revision: number; task_state: string } | null;
|
|
91
117
|
readonly config: StationConfig;
|
|
92
118
|
readonly configurationDigest: string;
|
|
93
119
|
private readonly directory: string;
|
|
@@ -290,10 +316,8 @@ export class StationWork {
|
|
|
290
316
|
spawned = true;
|
|
291
317
|
try {
|
|
292
318
|
if (id) {
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
else this.guard(c);
|
|
296
|
-
if (c.state !== 'dispatching') stationUnavailable();
|
|
319
|
+
// Own the process first: a guard that refuses below must still leave a pid and identity
|
|
320
|
+
// that a stop can prove exited (staging card_06a619a3e2e94faf left a pid-less helper).
|
|
297
321
|
const identity = processIdentity(child.pid!);
|
|
298
322
|
if (!identity) stationUnavailable();
|
|
299
323
|
this.store.transaction(() =>
|
|
@@ -303,6 +327,10 @@ export class StationWork {
|
|
|
303
327
|
)
|
|
304
328
|
.run(child.pid!, identity, id),
|
|
305
329
|
);
|
|
330
|
+
const c = this.get(id);
|
|
331
|
+
if (verificationGuard) verificationGuard();
|
|
332
|
+
else this.guard(c);
|
|
333
|
+
if (c.state !== 'dispatching') stationUnavailable();
|
|
306
334
|
}
|
|
307
335
|
child.stdin.end(
|
|
308
336
|
JSON.stringify({
|
|
@@ -365,15 +393,8 @@ export class StationWork {
|
|
|
365
393
|
if (!exited) stationUnavailable();
|
|
366
394
|
}
|
|
367
395
|
const result = object(JSON.parse(Buffer.concat(chunks).toString('utf8')));
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
code === 1 &&
|
|
371
|
-
result.version === 1 &&
|
|
372
|
-
result.ok === false &&
|
|
373
|
-
result.code === 'station_files_unavailable' &&
|
|
374
|
-
result.effect_state === 'not_started'
|
|
375
|
-
)
|
|
376
|
-
throw new StationFilesDefiniteFailure();
|
|
396
|
+
const refusal = definiteHelperRefusal(code, failed, result);
|
|
397
|
+
if (refusal) throw new StationFilesDefiniteFailure(refusal);
|
|
377
398
|
if (failed || code !== 0 || result.version !== 1 || result.ok !== true)
|
|
378
399
|
stationUnavailable();
|
|
379
400
|
accept(
|
|
@@ -396,12 +417,43 @@ export class StationWork {
|
|
|
396
417
|
return (error as NodeJS.ErrnoException).code !== 'ESRCH';
|
|
397
418
|
}
|
|
398
419
|
}
|
|
420
|
+
/** Whether no process on this host runs the station file helper, proven from every /proc entry. */
|
|
421
|
+
private noHelperProcess(): boolean {
|
|
422
|
+
if (process.platform !== 'linux') return false;
|
|
423
|
+
for (const entry of readdirSync('/proc')) {
|
|
424
|
+
if (!/^\d+$/.test(entry)) continue;
|
|
425
|
+
let argv: string[];
|
|
426
|
+
try {
|
|
427
|
+
argv = readFileSync(join('/proc', entry, 'cmdline'), 'utf8').split('\0');
|
|
428
|
+
} catch (error) {
|
|
429
|
+
if ((error as NodeJS.ErrnoException).code === 'ENOENT') continue;
|
|
430
|
+
return false;
|
|
431
|
+
}
|
|
432
|
+
if (argv.includes('salaam-station-files-v1')) return false;
|
|
433
|
+
}
|
|
434
|
+
return true;
|
|
435
|
+
}
|
|
399
436
|
private async stopHelper(id: string): Promise<void> {
|
|
400
437
|
const c = this.get(id);
|
|
401
438
|
if (!c.helper_state || c.helper_state === 'none' || c.helper_state === 'stopped') return;
|
|
402
439
|
this.files.get(id)?.abort();
|
|
403
440
|
try {
|
|
404
|
-
if (!c.helper_pid || !c.helper_identity)
|
|
441
|
+
if (!c.helper_pid || !c.helper_identity) {
|
|
442
|
+
// Recorded before the fix above: no pid to prove. Settle only when the whole process table
|
|
443
|
+
// holds no station helper; any live or unreadable process keeps the call fenced.
|
|
444
|
+
if (!this.noHelperProcess()) stationUnavailable();
|
|
445
|
+
this.store.transaction(() =>
|
|
446
|
+
this.store.db
|
|
447
|
+
.prepare(
|
|
448
|
+
"UPDATE station_calls SET helper_state='stopped',helper_stop_json=? WHERE id=?",
|
|
449
|
+
)
|
|
450
|
+
.run(
|
|
451
|
+
canonical({ version: 1, scope: 'host_process_table', method: 'no_helper_process' }),
|
|
452
|
+
id,
|
|
453
|
+
),
|
|
454
|
+
);
|
|
455
|
+
return;
|
|
456
|
+
}
|
|
405
457
|
if (this.processExists(c.helper_pid) || this.processExists(c.helper_pid, true)) {
|
|
406
458
|
if (processIdentity(c.helper_pid) !== c.helper_identity) stationUnavailable();
|
|
407
459
|
process.kill(-c.helper_pid, 'SIGKILL');
|
|
@@ -668,7 +720,7 @@ export class StationWork {
|
|
|
668
720
|
throw new ServiceError('invalid_field', 'Unsupported station mutation');
|
|
669
721
|
const allowed =
|
|
670
722
|
kind === 'patch'
|
|
671
|
-
? ['action', 'path', 'expected_tree', 'expected_hash', 'content']
|
|
723
|
+
? ['action', 'path', 'expected_tree', 'expected_hash', 'content', 'reason']
|
|
672
724
|
: ['action', 'expected_tree', 'command'];
|
|
673
725
|
if (
|
|
674
726
|
Object.keys(args).some((k) => !allowed.includes(k)) ||
|
|
@@ -781,8 +833,298 @@ export class StationWork {
|
|
|
781
833
|
input_digest: row.input_digest,
|
|
782
834
|
});
|
|
783
835
|
});
|
|
836
|
+
const prepared = this.get(id);
|
|
837
|
+
if (kind === 'patch' && prepared.operation_id)
|
|
838
|
+
this.answerFromGrant(prepared.operation_id, session, args);
|
|
784
839
|
return this.view(id);
|
|
785
840
|
}
|
|
841
|
+
/** A write the run's grant covers is answered by it, checked again at decision time. The
|
|
842
|
+
* operation is still prepared, digest-bound and journaled on its own; deletes always ask. */
|
|
843
|
+
/** An agent's standing "Always allow" rule (pinned at admission or added by a renewal, dropped at the renewal that no
|
|
844
|
+
* longer lists it) answers a workspace write anywhere in the workspace, never a delete or a
|
|
845
|
+
* hidden path. Checked again at decision time, so a rule revoked meanwhile answers nothing. */
|
|
846
|
+
private answerFromRule(operation: string, session: string, args: Record<string, unknown>): void {
|
|
847
|
+
if (!runGrantCovers('', args)) return;
|
|
848
|
+
const live = () =>
|
|
849
|
+
this.authority.approvalRules(session).find((r) => r.scope === 'workspace-write');
|
|
850
|
+
const rule = live();
|
|
851
|
+
if (!rule) return;
|
|
852
|
+
const approval = this.managed.forOperation(operation);
|
|
853
|
+
if (approval.state !== 'pending') return;
|
|
854
|
+
this.managed.decide(
|
|
855
|
+
approval.id,
|
|
856
|
+
'rule:' + operation,
|
|
857
|
+
approval.revision,
|
|
858
|
+
approval.operation_digest,
|
|
859
|
+
'approve',
|
|
860
|
+
{
|
|
861
|
+
decidedBy: 'rule:' + rule.id,
|
|
862
|
+
receipt: { rule: 'agent-rule', rule_id: rule.id, set_by: rule.set_by },
|
|
863
|
+
authorize: () => {
|
|
864
|
+
if (live()?.id !== rule.id)
|
|
865
|
+
throw new ServiceError(
|
|
866
|
+
'permission_changed',
|
|
867
|
+
'The approval rule no longer covers this write',
|
|
868
|
+
);
|
|
869
|
+
},
|
|
870
|
+
},
|
|
871
|
+
);
|
|
872
|
+
}
|
|
873
|
+
private answerFromGrant(operation: string, session: string, args: Record<string, unknown>): void {
|
|
874
|
+
if (!this.grants || !this.grantScope) return;
|
|
875
|
+
const binding = this.authority.binding(session) as { run?: { id?: unknown } } | null;
|
|
876
|
+
const prefix = runGrantPrefix(typeof binding?.run?.id === 'string' ? binding.run.id : '');
|
|
877
|
+
const live = () => {
|
|
878
|
+
const scope = this.grantScope!(session);
|
|
879
|
+
if (!scope || !prefix || !runGrantCovers(prefix, args)) return null;
|
|
880
|
+
const g = this.grants!.active({
|
|
881
|
+
session_id: session,
|
|
882
|
+
binding_digest: digest(binding),
|
|
883
|
+
...scope,
|
|
884
|
+
});
|
|
885
|
+
return g;
|
|
886
|
+
};
|
|
887
|
+
const grant = live();
|
|
888
|
+
if (!grant) return this.answerFromRule(operation, session, args);
|
|
889
|
+
const approval = this.managed.forOperation(operation);
|
|
890
|
+
if (approval.state !== 'pending') return;
|
|
891
|
+
this.managed.decide(
|
|
892
|
+
approval.id,
|
|
893
|
+
'run-grant:' + operation,
|
|
894
|
+
approval.revision,
|
|
895
|
+
approval.operation_digest,
|
|
896
|
+
'approve',
|
|
897
|
+
{
|
|
898
|
+
decidedBy: 'run-grant:' + grant.id,
|
|
899
|
+
receipt: {
|
|
900
|
+
rule: 'run-grant',
|
|
901
|
+
grant_id: grant.id,
|
|
902
|
+
granted_by: grant.granted_by,
|
|
903
|
+
granted_on: grant.approval_id,
|
|
904
|
+
},
|
|
905
|
+
authorize: () => {
|
|
906
|
+
if (live()?.id !== grant.id)
|
|
907
|
+
throw new ServiceError(
|
|
908
|
+
'permission_changed',
|
|
909
|
+
'The run grant no longer covers this write',
|
|
910
|
+
);
|
|
911
|
+
},
|
|
912
|
+
},
|
|
913
|
+
);
|
|
914
|
+
}
|
|
915
|
+
/** Item 11b: the restore journal. Each write is recorded before it is sent, so a daemon stop
|
|
916
|
+
* mid-restore leaves an honest unknown, never a silent effect. */
|
|
917
|
+
private restoreTables(): void {
|
|
918
|
+
this.store.db.exec(`CREATE TABLE IF NOT EXISTS station_restores(
|
|
919
|
+
id TEXT PRIMARY KEY NOT NULL, session_id TEXT NOT NULL, request_id TEXT NOT NULL UNIQUE,
|
|
920
|
+
restore_digest TEXT NOT NULL, approved_by TEXT NOT NULL, state TEXT NOT NULL, receipt TEXT);
|
|
921
|
+
CREATE TABLE IF NOT EXISTS station_restore_writes(
|
|
922
|
+
restore_id TEXT NOT NULL REFERENCES station_restores(id), n INTEGER NOT NULL, path TEXT NOT NULL,
|
|
923
|
+
args_json TEXT NOT NULL, state TEXT NOT NULL, result_json TEXT, artifact_id TEXT,
|
|
924
|
+
PRIMARY KEY(restore_id, n));`);
|
|
925
|
+
}
|
|
926
|
+
/** Item 11b: the live workspace through the helper's closed verbs (inspect, search, read). A
|
|
927
|
+
* path is absent only when an exact search finds no entry for it; a file the helper refuses to
|
|
928
|
+
* read (too large, not text) is unreadable, never guessed. */
|
|
929
|
+
async live(): Promise<LiveTree> {
|
|
930
|
+
const inspected = await this.helper({ action: 'inspect' });
|
|
931
|
+
return {
|
|
932
|
+
tree: String(inspected.tree_digest),
|
|
933
|
+
file: async (path: string): Promise<FileNow> => {
|
|
934
|
+
let offset: number | null = 0,
|
|
935
|
+
found = false;
|
|
936
|
+
while (offset !== null && !found) {
|
|
937
|
+
const page: Record<string, any> = await this.helper({
|
|
938
|
+
action: 'search',
|
|
939
|
+
query: path,
|
|
940
|
+
offset,
|
|
941
|
+
});
|
|
942
|
+
found = (page.entries as { path: string }[]).some((e) => e.path === path);
|
|
943
|
+
offset = page.next_offset ?? null;
|
|
944
|
+
}
|
|
945
|
+
if (!found) return { state: 'absent' };
|
|
946
|
+
try {
|
|
947
|
+
const read = await this.helper({ action: 'read', path });
|
|
948
|
+
return { state: 'content', content: String(read.content), sha256: String(read.sha256) };
|
|
949
|
+
} catch {
|
|
950
|
+
return { state: 'unreadable' };
|
|
951
|
+
}
|
|
952
|
+
},
|
|
953
|
+
};
|
|
954
|
+
}
|
|
955
|
+
/** Item 11b: the journal's answer for a restore request already seen, or null for a new one.
|
|
956
|
+
* A replay returns the saved receipt exactly; the same request for another restore is a
|
|
957
|
+
* conflict; an unfinished one says so. Checked before any plan is recomputed. */
|
|
958
|
+
replayed(session: string, request: string, restoreDigest: string): unknown | null {
|
|
959
|
+
this.restoreTables();
|
|
960
|
+
const saved = this.store.db
|
|
961
|
+
.prepare('SELECT * FROM station_restores WHERE request_id=?')
|
|
962
|
+
.get(request);
|
|
963
|
+
if (!saved) return null;
|
|
964
|
+
if (saved.restore_digest !== restoreDigest || saved.session_id !== session)
|
|
965
|
+
throw new ServiceError(
|
|
966
|
+
'request_conflict',
|
|
967
|
+
'This restore request was used for another restore',
|
|
968
|
+
);
|
|
969
|
+
if (saved.receipt) return JSON.parse(String(saved.receipt));
|
|
970
|
+
throw new ServiceError('restore_unconfirmed', 'This restore did not finish; review it again');
|
|
971
|
+
}
|
|
972
|
+
/** At start: a restore write that was in flight has an unknown outcome; its restore is partial. */
|
|
973
|
+
recoverRestores(): void {
|
|
974
|
+
this.restoreTables();
|
|
975
|
+
this.store.transaction(() => {
|
|
976
|
+
for (const w of this.store.db
|
|
977
|
+
.prepare("SELECT restore_id,n FROM station_restore_writes WHERE state='dispatching'")
|
|
978
|
+
.all()) {
|
|
979
|
+
this.store.db
|
|
980
|
+
.prepare(
|
|
981
|
+
"UPDATE station_restore_writes SET state='outcome_unknown' WHERE restore_id=? AND n=?",
|
|
982
|
+
)
|
|
983
|
+
.run(String(w.restore_id), Number(w.n));
|
|
984
|
+
this.store.db
|
|
985
|
+
.prepare("UPDATE station_restores SET state='partial' WHERE id=?")
|
|
986
|
+
.run(String(w.restore_id));
|
|
987
|
+
}
|
|
988
|
+
});
|
|
989
|
+
}
|
|
990
|
+
/**
|
|
991
|
+
* Item 11b: restore files exactly as planned and approved, after the run has ended. The caller
|
|
992
|
+
* checks the ended run and the approval; this writes. It refuses a workspace whose live tree is
|
|
993
|
+
* not the reviewed one, then sends each write through the protected helper in order, chaining
|
|
994
|
+
* the expected tree and each file's current hash, with a receipt per file. The same request
|
|
995
|
+
* replays its receipt. A failure stops the restore; nothing is retried.
|
|
996
|
+
*/
|
|
997
|
+
async restore(
|
|
998
|
+
session: string,
|
|
999
|
+
rootDigest: string,
|
|
1000
|
+
plan: {
|
|
1001
|
+
restore_digest: string;
|
|
1002
|
+
current_tree: string;
|
|
1003
|
+
writes: { path: string; content: string | null; expected_hash: string | null }[];
|
|
1004
|
+
},
|
|
1005
|
+
request: string,
|
|
1006
|
+
approvedBy: string,
|
|
1007
|
+
): Promise<unknown> {
|
|
1008
|
+
this.restoreTables();
|
|
1009
|
+
const saved = this.store.db
|
|
1010
|
+
.prepare('SELECT * FROM station_restores WHERE request_id=?')
|
|
1011
|
+
.get(request);
|
|
1012
|
+
if (saved) {
|
|
1013
|
+
if (saved.restore_digest !== plan.restore_digest || saved.session_id !== session)
|
|
1014
|
+
throw new ServiceError(
|
|
1015
|
+
'request_conflict',
|
|
1016
|
+
'This restore request was used for another restore',
|
|
1017
|
+
);
|
|
1018
|
+
if (saved.receipt) return JSON.parse(String(saved.receipt));
|
|
1019
|
+
throw new ServiceError('restore_unconfirmed', 'This restore did not finish; review it again');
|
|
1020
|
+
}
|
|
1021
|
+
const live = (await this.helper({ action: 'inspect' })) as { tree_digest?: unknown };
|
|
1022
|
+
if (live.tree_digest !== plan.current_tree)
|
|
1023
|
+
throw new ServiceError(
|
|
1024
|
+
'restore_changed',
|
|
1025
|
+
'The workspace changed since this restore was reviewed',
|
|
1026
|
+
);
|
|
1027
|
+
const id = randomUUID();
|
|
1028
|
+
this.store.transaction(() =>
|
|
1029
|
+
this.store.db
|
|
1030
|
+
.prepare("INSERT INTO station_restores VALUES (?,?,?,?,?,'running',NULL)")
|
|
1031
|
+
.run(id, session, request, plan.restore_digest, approvedBy),
|
|
1032
|
+
);
|
|
1033
|
+
let tree = plan.current_tree;
|
|
1034
|
+
const done: { path: string; artifact_id: string }[] = [];
|
|
1035
|
+
let stopped: string | null = null;
|
|
1036
|
+
for (const [n, w] of plan.writes.entries()) {
|
|
1037
|
+
const args = {
|
|
1038
|
+
action: 'patch',
|
|
1039
|
+
path: w.path,
|
|
1040
|
+
expected_tree: tree,
|
|
1041
|
+
expected_hash: w.expected_hash,
|
|
1042
|
+
content: w.content,
|
|
1043
|
+
// The helper accepts only a 64-hex request id: one per restore write, stable on replay.
|
|
1044
|
+
request_id: digest({ restore: id, write: n }),
|
|
1045
|
+
deadline_ms: Date.now() + 5000,
|
|
1046
|
+
};
|
|
1047
|
+
this.store.transaction(() =>
|
|
1048
|
+
this.store.db
|
|
1049
|
+
.prepare("INSERT INTO station_restore_writes VALUES (?,?,?,?,'dispatching',NULL,NULL)")
|
|
1050
|
+
.run(id, n, w.path, canonical(args)),
|
|
1051
|
+
);
|
|
1052
|
+
let result: Record<string, unknown>;
|
|
1053
|
+
try {
|
|
1054
|
+
result = (await this.helper(args)) as Record<string, unknown>;
|
|
1055
|
+
} catch (error) {
|
|
1056
|
+
const definite = error instanceof StationFilesDefiniteFailure;
|
|
1057
|
+
this.store.transaction(() =>
|
|
1058
|
+
this.store.db
|
|
1059
|
+
.prepare(
|
|
1060
|
+
'UPDATE station_restore_writes SET state=?,result_json=? WHERE restore_id=? AND n=?',
|
|
1061
|
+
)
|
|
1062
|
+
.run(
|
|
1063
|
+
definite ? 'failed' : 'outcome_unknown',
|
|
1064
|
+
canonical({
|
|
1065
|
+
code: definite ? (error as ServiceError).code : 'station_execution_unconfirmed',
|
|
1066
|
+
}),
|
|
1067
|
+
id,
|
|
1068
|
+
n,
|
|
1069
|
+
),
|
|
1070
|
+
);
|
|
1071
|
+
stopped = w.path;
|
|
1072
|
+
break;
|
|
1073
|
+
}
|
|
1074
|
+
const artifact = this.artifacts.prepare(
|
|
1075
|
+
session,
|
|
1076
|
+
canonical({
|
|
1077
|
+
version: 1,
|
|
1078
|
+
kind: 'station-restore-receipt',
|
|
1079
|
+
restore_id: id,
|
|
1080
|
+
request_id: request,
|
|
1081
|
+
restore_digest: plan.restore_digest,
|
|
1082
|
+
approved_by: approvedBy,
|
|
1083
|
+
session_id: session,
|
|
1084
|
+
root_digest: rootDigest,
|
|
1085
|
+
configuration_digest: this.configurationDigest,
|
|
1086
|
+
path: w.path,
|
|
1087
|
+
patch: {
|
|
1088
|
+
content: w.content,
|
|
1089
|
+
expected_tree: args.expected_tree,
|
|
1090
|
+
expected_hash: w.expected_hash,
|
|
1091
|
+
},
|
|
1092
|
+
result,
|
|
1093
|
+
}),
|
|
1094
|
+
);
|
|
1095
|
+
this.store.transaction(() => {
|
|
1096
|
+
artifact.commit();
|
|
1097
|
+
this.store.db
|
|
1098
|
+
.prepare(
|
|
1099
|
+
"UPDATE station_restore_writes SET state='returned',result_json=?,artifact_id=? WHERE restore_id=? AND n=?",
|
|
1100
|
+
)
|
|
1101
|
+
.run(canonical(result), artifact.id, id, n);
|
|
1102
|
+
});
|
|
1103
|
+
done.push({ path: w.path, artifact_id: artifact.id });
|
|
1104
|
+
tree = String(result.tree_digest);
|
|
1105
|
+
}
|
|
1106
|
+
const receipt = {
|
|
1107
|
+
version: 1 as const,
|
|
1108
|
+
state: stopped ? ('partial' as const) : ('restored' as const),
|
|
1109
|
+
restore_id: id,
|
|
1110
|
+
restore_digest: plan.restore_digest,
|
|
1111
|
+
tree_digest: tree,
|
|
1112
|
+
writes: done,
|
|
1113
|
+
...(stopped ? { stopped_at: stopped } : {}),
|
|
1114
|
+
};
|
|
1115
|
+
this.store.transaction(() => {
|
|
1116
|
+
this.store.db
|
|
1117
|
+
.prepare('UPDATE station_restores SET state=?,receipt=? WHERE id=?')
|
|
1118
|
+
.run(receipt.state, canonical(receipt), id);
|
|
1119
|
+
this.store.event(session, 'station.restored', {
|
|
1120
|
+
restore_id: id,
|
|
1121
|
+
restore_digest: plan.restore_digest,
|
|
1122
|
+
approved_by: approvedBy,
|
|
1123
|
+
state: receipt.state,
|
|
1124
|
+
});
|
|
1125
|
+
});
|
|
1126
|
+
return receipt;
|
|
1127
|
+
}
|
|
786
1128
|
run(id: string): unknown {
|
|
787
1129
|
const c = this.get(id);
|
|
788
1130
|
if (terminal(c.state) || this.jobs.has(id)) return this.view(id);
|
|
@@ -811,7 +1153,7 @@ export class StationWork {
|
|
|
811
1153
|
this.change(
|
|
812
1154
|
current,
|
|
813
1155
|
current.kind === 'patch' && !definite ? 'outcome_unknown' : 'failed',
|
|
814
|
-
{ code: definite ?
|
|
1156
|
+
{ code: definite ? error.code : 'station_execution_unconfirmed' },
|
|
815
1157
|
);
|
|
816
1158
|
if (current.operation_id) {
|
|
817
1159
|
if (definite)
|
|
@@ -843,7 +1185,13 @@ export class StationWork {
|
|
|
843
1185
|
if (c.kind === 'patch') {
|
|
844
1186
|
const result = await this.helper(
|
|
845
1187
|
{
|
|
846
|
-
|
|
1188
|
+
// Only the helper's own fields: it refuses any field it does not know (the agent's
|
|
1189
|
+
// reason is for the approval detail and stays in the operation's arguments).
|
|
1190
|
+
action: args.action,
|
|
1191
|
+
path: args.path,
|
|
1192
|
+
expected_tree: args.expected_tree,
|
|
1193
|
+
expected_hash: args.expected_hash,
|
|
1194
|
+
content: args.content,
|
|
847
1195
|
request_id: id,
|
|
848
1196
|
deadline_ms: Date.now() + this.authority.leaseWindow(c.session_id, c.root_digest, 5000),
|
|
849
1197
|
},
|
|
@@ -1067,6 +1415,7 @@ export class StationWork {
|
|
|
1067
1415
|
}
|
|
1068
1416
|
async initialize(): Promise<void> {
|
|
1069
1417
|
this.proven = false;
|
|
1418
|
+
this.recoverRestores();
|
|
1070
1419
|
for (const row of this.store.db
|
|
1071
1420
|
.prepare(
|
|
1072
1421
|
"SELECT id FROM station_calls WHERE helper_state NOT IN ('none','stopped') ORDER BY rowid",
|
package/src/store.ts
CHANGED
|
@@ -49,6 +49,8 @@ export class Store {
|
|
|
49
49
|
after: import('./workspaces.js').WorkspaceTree,
|
|
50
50
|
) => void;
|
|
51
51
|
taskGrantCheck?: (session: string) => void;
|
|
52
|
+
/** Provenance: the OS identity that local tool effects run as. */
|
|
53
|
+
toolExecution?: { mode: string; user: string; uid: number };
|
|
52
54
|
jobGrantCheck?: (session: string) => void;
|
|
53
55
|
taskModelAdmission?: (
|
|
54
56
|
session: string,
|
|
@@ -57,11 +59,15 @@ export class Store {
|
|
|
57
59
|
output: number,
|
|
58
60
|
) => void;
|
|
59
61
|
taskBudgetDenied?: (session: string, error: unknown) => void;
|
|
62
|
+
/** A durable task's own attempts are bounded by the task's request limit, not the attempt default. */
|
|
63
|
+
taskAttemptRequestLimit?: (session: string) => number | undefined;
|
|
60
64
|
taskChildReserve?: (session: string, attempt: string, count: number) => void;
|
|
61
65
|
taskChildFinished?: (attempt: string) => void;
|
|
62
66
|
taskCommandAdmission?: (session: string) => void;
|
|
63
67
|
taskRemainingRuntime?: (session: string) => number;
|
|
64
68
|
private committed: DurableEvent[] | null = null;
|
|
69
|
+
private readonly activity = new Map<string, number>();
|
|
70
|
+
readonly openedAt = Date.now();
|
|
65
71
|
constructor(path: string) {
|
|
66
72
|
this.db = new DatabaseSync(path);
|
|
67
73
|
if (path !== ':memory:') chmodSync(path, 0o600);
|
|
@@ -418,6 +424,8 @@ export class Store {
|
|
|
418
424
|
} finally {
|
|
419
425
|
this.committed = null;
|
|
420
426
|
}
|
|
427
|
+
const now = Date.now();
|
|
428
|
+
for (const event of events!) this.activity.set(event.session_id, now);
|
|
421
429
|
for (const event of events!) this.changes.emit('event', event);
|
|
422
430
|
return result!;
|
|
423
431
|
}
|
|
@@ -514,6 +522,7 @@ export class Store {
|
|
|
514
522
|
local_workspace: true,
|
|
515
523
|
local_coding: local.coding === true,
|
|
516
524
|
output_cap: local.outputCap ?? true,
|
|
525
|
+
...(this.toolExecution ? { tool_execution: this.toolExecution } : {}),
|
|
517
526
|
}
|
|
518
527
|
: {}),
|
|
519
528
|
},
|
|
@@ -563,8 +572,11 @@ export class Store {
|
|
|
563
572
|
expected: number,
|
|
564
573
|
controller: Controller,
|
|
565
574
|
clone: (source: Session, target: string) => string,
|
|
575
|
+
/** A rewind forks from an earlier conversation leaf. */
|
|
576
|
+
at?: { leaf: string | null },
|
|
566
577
|
): Session {
|
|
567
|
-
|
|
578
|
+
const input = { kind: 'fork', id, expected, ...(at ? { leaf: at.leaf } : {}) };
|
|
579
|
+
return this.request('local-operator', request, input, () => {
|
|
568
580
|
this.authorizeControl(id, controller);
|
|
569
581
|
const source = this.session(id);
|
|
570
582
|
if (source.revision !== expected)
|
|
@@ -589,7 +601,7 @@ export class Store {
|
|
|
589
601
|
.prepare(
|
|
590
602
|
'INSERT INTO sessions(id,cwd,pi_session_id,pi_session_file,pi_cursor,pi_leaf) VALUES(?,?,?,?,?,?)',
|
|
591
603
|
)
|
|
592
|
-
.run(target, source.cwd, target, file, source.pi_cursor, source.pi_leaf);
|
|
604
|
+
.run(target, source.cwd, target, file, source.pi_cursor, at ? at.leaf : source.pi_leaf);
|
|
593
605
|
this.event(
|
|
594
606
|
target,
|
|
595
607
|
'session.created',
|
|
@@ -598,6 +610,7 @@ export class Store {
|
|
|
598
610
|
local_workspace: true,
|
|
599
611
|
local_coding: this.localCoding(id),
|
|
600
612
|
output_cap: this.localOutputCap(id) ?? true,
|
|
613
|
+
...(this.toolExecution ? { tool_execution: this.toolExecution } : {}),
|
|
601
614
|
forked_from: id,
|
|
602
615
|
},
|
|
603
616
|
request,
|
|
@@ -613,7 +626,13 @@ export class Store {
|
|
|
613
626
|
this.event(
|
|
614
627
|
target,
|
|
615
628
|
'session.forked',
|
|
616
|
-
{
|
|
629
|
+
{
|
|
630
|
+
parent: id,
|
|
631
|
+
parent_revision: expected,
|
|
632
|
+
leaf: at ? at.leaf : source.pi_leaf,
|
|
633
|
+
cursor: source.pi_cursor,
|
|
634
|
+
...(at ? { rewound: true } : {}),
|
|
635
|
+
},
|
|
617
636
|
request,
|
|
618
637
|
);
|
|
619
638
|
return this.session(target);
|
|
@@ -637,6 +656,21 @@ export class Store {
|
|
|
637
656
|
return { name };
|
|
638
657
|
});
|
|
639
658
|
}
|
|
659
|
+
/** Wall-clock time of the last committed event for a session in this process, if any. */
|
|
660
|
+
lastActivity(id: string): number | null {
|
|
661
|
+
return this.activity.get(id) ?? null;
|
|
662
|
+
}
|
|
663
|
+
/** The newest committed event in this process, or the time the store opened. */
|
|
664
|
+
lastActivityAny(): number {
|
|
665
|
+
return Math.max(this.openedAt, ...this.activity.values());
|
|
666
|
+
}
|
|
667
|
+
/** The newest prompt submitted to a session, for the session picker. */
|
|
668
|
+
lastPrompt(id: string): string | null {
|
|
669
|
+
const row = this.db
|
|
670
|
+
.prepare('SELECT text FROM submissions WHERE session_id=? ORDER BY rowid DESC LIMIT 1')
|
|
671
|
+
.get(id) as { text: string } | undefined;
|
|
672
|
+
return row?.text ?? null;
|
|
673
|
+
}
|
|
640
674
|
sessions(): Session[] {
|
|
641
675
|
return (
|
|
642
676
|
this.db.prepare('SELECT id FROM sessions ORDER BY rowid').all() as { id: string }[]
|
|
@@ -702,6 +736,27 @@ export class Store {
|
|
|
702
736
|
}
|
|
703
737
|
});
|
|
704
738
|
}
|
|
739
|
+
/**
|
|
740
|
+
* The one sanctioned route change: an operator switching model or thinking level in an
|
|
741
|
+
* interactive session between turns. Recorded as provider.route_changed; the worker restarts on
|
|
742
|
+
* the same Pi transcript with the new route.
|
|
743
|
+
*/
|
|
744
|
+
switchRoute(id: string, route: ProviderRoute): { from: ProviderRoute; to: ProviderRoute } {
|
|
745
|
+
return this.transaction(() => {
|
|
746
|
+
const from = this.providerRoute(id);
|
|
747
|
+
if (!from || !this.localWorkspace(id))
|
|
748
|
+
throw new ServiceError(
|
|
749
|
+
'route_switch_unavailable',
|
|
750
|
+
'Only interactive sessions can switch model mid-session',
|
|
751
|
+
409,
|
|
752
|
+
);
|
|
753
|
+
this.db
|
|
754
|
+
.prepare('UPDATE session_routes SET route=? WHERE session_id=?')
|
|
755
|
+
.run(canonical(route), id);
|
|
756
|
+
this.event(id, 'provider.route_changed', { from, to: route });
|
|
757
|
+
return { from, to: route };
|
|
758
|
+
});
|
|
759
|
+
}
|
|
705
760
|
providerRoute(id: string): ProviderRoute | null {
|
|
706
761
|
const row = this.db.prepare('SELECT route FROM session_routes WHERE session_id=?').get(id) as
|
|
707
762
|
{ route: string } | undefined;
|
|
@@ -1149,6 +1204,9 @@ export class Store {
|
|
|
1149
1204
|
);
|
|
1150
1205
|
let remaining = 256 * 1024;
|
|
1151
1206
|
const messages: SessionSnapshot['messages'] = [];
|
|
1207
|
+
// Walking back from the newest entry: tool calls belong just before the message that follows
|
|
1208
|
+
// them, so a turn's calls are shown in place rather than gathered at the end.
|
|
1209
|
+
let trailing: string[] = [];
|
|
1152
1210
|
while (
|
|
1153
1211
|
cursor &&
|
|
1154
1212
|
!seen.has(cursor) &&
|
|
@@ -1163,6 +1221,14 @@ export class Store {
|
|
|
1163
1221
|
cursor = entry.parentId ?? null;
|
|
1164
1222
|
const message = entry.message;
|
|
1165
1223
|
if (!message || !['user', 'assistant'].includes(message.role)) continue;
|
|
1224
|
+
if (Array.isArray(message.content)) {
|
|
1225
|
+
const calls = message.content
|
|
1226
|
+
.filter((part: { type: string; id?: unknown }) => part.type === 'toolCall')
|
|
1227
|
+
.map((part: { id: unknown }) => String(part.id));
|
|
1228
|
+
const later = messages.at(-1);
|
|
1229
|
+
if (calls.length && later) later.tool_calls = [...calls, ...(later.tool_calls ?? [])];
|
|
1230
|
+
else if (calls.length) trailing = [...calls, ...trailing];
|
|
1231
|
+
}
|
|
1166
1232
|
const text =
|
|
1167
1233
|
typeof message.content === 'string'
|
|
1168
1234
|
? message.content
|
|
@@ -1194,6 +1260,7 @@ export class Store {
|
|
|
1194
1260
|
attempts: boundedAttempts,
|
|
1195
1261
|
submissions: boundedSubmissions,
|
|
1196
1262
|
messages: messages.reverse(),
|
|
1263
|
+
...(trailing.length ? { trailing_tool_calls: trailing } : {}),
|
|
1197
1264
|
history_truncated: cursor !== null,
|
|
1198
1265
|
approvals: this.approvals(id),
|
|
1199
1266
|
operations: this.operations(id),
|