@kasenri/dsh-orbit 0.5.1
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/LICENSE +21 -0
- package/README.md +220 -0
- package/cordis.patch.yml +9 -0
- package/lib/activation.js +110 -0
- package/lib/client.js +625 -0
- package/lib/decisions.js +167 -0
- package/lib/dsh-host.js +364 -0
- package/lib/evidence.js +149 -0
- package/lib/guard.js +157 -0
- package/lib/host.js +1 -0
- package/lib/index.js +138 -0
- package/lib/kernel.js +355 -0
- package/lib/pipeline-guard.js +75 -0
- package/lib/routes.js +52 -0
- package/lib/sanitize.js +39 -0
- package/lib/service.js +104 -0
- package/lib/settlement.js +33 -0
- package/lib/state-store.js +128 -0
- package/lib/supervisor.js +792 -0
- package/lib/tool.js +82 -0
- package/lib/types.js +19 -0
- package/package.json +74 -0
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { guardBashCommand, guardReason, guardToolPath } from "./guard.js";
|
|
2
|
+
const WRITE_PATH_TOOLS = new Set(['write', 'edit', 'str_replace_editor']);
|
|
3
|
+
/** Top-level autonomous mutation drivers that must not run beside an active Orbit run. */
|
|
4
|
+
export const MUTATION_DRIVER_TOOLS = new Set(['create_goal', 'ralph', 'workflow']);
|
|
5
|
+
/** Canonical and legacy controller tool names. */
|
|
6
|
+
export const ORBIT_CONTROLLER_TOOLS = new Set(['orbit_controller', 'cx_controller']);
|
|
7
|
+
/**
|
|
8
|
+
* The Orbit recoverable tool guard plus mutation-driver mutual exclusion.
|
|
9
|
+
*
|
|
10
|
+
* Recovery contract: a denial blocks only this one tool call; the agent turn and
|
|
11
|
+
* the Orbit phase continue. Mutation exclusion, by contrast, is an ownership fence:
|
|
12
|
+
* Orbit and another top-level autonomous driver must never run in the same project.
|
|
13
|
+
*/
|
|
14
|
+
export function createOrbitPreExecuteHandler(service, options = {}) {
|
|
15
|
+
return async function orbitPreExecute(exec, next) {
|
|
16
|
+
const cwd = exec.agent?.session?.header?.cwd ?? process.cwd();
|
|
17
|
+
const active = service.hasActiveRun(cwd);
|
|
18
|
+
const args = (exec.arguments ?? {});
|
|
19
|
+
// Orbit owns the workspace: refuse to start another top-level mutation driver.
|
|
20
|
+
if (active && MUTATION_DRIVER_TOOLS.has(exec.name)) {
|
|
21
|
+
return {
|
|
22
|
+
kind: 'deny',
|
|
23
|
+
reason: `ORBIT_MUTATION_DRIVER_CONFLICT: an active Orbit run owns this workspace, so ${exec.name} must not start. ` +
|
|
24
|
+
'Resume or stop the Orbit run first, or continue through orbit_controller.',
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
// Another driver already owns the workspace: refuse to start Orbit.
|
|
28
|
+
if (!active && ORBIT_CONTROLLER_TOOLS.has(exec.name)) {
|
|
29
|
+
const action = typeof args['action'] === 'string' ? args['action'] : '';
|
|
30
|
+
if (action === 'run' || action === 'start') {
|
|
31
|
+
const competing = options.competingDriver?.(exec.agent);
|
|
32
|
+
if (competing) {
|
|
33
|
+
return {
|
|
34
|
+
kind: 'deny',
|
|
35
|
+
reason: `ORBIT_MUTATION_DRIVER_CONFLICT: ${competing} already owns mutation in this workspace; stop it before starting Orbit.`,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
if (!active)
|
|
41
|
+
return next();
|
|
42
|
+
if (exec.name === 'bash') {
|
|
43
|
+
const command = typeof args['command'] === 'string' ? args['command'] : '';
|
|
44
|
+
const decision = guardBashCommand(command, { github_allowed: service.githubAllowed(cwd) });
|
|
45
|
+
if (!decision.allowed) {
|
|
46
|
+
const outcome = await service.recordGuardBlock(decision.code, decision.reason, cwd);
|
|
47
|
+
return { kind: 'deny', reason: `${guardReason(decision)} ${outcome.instruction}` };
|
|
48
|
+
}
|
|
49
|
+
return next();
|
|
50
|
+
}
|
|
51
|
+
if (WRITE_PATH_TOOLS.has(exec.name)) {
|
|
52
|
+
const target = args['path'] ?? args['file_path'] ?? args['filePath'];
|
|
53
|
+
const decision = guardToolPath(exec.name, target, { cwd });
|
|
54
|
+
if (!decision.allowed) {
|
|
55
|
+
const outcome = await service.recordGuardBlock(decision.code, decision.reason, cwd);
|
|
56
|
+
return { kind: 'deny', reason: `${guardReason(decision)} ${outcome.instruction}` };
|
|
57
|
+
}
|
|
58
|
+
return next();
|
|
59
|
+
}
|
|
60
|
+
// The browser tool can write its structured result to a caller path; route
|
|
61
|
+
// that path through the same durable-state policy instead of a second copy.
|
|
62
|
+
if (exec.name === 'agent_browser') {
|
|
63
|
+
const outputPath = args['outputPath'];
|
|
64
|
+
if (typeof outputPath === 'string' && outputPath.length > 0) {
|
|
65
|
+
const decision = guardToolPath('write', outputPath, { cwd });
|
|
66
|
+
if (!decision.allowed) {
|
|
67
|
+
const outcome = await service.recordGuardBlock(decision.code, decision.reason, cwd);
|
|
68
|
+
return { kind: 'deny', reason: `${guardReason(decision)} ${outcome.instruction}` };
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return next();
|
|
72
|
+
}
|
|
73
|
+
return next();
|
|
74
|
+
};
|
|
75
|
+
}
|
package/lib/routes.js
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Effective route resolution for a new Orbit run.
|
|
3
|
+
*
|
|
4
|
+
* Orbit stores only Commander and Watchdog itself (`orbit` settings namespace,
|
|
5
|
+
* falling back to the composition `config.routes`). The Executor follows the
|
|
6
|
+
* initiating Session's current model selection, read through the public
|
|
7
|
+
* request-header seam, and falls back to `config.routes.executor` on surfaces
|
|
8
|
+
* without one (headless, CLI, minimal profiles, tests). Resolution happens
|
|
9
|
+
* exactly once per new run and is frozen into `state.routes`.
|
|
10
|
+
*/
|
|
11
|
+
/**
|
|
12
|
+
* Normalize one provider/model/effort candidate into a complete route.
|
|
13
|
+
* @param selection - candidate fields from settings, the session header, or tests.
|
|
14
|
+
* @returns the complete route, or undefined when provider/model are unusable.
|
|
15
|
+
*/
|
|
16
|
+
export function routeFromSelection(selection) {
|
|
17
|
+
if (selection === undefined)
|
|
18
|
+
return undefined;
|
|
19
|
+
const { provider, model, reasoningEffort } = selection;
|
|
20
|
+
if (typeof provider !== 'string' || provider === '')
|
|
21
|
+
return undefined;
|
|
22
|
+
if (typeof model !== 'string' || model === '')
|
|
23
|
+
return undefined;
|
|
24
|
+
return {
|
|
25
|
+
provider,
|
|
26
|
+
model,
|
|
27
|
+
...(typeof reasoningEffort === 'string' && reasoningEffort !== '' ? { reasoningEffort } : {}),
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Resolve the three role routes for a NEW run:
|
|
32
|
+
* Commander = settings (base = config) → config; Executor = session selection
|
|
33
|
+
* → config; Watchdog = settings (base = config) → config.
|
|
34
|
+
* @param input - config fallback, settings section, and session selection.
|
|
35
|
+
* @returns the complete frozen route set.
|
|
36
|
+
*/
|
|
37
|
+
export function resolveEffectiveRoutes(input) {
|
|
38
|
+
return {
|
|
39
|
+
commander: routeFromSelection(input.settings?.commander) ?? input.configRoutes.commander,
|
|
40
|
+
executor: routeFromSelection(input.sessionSelection) ?? input.configRoutes.executor,
|
|
41
|
+
watchdog: routeFromSelection(input.settings?.watchdog) ?? input.configRoutes.watchdog,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Read the initiating Session's current model selection from the public
|
|
46
|
+
* request-header seam (`Agent.session.requestHeader().config`).
|
|
47
|
+
* @param agent - initiating agent, or undefined outside a boundary.
|
|
48
|
+
* @returns the selection route, or undefined when no header exists.
|
|
49
|
+
*/
|
|
50
|
+
export function sessionSelectionOf(agent) {
|
|
51
|
+
return routeFromSelection(agent?.session?.requestHeader?.()?.config);
|
|
52
|
+
}
|
package/lib/sanitize.js
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/** Secret redaction shared by Orbit state, logs, and watchdog prompts. */
|
|
2
|
+
const SECRET_PATTERNS = [
|
|
3
|
+
/(authorization\s*:\s*(?:bearer\s+)?)[^\s,;]+/gi,
|
|
4
|
+
/((?:api[_-]?key|access[_-]?token|refresh[_-]?token|password|passwd|cookie|secret|private[_-]?key)\s*[=:]\s*)[^\s,;]+/gi,
|
|
5
|
+
/(\b(?:sk|pk|ghp|github_pat)_[A-Za-z0-9_-]{8,})/g,
|
|
6
|
+
/(\bBearer\s+)[A-Za-z0-9._-]+/gi,
|
|
7
|
+
];
|
|
8
|
+
const SENSITIVE_KEY = /^(?:password|passwd|cookie|secret|authorization|bearer|private[_-]?key|api[_-]?key|access[_-]?token|refresh[_-]?token|token|github[_-]?token|gh[_-]?token|credential)$/i;
|
|
9
|
+
export function redactText(value) {
|
|
10
|
+
let result = value;
|
|
11
|
+
for (const pattern of SECRET_PATTERNS)
|
|
12
|
+
result = result.replace(pattern, '$1[REDACTED]');
|
|
13
|
+
return result;
|
|
14
|
+
}
|
|
15
|
+
export function redactValue(value, key) {
|
|
16
|
+
if (key !== undefined && SENSITIVE_KEY.test(key))
|
|
17
|
+
return '[REDACTED]';
|
|
18
|
+
if (Array.isArray(value))
|
|
19
|
+
return value.map((item) => redactValue(item));
|
|
20
|
+
if (value !== null && typeof value === 'object') {
|
|
21
|
+
const out = {};
|
|
22
|
+
for (const [childKey, childValue] of Object.entries(value)) {
|
|
23
|
+
out[childKey] = redactValue(childValue, childKey);
|
|
24
|
+
}
|
|
25
|
+
return out;
|
|
26
|
+
}
|
|
27
|
+
if (typeof value === 'string')
|
|
28
|
+
return redactText(value);
|
|
29
|
+
return value;
|
|
30
|
+
}
|
|
31
|
+
export function truncateSafe(value, max = 4000) {
|
|
32
|
+
const redacted = redactText(value);
|
|
33
|
+
if (redacted.length <= max)
|
|
34
|
+
return redacted;
|
|
35
|
+
return `${redacted.slice(0, max)}\n...[truncated]`;
|
|
36
|
+
}
|
|
37
|
+
export function looksLikeSecretKey(key) {
|
|
38
|
+
return SENSITIVE_KEY.test(key);
|
|
39
|
+
}
|
package/lib/service.js
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { accessSync, constants, mkdirSync } from 'node:fs';
|
|
2
|
+
import { Service } from '@deepseek-ai/cordis';
|
|
3
|
+
import { DshOrbitHost } from "./dsh-host.js";
|
|
4
|
+
import { OrbitStateStore } from "./state-store.js";
|
|
5
|
+
import { OrbitSupervisor } from "./supervisor.js";
|
|
6
|
+
export class OrbitService extends Service {
|
|
7
|
+
host;
|
|
8
|
+
config;
|
|
9
|
+
constructor(ctx, config) {
|
|
10
|
+
super(ctx, 'orbit');
|
|
11
|
+
this.host = new DshOrbitHost(ctx);
|
|
12
|
+
this.config = config;
|
|
13
|
+
}
|
|
14
|
+
supervisorFor(projectDir) {
|
|
15
|
+
return new OrbitSupervisor(new OrbitStateStore(projectDir), this.host, {
|
|
16
|
+
defaultRoutes: this.config.routes,
|
|
17
|
+
...(this.config.resolveRoutes ? { resolveRoutes: this.config.resolveRoutes } : {}),
|
|
18
|
+
browserTools: this.config.browserTools,
|
|
19
|
+
commanderReadOnlyTools: this.config.commanderReadOnlyTools,
|
|
20
|
+
watchdogTools: this.config.watchdogTools,
|
|
21
|
+
executorTools: this.config.executorTools,
|
|
22
|
+
...(this.config.executorTimeoutMs ? { executorTimeoutMs: this.config.executorTimeoutMs } : {}),
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
resolveProjectDir(projectDir) {
|
|
26
|
+
return projectDir ?? this.config.projectDir ?? process.cwd();
|
|
27
|
+
}
|
|
28
|
+
run(input, projectDir, signal) {
|
|
29
|
+
return this.supervisorFor(this.resolveProjectDir(projectDir)).bootstrap(input, signal);
|
|
30
|
+
}
|
|
31
|
+
async resume(input, projectDir, signal) {
|
|
32
|
+
const dir = this.resolveProjectDir(projectDir);
|
|
33
|
+
const state = new OrbitStateStore(dir).readState();
|
|
34
|
+
if (!state)
|
|
35
|
+
return { ok: false, action: 'resume', message: 'ORBIT_RUN_NOT_FOUND: no durable run to resume.' };
|
|
36
|
+
const supervisor = this.supervisorFor(dir);
|
|
37
|
+
return supervisor.run(state, signal);
|
|
38
|
+
}
|
|
39
|
+
stop(runId, projectDir) {
|
|
40
|
+
return this.supervisorFor(this.resolveProjectDir(projectDir)).stop('stop', runId);
|
|
41
|
+
}
|
|
42
|
+
status(projectDir) {
|
|
43
|
+
return this.supervisorFor(this.resolveProjectDir(projectDir)).status();
|
|
44
|
+
}
|
|
45
|
+
recordGuardBlock(code, reason, projectDir) {
|
|
46
|
+
return this.supervisorFor(this.resolveProjectDir(projectDir)).recordGuardBlock(code, reason);
|
|
47
|
+
}
|
|
48
|
+
hasActiveRun(projectDir) {
|
|
49
|
+
const state = new OrbitStateStore(this.resolveProjectDir(projectDir)).readState();
|
|
50
|
+
return state !== null && state.driver_ownership !== 'CLOSED';
|
|
51
|
+
}
|
|
52
|
+
githubAllowed(projectDir) {
|
|
53
|
+
const state = new OrbitStateStore(this.resolveProjectDir(projectDir)).readState();
|
|
54
|
+
return state?.github_allowed === true;
|
|
55
|
+
}
|
|
56
|
+
async doctor(projectDir) {
|
|
57
|
+
const dir = this.resolveProjectDir(projectDir);
|
|
58
|
+
const checks = [];
|
|
59
|
+
try {
|
|
60
|
+
mkdirSync(`${dir}/.cx`, { recursive: true, mode: 0o700 });
|
|
61
|
+
accessSync(`${dir}/.cx`, constants.W_OK);
|
|
62
|
+
checks.push({ name: 'state-storage', status: 'pass', detail: `${dir}/.cx is writable` });
|
|
63
|
+
}
|
|
64
|
+
catch (error) {
|
|
65
|
+
checks.push({ name: 'state-storage', status: 'fail', detail: String(error) });
|
|
66
|
+
}
|
|
67
|
+
checks.push({ name: 'subagent-service', status: this.ctx.subagents ? 'pass' : 'fail', detail: this.ctx.subagents ? 'ctx.subagents available' : 'ctx.subagents missing' });
|
|
68
|
+
checks.push({ name: 'tools-service', status: this.ctx.tools ? 'pass' : 'fail', detail: this.ctx.tools ? 'ctx.tools available' : 'ctx.tools missing' });
|
|
69
|
+
const browserTool = this.config.browserTools[0] ?? 'agent_browser';
|
|
70
|
+
const browserAvailable = this.host.hasTool(browserTool);
|
|
71
|
+
checks.push({
|
|
72
|
+
name: 'browser-capability',
|
|
73
|
+
status: browserAvailable ? 'pass' : 'warn',
|
|
74
|
+
detail: browserAvailable ? `${browserTool} registered` : `${browserTool} unavailable; browser steps will fail with BROWSER_CAPABILITY_UNAVAILABLE`,
|
|
75
|
+
});
|
|
76
|
+
const executorRegistered = this.config.executorTools.filter((tool) => this.host.hasTool(tool));
|
|
77
|
+
checks.push({
|
|
78
|
+
name: 'executor-writer-scope',
|
|
79
|
+
status: executorRegistered.length > 0 ? 'pass' : 'fail',
|
|
80
|
+
detail: executorRegistered.length > 0 ? `executor allowlist: ${executorRegistered.join(', ')}` : 'no configured executor tool is registered',
|
|
81
|
+
});
|
|
82
|
+
const driverTools = ['create_goal', 'ralph', 'workflow'].filter((tool) => this.host.hasTool(tool));
|
|
83
|
+
checks.push({
|
|
84
|
+
name: 'mutation-driver-hook',
|
|
85
|
+
status: 'pass',
|
|
86
|
+
detail: driverTools.length > 0
|
|
87
|
+
? `Orbit mutation guard will deny ${driverTools.join(', ')} while a run is active`
|
|
88
|
+
: 'no top-level mutation driver tool is registered in this profile',
|
|
89
|
+
});
|
|
90
|
+
checks.push({
|
|
91
|
+
name: 'host-adapter-lifecycle',
|
|
92
|
+
status: 'pass',
|
|
93
|
+
detail: 'DshOrbitHost provides cancel/dispose/runtimeSnapshot for every role handle',
|
|
94
|
+
});
|
|
95
|
+
checks.push({
|
|
96
|
+
name: 'role-routes',
|
|
97
|
+
status: this.config.routes.commander.model && this.config.routes.executor.model ? 'pass' : 'fail',
|
|
98
|
+
detail: `commander=${this.config.routes.commander.model} executor=${this.config.routes.executor.model} watchdog=${this.config.routes.watchdog.model}`,
|
|
99
|
+
});
|
|
100
|
+
checks.push({ name: 'tested-dsh-version', status: 'pass', detail: 'tested against @deepseek-ai/dsh 0.1.5-rc.2' });
|
|
101
|
+
const status = checks.some((check) => check.status === 'fail') ? 'fail' : checks.some((check) => check.status === 'warn') ? 'warn' : 'pass';
|
|
102
|
+
return { status, generatedAt: new Date().toISOString(), checks };
|
|
103
|
+
}
|
|
104
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Settlement classification for Orbit executor/commander children.
|
|
3
|
+
*
|
|
4
|
+
* The durable seam is the session `turn/end` event: its `data.reason.kind` is
|
|
5
|
+
* `completed | aborted | error | blocked | max-tokens` (`interrupted` only
|
|
6
|
+
* appears on cold-read synthesis). A child that is merely `idle` is NOT success.
|
|
7
|
+
*/
|
|
8
|
+
export function classifyTurnSettlement(events) {
|
|
9
|
+
let end;
|
|
10
|
+
for (let index = events.length - 1; index >= 0; index -= 1) {
|
|
11
|
+
if (events[index]?.type === 'turn/end') {
|
|
12
|
+
end = events[index];
|
|
13
|
+
break;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
if (!end)
|
|
17
|
+
return { settlement: 'open' };
|
|
18
|
+
const reason = end.data?.reason;
|
|
19
|
+
switch (reason?.kind) {
|
|
20
|
+
case 'completed':
|
|
21
|
+
return { settlement: 'completed' };
|
|
22
|
+
case 'aborted':
|
|
23
|
+
return { settlement: 'aborted', ...(reason.reason?.kind ? { cancelCause: reason.reason.kind } : {}) };
|
|
24
|
+
case 'error':
|
|
25
|
+
return { settlement: 'error', ...(reason.error?.message ? { errorMessage: reason.error.message } : {}) };
|
|
26
|
+
case 'blocked':
|
|
27
|
+
return { settlement: 'blocked' };
|
|
28
|
+
case 'max-tokens':
|
|
29
|
+
return { settlement: 'max-tokens' };
|
|
30
|
+
default:
|
|
31
|
+
return { settlement: 'open' };
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { hostname } from 'node:os';
|
|
3
|
+
import { dirname, join } from 'node:path';
|
|
4
|
+
import { randomUUID } from 'node:crypto';
|
|
5
|
+
import { redactValue } from "./sanitize.js";
|
|
6
|
+
import { ORBIT_SCHEMA_VERSION } from "./types.js";
|
|
7
|
+
const LOCK_TIMEOUT_MS = 5_000;
|
|
8
|
+
const LOCK_STALE_MS = 30_000;
|
|
9
|
+
const LOCK_SPIN_MS = 20;
|
|
10
|
+
export function driverOwnershipFor(phase, status) {
|
|
11
|
+
if (['SUCCESS', 'STOPPED', 'BUDGET_EXHAUSTED'].includes(phase) || ['success', 'stopped', 'budget_exhausted'].includes(status)) {
|
|
12
|
+
return 'CLOSED';
|
|
13
|
+
}
|
|
14
|
+
return phase === 'NEEDS_USER' || status === 'needs_user' ? 'AWAITING_USER' : 'ACTIVE';
|
|
15
|
+
}
|
|
16
|
+
function nowIso() {
|
|
17
|
+
return new Date().toISOString();
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Project-scoped durable store for `.cx/state.json`.
|
|
21
|
+
* Atomic write + short-transaction directory lock + monotonic revision.
|
|
22
|
+
*/
|
|
23
|
+
export class OrbitStateStore {
|
|
24
|
+
stateDir;
|
|
25
|
+
lockDepth = 0;
|
|
26
|
+
constructor(projectDir) {
|
|
27
|
+
this.stateDir = join(projectDir, '.cx');
|
|
28
|
+
}
|
|
29
|
+
get statePath() {
|
|
30
|
+
return join(this.stateDir, 'state.json');
|
|
31
|
+
}
|
|
32
|
+
transact(operation) {
|
|
33
|
+
if (this.lockDepth > 0)
|
|
34
|
+
return operation();
|
|
35
|
+
mkdirSync(this.stateDir, { recursive: true, mode: 0o700 });
|
|
36
|
+
const lock = join(this.stateDir, 'controller.lock');
|
|
37
|
+
const deadline = Date.now() + LOCK_TIMEOUT_MS;
|
|
38
|
+
for (;;) {
|
|
39
|
+
try {
|
|
40
|
+
mkdirSync(lock, { mode: 0o700 });
|
|
41
|
+
writeFileSync(join(lock, 'owner.json'), JSON.stringify({ pid: process.pid, hostname: hostname(), acquired_at: nowIso() }), { mode: 0o600 });
|
|
42
|
+
break;
|
|
43
|
+
}
|
|
44
|
+
catch (error) {
|
|
45
|
+
if (error?.code !== 'EEXIST')
|
|
46
|
+
throw error;
|
|
47
|
+
if (isStale(lock) || Date.now() > deadline) {
|
|
48
|
+
if (Date.now() > deadline && !isStale(lock))
|
|
49
|
+
throw new Error('ORBIT_STATE_LOCK_TIMEOUT: another controller transaction is active');
|
|
50
|
+
rmSync(lock, { recursive: true, force: true });
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
sleepSync(LOCK_SPIN_MS);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
this.lockDepth += 1;
|
|
57
|
+
try {
|
|
58
|
+
return operation();
|
|
59
|
+
}
|
|
60
|
+
finally {
|
|
61
|
+
this.lockDepth -= 1;
|
|
62
|
+
rmSync(lock, { recursive: true, force: true });
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
readRawState() {
|
|
66
|
+
try {
|
|
67
|
+
const parsed = JSON.parse(readFileSync(this.statePath, 'utf8'));
|
|
68
|
+
if (parsed !== null && typeof parsed === 'object')
|
|
69
|
+
return parsed;
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
readState() {
|
|
77
|
+
const raw = this.readRawState();
|
|
78
|
+
if (raw === null)
|
|
79
|
+
return null;
|
|
80
|
+
return raw;
|
|
81
|
+
}
|
|
82
|
+
writeState(state) {
|
|
83
|
+
return this.transact(() => {
|
|
84
|
+
const current = this.readRawState();
|
|
85
|
+
const revision = Number(current?.['state_revision'] ?? 0) + 1;
|
|
86
|
+
const next = {
|
|
87
|
+
...state,
|
|
88
|
+
schema_version: ORBIT_SCHEMA_VERSION,
|
|
89
|
+
state_revision: revision,
|
|
90
|
+
driver_ownership: driverOwnershipFor(state.phase, state.status),
|
|
91
|
+
updated_at: nowIso(),
|
|
92
|
+
};
|
|
93
|
+
Object.assign(state, next);
|
|
94
|
+
this.writeJson(this.statePath, next);
|
|
95
|
+
return next;
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
writeJson(filePath, value) {
|
|
99
|
+
mkdirSync(dirname(filePath), { recursive: true, mode: 0o700 });
|
|
100
|
+
const temp = `${filePath}.${randomUUID()}.tmp`;
|
|
101
|
+
writeFileSync(temp, `${JSON.stringify(redactValue(value), null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
|
|
102
|
+
renameSync(temp, filePath);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
function isStale(lock) {
|
|
106
|
+
try {
|
|
107
|
+
const owner = JSON.parse(readFileSync(join(lock, 'owner.json'), 'utf8'));
|
|
108
|
+
if (typeof owner.pid === 'number') {
|
|
109
|
+
try {
|
|
110
|
+
process.kill(owner.pid, 0);
|
|
111
|
+
}
|
|
112
|
+
catch {
|
|
113
|
+
return true;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
const acquired = Date.parse(owner.acquired_at ?? '');
|
|
117
|
+
if (!Number.isNaN(acquired) && Date.now() - acquired > LOCK_STALE_MS)
|
|
118
|
+
return true;
|
|
119
|
+
statSync(lock);
|
|
120
|
+
return false;
|
|
121
|
+
}
|
|
122
|
+
catch {
|
|
123
|
+
return true;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
function sleepSync(ms) {
|
|
127
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
128
|
+
}
|