@kylecheng3146/agent-ops 0.5.2 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +64 -0
- package/dist/packages/cli/src/args.js +214 -10
- package/dist/packages/cli/src/bin.js +77 -17
- package/dist/packages/cli/src/cli.js +42 -10
- package/dist/packages/cli/src/commands/advance.js +173 -0
- package/dist/packages/cli/src/commands/review-show.js +189 -0
- package/dist/packages/cli/src/commands/review.js +201 -34
- package/dist/packages/cli/src/commands/run.js +111 -0
- package/dist/packages/cli/src/commands/task.js +62 -17
- package/dist/packages/cli/src/commands/verify.js +1 -0
- package/dist/packages/cli/src/context.js +63 -1
- package/dist/packages/cli/src/hook-process.js +31 -1
- package/dist/packages/cli/src/owned-run-step.js +65 -0
- package/dist/packages/cli/src/parallel-deps.js +18 -5
- package/dist/packages/cli/src/run-deps.js +314 -0
- package/dist/packages/cli/src/run-entry.js +943 -0
- package/dist/packages/cli/src/run-setup-entry.js +32 -0
- package/dist/packages/cli/src/run-step-entry.js +58 -0
- package/dist/runtime/src/config/explain.js +6 -0
- package/dist/runtime/src/config/hash.js +1 -1
- package/dist/runtime/src/config/merge.js +9 -1
- package/dist/runtime/src/contracts.js +2 -2
- package/dist/runtime/src/hooks/completion-gate.js +8 -2
- package/dist/runtime/src/install/doctor.js +2 -0
- package/dist/runtime/src/install/harness.js +1 -1
- package/dist/runtime/src/install/preauth.js +10 -1
- package/dist/runtime/src/parallel/finish.js +217 -39
- package/dist/runtime/src/parallel/guard.js +5 -1
- package/dist/runtime/src/parallel/integrate.js +416 -0
- package/dist/runtime/src/parallel/manage.js +4 -1
- package/dist/runtime/src/parallel/receipt.js +153 -0
- package/dist/runtime/src/parallel/service.js +45 -3
- package/dist/runtime/src/review/attestation.js +60 -11
- package/dist/runtime/src/review/execute.js +24 -1
- package/dist/runtime/src/review/packet.js +35 -1
- package/dist/runtime/src/review/render.js +71 -0
- package/dist/runtime/src/review/runner.js +12 -1
- package/dist/runtime/src/review/scope.js +18 -1
- package/dist/runtime/src/review/slots.js +35 -10
- package/dist/runtime/src/run/authorization.js +60 -0
- package/dist/runtime/src/run/budget.js +30 -0
- package/dist/runtime/src/run/controls.js +139 -0
- package/dist/runtime/src/run/convergence.js +177 -0
- package/dist/runtime/src/run/hosts/claude.js +180 -0
- package/dist/runtime/src/run/hosts/codex.js +279 -0
- package/dist/runtime/src/run/hosts/index.js +4 -0
- package/dist/runtime/src/run/hosts/protocol.js +254 -0
- package/dist/runtime/src/run/hosts/types.js +1 -0
- package/dist/runtime/src/run/hosts/util.js +136 -0
- package/dist/runtime/src/run/integration.js +204 -0
- package/dist/runtime/src/run/macOS.js +189 -0
- package/dist/runtime/src/run/ownership.js +22 -0
- package/dist/runtime/src/run/planning.js +25 -0
- package/dist/runtime/src/run/policy-transition.js +1076 -0
- package/dist/runtime/src/run/policy.js +176 -0
- package/dist/runtime/src/run/ratchet.js +71 -0
- package/dist/runtime/src/run/scheduler.js +260 -0
- package/dist/runtime/src/run/service.js +425 -0
- package/dist/runtime/src/run/supervisor.js +635 -0
- package/dist/runtime/src/run/transport.js +159 -0
- package/dist/runtime/src/run/types.js +1 -0
- package/dist/runtime/src/run/usage.js +81 -0
- package/dist/runtime/src/run/verification.js +64 -0
- package/dist/runtime/src/run/worker-gate.js +48 -0
- package/dist/runtime/src/schema/acceptance.js +91 -0
- package/dist/runtime/src/schema/validate.js +72 -7
- package/dist/runtime/src/task/completion.js +26 -6
- package/dist/runtime/src/task/contract.js +17 -0
- package/dist/runtime/src/task/pin-finding.js +81 -0
- package/dist/runtime/src/task/render.js +3 -0
- package/dist/runtime/src/task/service.js +361 -10
- package/dist/runtime/src/task/store.js +57 -7
- package/dist/runtime/src/verify/acceptance-coverage.js +85 -0
- package/dist/runtime/src/verify/acceptance-protocol.js +252 -0
- package/dist/runtime/src/verify/acceptance-replay.js +239 -0
- package/dist/runtime/src/verify/adapters/index.js +5 -0
- package/dist/runtime/src/verify/adapters/jest.js +142 -0
- package/dist/runtime/src/verify/adapters/node-reporter.js +20 -0
- package/dist/runtime/src/verify/adapters/node.js +254 -0
- package/dist/runtime/src/verify/adapters/pytest.js +188 -0
- package/dist/runtime/src/verify/adapters/rust.js +84 -0
- package/dist/runtime/src/verify/adapters/shared.js +82 -0
- package/dist/runtime/src/verify/adapters/vitest.js +218 -0
- package/dist/runtime/src/verify/evidence.js +5 -2
- package/dist/runtime/src/verify/service.js +24 -8
- package/dist/runtime/src/verify/source-fingerprint.js +1 -1
- package/dist/runtime/src/verify/spawn.js +13 -2
- package/docs/en/guides/configuration.md +10 -0
- package/docs/en/spec/acceptance-and-evidence.md +58 -0
- package/docs/en/spec/delegation.md +4 -4
- package/docs/en/spec/loop-engineering.md +49 -0
- package/docs/en/spec/review.md +24 -2
- package/docs/zh-TW/guides/configuration.md +9 -0
- package/docs/zh-TW/spec/acceptance-and-evidence.md +31 -1
- package/docs/zh-TW/spec/delegation.md +4 -4
- package/docs/zh-TW/spec/loop-engineering.md +22 -0
- package/docs/zh-TW/spec/review.md +24 -2
- package/package.json +1 -1
- package/schemas/config.schema.json +221 -19
- package/schemas/evidence.schema.json +129 -6
- package/schemas/task.schema.json +268 -6
- package/templates/acceptance/agent_ops_acceptance.rs +36 -0
- package/templates/acceptance/agent_ops_pytest.py +46 -0
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { canonicalJson } from "../config/hash.js";
|
|
4
|
+
import { sha256 } from "../fs/hash.js";
|
|
5
|
+
import { AgentOpsError } from "../fs/paths.js";
|
|
6
|
+
import { readPrivateFile, writePrivateFile } from "../security/permissions.js";
|
|
7
|
+
import { FileRunRepository } from "./service.js";
|
|
8
|
+
function ledger(commonDir) { return new FileRunRepository(join(commonDir, "agent-ops", "runs"), commonDir); }
|
|
9
|
+
function proofPath(commonDir, runId) { return join(commonDir, "agent-ops", "runs", runId, "integration-proof.json"); }
|
|
10
|
+
function integrationIdentity(integration) {
|
|
11
|
+
return {
|
|
12
|
+
transactionId: integration.transactionId,
|
|
13
|
+
expectedTarget: integration.expectedTarget,
|
|
14
|
+
candidate: integration.candidate
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
/** The binding intentionally excludes receiptDigest to avoid a receipt/state cycle. */
|
|
18
|
+
export function integrationReceiptBindingDigest(integration) {
|
|
19
|
+
return sha256(canonicalJson(integrationIdentity(integration)));
|
|
20
|
+
}
|
|
21
|
+
export function integrationReceiptBinding(integration) {
|
|
22
|
+
return { ...integrationIdentity(integration), digest: integrationReceiptBindingDigest(integration) };
|
|
23
|
+
}
|
|
24
|
+
const plain = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
25
|
+
/** Validate the immutable run transaction fields embedded in a final receipt. */
|
|
26
|
+
export function validateRunIntegrationReceipt(integration, receipt) {
|
|
27
|
+
const binding = receipt.integrationJournal;
|
|
28
|
+
if (receipt.candidateHead !== integration.candidate || receipt.targetCommit !== integration.expectedTarget ||
|
|
29
|
+
!plain(binding) || binding.transactionId !== integration.transactionId ||
|
|
30
|
+
binding.expectedTarget !== integration.expectedTarget || binding.candidate !== integration.candidate ||
|
|
31
|
+
binding.digest !== integrationReceiptBindingDigest(integration)) {
|
|
32
|
+
throw new AgentOpsError("RUN_INTEGRATION_RECEIPT_INVALID", "The final receipt is not bound to the sealed integration transaction.");
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
async function readSealedProof(commonDir, runId, integration) {
|
|
36
|
+
const source = await readPrivateFile(proofPath(commonDir, runId), commonDir);
|
|
37
|
+
if (source === null || integration.proofDigest === undefined || sha256(source) !== integration.proofDigest) {
|
|
38
|
+
throw new AgentOpsError("RUN_INTEGRATION_PROOF_CHANGED", "The sealed integration proof is missing or changed.");
|
|
39
|
+
}
|
|
40
|
+
let value;
|
|
41
|
+
try {
|
|
42
|
+
value = JSON.parse(source);
|
|
43
|
+
}
|
|
44
|
+
catch (cause) {
|
|
45
|
+
throw new AgentOpsError("RUN_INTEGRATION_PROOF_CHANGED", "The sealed integration proof is not valid JSON.", { cause });
|
|
46
|
+
}
|
|
47
|
+
if (!plain(value) || value.target !== integration.expectedTarget || value.head !== integration.candidate) {
|
|
48
|
+
throw new AgentOpsError("RUN_INTEGRATION_PROOF_CHANGED", "Integration proof no longer matches the frozen target and candidate.");
|
|
49
|
+
}
|
|
50
|
+
return value;
|
|
51
|
+
}
|
|
52
|
+
/** Seal the exact final gate before any target mutation. Recovery cannot invent a new proof. */
|
|
53
|
+
export async function prepareRunIntegration(commonDir, record, proof) {
|
|
54
|
+
if (record.runId === undefined)
|
|
55
|
+
return;
|
|
56
|
+
const repository = ledger(commonDir);
|
|
57
|
+
const state = await repository.read(record.runId);
|
|
58
|
+
if (state === null || state.ownerSessionId !== record.sessionId || state.coordinatorId !== record.workerId ||
|
|
59
|
+
state.status !== "active" || state.disableRestart || state.workers.some(worker => ["assigned", "starting", "running", "idle", "handing-off"].includes(worker.status)))
|
|
60
|
+
throw new AgentOpsError("RUN_INTEGRATION_OWNERSHIP", "Final integration requires the stopped registered coordinator and current active run.");
|
|
61
|
+
if (state.integration !== null) {
|
|
62
|
+
if (state.integration.status !== "prepared")
|
|
63
|
+
throw new AgentOpsError("RUN_INTEGRATION_RECOVERY_REQUIRED", "Reconcile the existing integration before preparing another candidate.");
|
|
64
|
+
const sealed = await readSealedProof(commonDir, record.runId, state.integration);
|
|
65
|
+
if (canonicalJson(sealed) !== canonicalJson(proof))
|
|
66
|
+
throw new AgentOpsError("RUN_INTEGRATION_RECOVERY_REQUIRED", "The existing sealed integration cannot be replaced by a new proof.");
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
const source = JSON.stringify(proof);
|
|
70
|
+
await repository.mutate(record.runId, async (current) => {
|
|
71
|
+
if (current.status !== "active" || current.disableRestart || current.revision !== state.revision)
|
|
72
|
+
throw new AgentOpsError("RUN_INTEGRATION_STALE", "Run changed while sealing final proof.");
|
|
73
|
+
await writePrivateFile(proofPath(commonDir, record.runId), source, commonDir);
|
|
74
|
+
return { ...current, integration: { transactionId: randomUUID(), status: "prepared", expectedTarget: proof.target,
|
|
75
|
+
candidate: proof.head, proofDigest: sha256(source), completedSteps: [], receiptPath: null, receiptDigest: null,
|
|
76
|
+
updatedAt: new Date().toISOString(), childDeliveries: proof.children.map(child => child.commit) } };
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
export async function readRunIntegrationProof(commonDir, record) {
|
|
80
|
+
if (record.runId === undefined)
|
|
81
|
+
return null;
|
|
82
|
+
const state = await ledger(commonDir).read(record.runId);
|
|
83
|
+
if (state?.integration == null)
|
|
84
|
+
return null;
|
|
85
|
+
if (state.ownerSessionId !== record.sessionId || state.coordinatorId !== record.workerId)
|
|
86
|
+
throw new AgentOpsError("RUN_INTEGRATION_OWNERSHIP", "Recovery coordinator does not match the sealed transaction.");
|
|
87
|
+
return await readSealedProof(commonDir, record.runId, state.integration);
|
|
88
|
+
}
|
|
89
|
+
/** A failed pre-merge CAS may be retried only after archiving the unused sealed transaction. */
|
|
90
|
+
export async function abandonPreparedRunIntegration(commonDir, record, git) {
|
|
91
|
+
if (record.runId === undefined)
|
|
92
|
+
return;
|
|
93
|
+
const repository = ledger(commonDir);
|
|
94
|
+
const state = await repository.read(record.runId);
|
|
95
|
+
const transaction = state?.integration;
|
|
96
|
+
if (state === null || transaction == null)
|
|
97
|
+
return;
|
|
98
|
+
if (transaction.status !== "prepared" || state.ownerSessionId !== record.sessionId || state.coordinatorId !== record.workerId ||
|
|
99
|
+
state.workers.some(worker => ["assigned", "starting", "running", "idle", "handing-off"].includes(worker.status)))
|
|
100
|
+
throw new AgentOpsError("RUN_INTEGRATION_RECOVERY_REQUIRED", "Only a stopped, unused prepared transaction may be superseded.");
|
|
101
|
+
const target = await git(state.root, ["rev-parse", state.targetBranch + "^{commit}"]);
|
|
102
|
+
const commit = target.stdout.trim();
|
|
103
|
+
const ancestry = await git(state.root, ["merge-base", "--is-ancestor", transaction.candidate, commit]);
|
|
104
|
+
if (target.exitCode !== 0 || commit === transaction.expectedTarget || commit === transaction.candidate || ancestry.exitCode !== 1)
|
|
105
|
+
throw new AgentOpsError("RUN_INTEGRATION_RECOVERY_REQUIRED", "The old candidate may have reached the target; preserve and recover its sealed transaction.");
|
|
106
|
+
const proof = await readSealedProof(commonDir, record.runId, transaction);
|
|
107
|
+
await repository.mutate(record.runId, async (current) => {
|
|
108
|
+
const actual = await git(state.root, ["rev-parse", state.targetBranch + "^{commit}"]);
|
|
109
|
+
if (current.revision !== state.revision || actual.exitCode !== 0 || actual.stdout.trim() !== commit)
|
|
110
|
+
throw new AgentOpsError("RUN_INTEGRATION_STALE", "Run or target changed while superseding an unused transaction.");
|
|
111
|
+
const path = join(commonDir, "agent-ops", "runs", record.runId, "integration-history", transaction.transactionId + ".json");
|
|
112
|
+
await writePrivateFile(path, JSON.stringify({ transaction, proof, observedTarget: commit }), commonDir);
|
|
113
|
+
return { ...current, integration: null };
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
/** Read the binding used by a finish receipt without trusting caller-supplied transaction fields. */
|
|
117
|
+
export async function readRunIntegrationReceiptBinding(commonDir, record, expected) {
|
|
118
|
+
if (record.runId === undefined)
|
|
119
|
+
return null;
|
|
120
|
+
const state = await ledger(commonDir).read(record.runId);
|
|
121
|
+
if (state === null || state.integration === null)
|
|
122
|
+
throw new AgentOpsError("RUN_INTEGRATION_REQUIRED", "A run-owned final receipt requires a sealed integration transaction.");
|
|
123
|
+
if (state.ownerSessionId !== record.sessionId || state.coordinatorId !== record.workerId)
|
|
124
|
+
throw new AgentOpsError("RUN_INTEGRATION_OWNERSHIP", "The integration transaction belongs to another coordinator.");
|
|
125
|
+
await readSealedProof(commonDir, record.runId, state.integration);
|
|
126
|
+
if (state.integration.expectedTarget !== expected.target || state.integration.candidate !== expected.candidate)
|
|
127
|
+
throw new AgentOpsError("RUN_INTEGRATION_PROOF_CHANGED", "The receipt candidate does not match the sealed integration transaction.");
|
|
128
|
+
return integrationReceiptBinding(state.integration);
|
|
129
|
+
}
|
|
130
|
+
export async function markRunIntegration(commonDir, record, status, step, receipt) {
|
|
131
|
+
if (record.runId === undefined)
|
|
132
|
+
return;
|
|
133
|
+
await ledger(commonDir).mutate(record.runId, current => {
|
|
134
|
+
if (current.integration === null || current.ownerSessionId !== record.sessionId || current.coordinatorId !== record.workerId)
|
|
135
|
+
throw new AgentOpsError("RUN_INTEGRATION_OWNERSHIP", "The integration transaction is missing or belongs to another coordinator.");
|
|
136
|
+
return { ...current, integration: { ...current.integration, status, updatedAt: new Date().toISOString(),
|
|
137
|
+
completedSteps: [...new Set([...current.integration.completedSteps, step])],
|
|
138
|
+
...(receipt === undefined ? {} : { receiptPath: receipt.path, receiptDigest: receipt.digest }) } };
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Finish bookkeeping after a coordinator checkout vanished. This only verifies
|
|
143
|
+
* sealed artifacts and advances the ledger; it never recreates a checkout or
|
|
144
|
+
* starts a native writer.
|
|
145
|
+
*/
|
|
146
|
+
export async function recoverRunIntegrationAfterCleanup(commonDir, runId, git) {
|
|
147
|
+
const repository = ledger(commonDir);
|
|
148
|
+
const state = await repository.read(runId);
|
|
149
|
+
if (state === null || state.integration === null)
|
|
150
|
+
throw new AgentOpsError("RUN_INTEGRATION_REQUIRED", "There is no sealed integration to recover.");
|
|
151
|
+
const integration = state.integration;
|
|
152
|
+
if (state.workers.some(worker => ["assigned", "starting", "running", "idle", "handing-off"].includes(worker.status)))
|
|
153
|
+
throw new AgentOpsError("RUN_INTEGRATION_RECOVERY_REQUIRED", "Integration cleanup cannot be recovered while a writer is active.");
|
|
154
|
+
await readSealedProof(commonDir, runId, integration);
|
|
155
|
+
if (integration.status !== "cleaned" &&
|
|
156
|
+
(integration.status !== "receipt-written" || !integration.completedSteps.includes("receipt") ||
|
|
157
|
+
!integration.completedSteps.includes("note"))) {
|
|
158
|
+
throw new AgentOpsError("RUN_INTEGRATION_RECOVERY_REQUIRED", "Recovery requires a recorded receipt and Git note before cleanup can be completed.");
|
|
159
|
+
}
|
|
160
|
+
if (integration.receiptPath === null || integration.receiptDigest === null)
|
|
161
|
+
throw new AgentOpsError("RUN_INTEGRATION_RECOVERY_REQUIRED", "The integration has no sealed receipt.");
|
|
162
|
+
const source = await readPrivateFile(integration.receiptPath, commonDir);
|
|
163
|
+
if (source === null || sha256(source) !== integration.receiptDigest)
|
|
164
|
+
throw new AgentOpsError("RUN_INTEGRATION_RECEIPT_INVALID", "The recorded integration receipt is missing or changed.");
|
|
165
|
+
let receipt;
|
|
166
|
+
try {
|
|
167
|
+
const value = JSON.parse(source);
|
|
168
|
+
if (!plain(value))
|
|
169
|
+
throw new Error("receipt is not an object");
|
|
170
|
+
receipt = value;
|
|
171
|
+
}
|
|
172
|
+
catch (cause) {
|
|
173
|
+
throw new AgentOpsError("RUN_INTEGRATION_RECEIPT_INVALID", "The recorded integration receipt is not valid JSON.", { cause });
|
|
174
|
+
}
|
|
175
|
+
if (receipt.sessionId !== state.ownerSessionId)
|
|
176
|
+
throw new AgentOpsError("RUN_INTEGRATION_RECEIPT_INVALID", "The receipt belongs to another run owner.");
|
|
177
|
+
validateRunIntegrationReceipt(integration, receipt);
|
|
178
|
+
const note = await git(state.root, ["notes", "--ref=agent-ops", "show", integration.candidate]);
|
|
179
|
+
const noteText = note.stdout;
|
|
180
|
+
if (note.exitCode !== 0 || !noteText.includes(`session: ${state.ownerSessionId}`) ||
|
|
181
|
+
!noteText.includes(`receipt: ${integration.receiptPath}`) ||
|
|
182
|
+
!noteText.includes(`receipt-sha256: ${integration.receiptDigest}`)) {
|
|
183
|
+
throw new AgentOpsError("RUN_INTEGRATION_RECOVERY_REQUIRED", "The sealed receipt is not bound by the Git note for the candidate.");
|
|
184
|
+
}
|
|
185
|
+
const target = await git(state.root, ["rev-parse", state.targetBranch + "^{commit}"]);
|
|
186
|
+
const exact = target.exitCode === 0 && target.stdout.trim() === integration.candidate;
|
|
187
|
+
const historic = !exact && target.exitCode === 0 && integration.completedSteps.includes("target") &&
|
|
188
|
+
integration.completedSteps.includes("receipt") && integration.completedSteps.includes("note") &&
|
|
189
|
+
(await git(state.root, ["merge-base", "--is-ancestor", integration.candidate, target.stdout.trim()])).exitCode === 0;
|
|
190
|
+
if (!exact && !historic)
|
|
191
|
+
throw new AgentOpsError("RUN_INTEGRATION_TARGET_CHANGED", "The target is inconsistent with the sealed, receipt-bound integration history.");
|
|
192
|
+
await repository.mutate(runId, current => {
|
|
193
|
+
if (current.integration === null || current.integration.transactionId !== integration.transactionId ||
|
|
194
|
+
current.integration.candidate !== integration.candidate || current.integration.expectedTarget !== integration.expectedTarget)
|
|
195
|
+
throw new AgentOpsError("RUN_INTEGRATION_STALE", "The integration changed during recovery.");
|
|
196
|
+
if (current.integration.status === "cleaned")
|
|
197
|
+
return current;
|
|
198
|
+
if (current.integration.status !== "receipt-written")
|
|
199
|
+
throw new AgentOpsError("RUN_INTEGRATION_RECOVERY_REQUIRED", "The integration is not at the cleanup recovery boundary.");
|
|
200
|
+
return { ...current, integration: { ...current.integration, status: "cleaned",
|
|
201
|
+
completedSteps: [...new Set([...current.integration.completedSteps, "coordinator"])], updatedAt: new Date().toISOString() } };
|
|
202
|
+
});
|
|
203
|
+
return { transactionId: integration.transactionId, receiptPath: integration.receiptPath, receiptDigest: integration.receiptDigest };
|
|
204
|
+
}
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import { execFile as execFileCallback } from "node:child_process";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { rm } from "node:fs/promises";
|
|
4
|
+
import { isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
5
|
+
import { promisify } from "node:util";
|
|
6
|
+
import { AgentOpsError } from "../fs/paths.js";
|
|
7
|
+
import { readPrivateFile, writePrivateFile } from "../security/permissions.js";
|
|
8
|
+
const execFile = promisify(execFileCallback);
|
|
9
|
+
function xml(value) {
|
|
10
|
+
return value.replace(/&/gu, "&").replace(/</gu, "<").replace(/>/gu, ">")
|
|
11
|
+
.replace(/"/gu, """).replace(/'/gu, "'");
|
|
12
|
+
}
|
|
13
|
+
function shellSafe(value) {
|
|
14
|
+
return value.replace(/[^A-Za-z0-9._-]/gu, "-").slice(0, 60) || "worker";
|
|
15
|
+
}
|
|
16
|
+
function assertPathInside(root, candidate) {
|
|
17
|
+
const canonicalRoot = resolve(root);
|
|
18
|
+
const canonicalCandidate = resolve(candidate);
|
|
19
|
+
const fromRoot = relative(canonicalRoot, canonicalCandidate);
|
|
20
|
+
if (fromRoot === ".." || fromRoot.startsWith(`..${sep}`) || isAbsolute(fromRoot)) {
|
|
21
|
+
throw new AgentOpsError("LAUNCHD_PATH_INVALID", "launchd transient state escaped its private directory.");
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
function defaultUid() {
|
|
25
|
+
if (typeof process.getuid !== "function") {
|
|
26
|
+
throw new AgentOpsError("LAUNCHD_UID_UNAVAILABLE", "launchd requires a macOS user id.");
|
|
27
|
+
}
|
|
28
|
+
return process.getuid();
|
|
29
|
+
}
|
|
30
|
+
function defaultExecFile(file, args, options) {
|
|
31
|
+
return execFile(file, [...args], options).then((result) => ({
|
|
32
|
+
stdout: result.stdout,
|
|
33
|
+
stderr: result.stderr
|
|
34
|
+
}));
|
|
35
|
+
}
|
|
36
|
+
export function createLaunchdDescriptor(input) {
|
|
37
|
+
if (input.runId.length === 0 || input.workerId.length === 0 || input.command.length === 0 || input.cwd.length === 0) {
|
|
38
|
+
throw new AgentOpsError("LAUNCHD_DESCRIPTOR_INVALID", "launchd descriptor fields cannot be empty.");
|
|
39
|
+
}
|
|
40
|
+
const privateDirectory = resolve(input.privateDirectory);
|
|
41
|
+
const label = `com.agent-ops.run.${shellSafe(input.runId)}.${shellSafe(input.workerId)}.${createHash("sha256").update(input.runId).digest("hex").slice(0, 8)}`;
|
|
42
|
+
const path = join(privateDirectory, `${label}.plist`);
|
|
43
|
+
const disabledPath = join(privateDirectory, `${label}.disabled.json`);
|
|
44
|
+
assertPathInside(privateDirectory, path);
|
|
45
|
+
assertPathInside(privateDirectory, disabledPath);
|
|
46
|
+
const uid = input.uid ?? (process.platform === "darwin" ? defaultUid() : 0);
|
|
47
|
+
if (!Number.isSafeInteger(uid) || uid < 0) {
|
|
48
|
+
throw new AgentOpsError("LAUNCHD_UID_INVALID", "launchd user id is invalid.");
|
|
49
|
+
}
|
|
50
|
+
const stdoutPath = input.stdoutPath ?? join(privateDirectory, `${label}.stdout.log`);
|
|
51
|
+
const stderrPath = input.stderrPath ?? join(privateDirectory, `${label}.stderr.log`);
|
|
52
|
+
assertPathInside(privateDirectory, stdoutPath);
|
|
53
|
+
assertPathInside(privateDirectory, stderrPath);
|
|
54
|
+
const argumentsXml = [input.command, ...input.args]
|
|
55
|
+
.map((arg) => ` <string>${xml(arg)}</string>`)
|
|
56
|
+
.join("\n");
|
|
57
|
+
const xmlDocument = `<?xml version="1.0" encoding="UTF-8"?>
|
|
58
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
59
|
+
<plist version="1.0">
|
|
60
|
+
<dict>
|
|
61
|
+
<key>Label</key><string>${xml(label)}</string>
|
|
62
|
+
<key>ProgramArguments</key>
|
|
63
|
+
<array>
|
|
64
|
+
${argumentsXml}
|
|
65
|
+
</array>
|
|
66
|
+
<key>WorkingDirectory</key><string>${xml(input.cwd)}</string>
|
|
67
|
+
${input.pathEnvironment === undefined ? "" : " <key>EnvironmentVariables</key><dict><key>PATH</key><string>" + xml(input.pathEnvironment) + "</string></dict>"}
|
|
68
|
+
<key>RunAtLoad</key><true/>
|
|
69
|
+
<key>KeepAlive</key>
|
|
70
|
+
<dict><key>SuccessfulExit</key><false/></dict>
|
|
71
|
+
<key>StandardOutPath</key><string>${xml(stdoutPath)}</string>
|
|
72
|
+
<key>StandardErrorPath</key><string>${xml(stderrPath)}</string>
|
|
73
|
+
</dict>
|
|
74
|
+
</plist>
|
|
75
|
+
`;
|
|
76
|
+
return {
|
|
77
|
+
label,
|
|
78
|
+
domain: `gui/${uid}`,
|
|
79
|
+
path,
|
|
80
|
+
privateDirectory,
|
|
81
|
+
disabledPath,
|
|
82
|
+
xml: xmlDocument
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
export function compareBootLogin(saved, current) {
|
|
86
|
+
if (saved.boot !== current.boot) {
|
|
87
|
+
return { resume: false, reason: "boot-change" };
|
|
88
|
+
}
|
|
89
|
+
if (saved.login !== current.login) {
|
|
90
|
+
return { resume: false, reason: "login-change" };
|
|
91
|
+
}
|
|
92
|
+
return { resume: true, reason: "same-login" };
|
|
93
|
+
}
|
|
94
|
+
export async function readBootIdentity(platform = process.platform, run = defaultExecFile) {
|
|
95
|
+
if (platform !== "darwin") {
|
|
96
|
+
return `platform:${platform}`;
|
|
97
|
+
}
|
|
98
|
+
const result = await run("sysctl", ["-n", "kern.boottime"], {});
|
|
99
|
+
const value = result.stdout?.trim();
|
|
100
|
+
if (value === undefined || value.length === 0) {
|
|
101
|
+
throw new AgentOpsError("LAUNCHD_BOOT_IDENTITY_UNAVAILABLE", "macOS boot identity was empty.");
|
|
102
|
+
}
|
|
103
|
+
return value;
|
|
104
|
+
}
|
|
105
|
+
export function readLoginIdentity(uid = defaultUid()) {
|
|
106
|
+
if (!Number.isSafeInteger(uid) || uid < 0) {
|
|
107
|
+
throw new AgentOpsError("LAUNCHD_UID_INVALID", "macOS login user id is invalid.");
|
|
108
|
+
}
|
|
109
|
+
return `uid:${uid}:${process.env.LOGNAME ?? process.env.USER ?? "unknown"}`;
|
|
110
|
+
}
|
|
111
|
+
/** A uid alone survives logout/login; the GUI audit session identifies this login. */
|
|
112
|
+
export async function readGuiLoginIdentity(uid = defaultUid(), run = defaultExecFile) {
|
|
113
|
+
if (!Number.isSafeInteger(uid) || uid < 0)
|
|
114
|
+
throw new AgentOpsError("LAUNCHD_UID_INVALID", "Invalid macOS login user id.");
|
|
115
|
+
const result = await run("launchctl", ["print", `gui/${uid}`], {});
|
|
116
|
+
const security = result.stdout?.match(/^\tsecurity context = \{[\s\S]*?^\t\}/mu)?.[0];
|
|
117
|
+
const auditSession = security?.match(/^\s*asid = ([0-9]+)$/mu)?.[1];
|
|
118
|
+
if (auditSession === undefined)
|
|
119
|
+
throw new AgentOpsError("LAUNCHD_LOGIN_IDENTITY_UNAVAILABLE", "Cannot identify the current GUI audit session.");
|
|
120
|
+
return `gui:${uid}:asid:${auditSession}`;
|
|
121
|
+
}
|
|
122
|
+
export class LaunchdController {
|
|
123
|
+
platform;
|
|
124
|
+
uid;
|
|
125
|
+
run;
|
|
126
|
+
constructor(options = {}) {
|
|
127
|
+
this.platform = options.platform ?? process.platform;
|
|
128
|
+
this.uid = options.uid ?? (this.platform === "darwin" ? defaultUid() : 0);
|
|
129
|
+
this.run = options.execFile ?? defaultExecFile;
|
|
130
|
+
}
|
|
131
|
+
get supported() {
|
|
132
|
+
return this.platform === "darwin";
|
|
133
|
+
}
|
|
134
|
+
async writeDescriptor(descriptor) {
|
|
135
|
+
this.assertSupported();
|
|
136
|
+
await writePrivateFile(descriptor.path, descriptor.xml, descriptor.privateDirectory);
|
|
137
|
+
}
|
|
138
|
+
async bootstrap(descriptor) {
|
|
139
|
+
this.assertSupported();
|
|
140
|
+
await this.run("launchctl", ["bootstrap", descriptor.domain, descriptor.path], {
|
|
141
|
+
cwd: descriptor.privateDirectory
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
async wake(descriptor) {
|
|
145
|
+
this.assertSupported();
|
|
146
|
+
try {
|
|
147
|
+
await this.bootstrap(descriptor);
|
|
148
|
+
}
|
|
149
|
+
catch {
|
|
150
|
+
await this.run("launchctl", ["kickstart", `${descriptor.domain}/${descriptor.label}`], { cwd: descriptor.privateDirectory });
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
async bootout(descriptor) {
|
|
154
|
+
this.assertSupported();
|
|
155
|
+
try {
|
|
156
|
+
await this.run("launchctl", ["bootout", `${descriptor.domain}/${descriptor.label}`], { cwd: descriptor.privateDirectory });
|
|
157
|
+
}
|
|
158
|
+
catch (cause) {
|
|
159
|
+
const failure = cause;
|
|
160
|
+
if (failure.code !== 3 || typeof failure.stderr !== "string" || !failure.stderr.includes("No such process"))
|
|
161
|
+
throw cause;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
async disableRestart(descriptor, reason) {
|
|
165
|
+
this.assertSupported();
|
|
166
|
+
if (reason.length === 0 || reason.length > 512 || reason.includes("\0")) {
|
|
167
|
+
throw new AgentOpsError("LAUNCHD_DISABLE_REASON_INVALID", "launchd disable reason is invalid.");
|
|
168
|
+
}
|
|
169
|
+
await writePrivateFile(descriptor.disabledPath, `${JSON.stringify({ disabled: true, reason, at: new Date().toISOString() })}\n`, descriptor.privateDirectory);
|
|
170
|
+
await this.run("launchctl", ["disable", `${descriptor.domain}/${descriptor.label}`], {
|
|
171
|
+
cwd: descriptor.privateDirectory
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
async isRestartDisabled(descriptor) {
|
|
175
|
+
return (await readPrivateFile(descriptor.disabledPath, descriptor.privateDirectory)) !== null;
|
|
176
|
+
}
|
|
177
|
+
async enableRestart(descriptor) {
|
|
178
|
+
this.assertSupported();
|
|
179
|
+
await this.run("launchctl", ["enable", `${descriptor.domain}/${descriptor.label}`], {
|
|
180
|
+
cwd: descriptor.privateDirectory
|
|
181
|
+
});
|
|
182
|
+
await rm(descriptor.disabledPath, { force: true });
|
|
183
|
+
}
|
|
184
|
+
assertSupported() {
|
|
185
|
+
if (!this.supported) {
|
|
186
|
+
throw new AgentOpsError("LAUNCHD_UNSUPPORTED", "Transient launchd primitives require macOS.");
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { FileRunRepository } from "./service.js";
|
|
3
|
+
/** The common run ledger, rather than worker-local metadata, grants writer ownership. */
|
|
4
|
+
export async function registeredRunWriter(record, sessionId, agentId) {
|
|
5
|
+
if (record.runId === undefined || record.workerId === undefined || record.workerGeneration === undefined)
|
|
6
|
+
return false;
|
|
7
|
+
try {
|
|
8
|
+
const common = join(record.mainRoot, ".git");
|
|
9
|
+
const state = await new FileRunRepository(join(common, "agent-ops", "runs"), common).read(record.runId);
|
|
10
|
+
const worker = state?.workers.find(w => w.workerId === record.workerId);
|
|
11
|
+
return state !== null && state !== undefined && state.status === "active" && !state.disableRestart &&
|
|
12
|
+
worker !== undefined && worker.worktree === record.path && worker.generation === record.workerGeneration &&
|
|
13
|
+
worker.ownerSessionId === record.ownerSessionId && state.coordinatorId === record.coordinatorId &&
|
|
14
|
+
["running", "idle"].includes(worker.status) && worker.stopIntent === null &&
|
|
15
|
+
(worker.nativeSessionId === sessionId || worker.ownerSessionId === sessionId) &&
|
|
16
|
+
worker.leaseExpiresAt !== null && Date.parse(worker.leaseExpiresAt) > Date.now() &&
|
|
17
|
+
(agentId === undefined || agentId === record.agentId);
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
return false;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { AgentOpsError } from "../fs/paths.js";
|
|
2
|
+
import { validateCriterion } from "../schema/validate.js";
|
|
3
|
+
const plain = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
4
|
+
const text = (v) => typeof v === "string" && v.trim().length > 0 && v.length <= 4096 && !v.includes("\0");
|
|
5
|
+
/** Validate a deferred worker plan without freezing criteria before dependencies are delivered. */
|
|
6
|
+
export function workerPlan(value, taskId, state) {
|
|
7
|
+
if (!plain(value) || Object.keys(value).some(k => !["title", "intent", "criteria", "dependencies"].includes(k)) ||
|
|
8
|
+
!text(value.title) || !text(value.intent) || !Array.isArray(value.criteria) || value.criteria.length < 2 || value.criteria.length > 5 ||
|
|
9
|
+
!Array.isArray(value.dependencies) || value.dependencies.length > 512 || new Set(value.dependencies).size !== value.dependencies.length ||
|
|
10
|
+
value.dependencies.some(id => typeof id !== "string" || id === state.rootTaskId || !state.tasks.some(task => task.taskId === id)))
|
|
11
|
+
throw new AgentOpsError("RUN_WORKER_PLAN_INVALID", "Worker plans require 2–5 criteria and known non-coordinator dependencies.");
|
|
12
|
+
const ids = new Set();
|
|
13
|
+
const criteria = value.criteria.map((criterion, index) => {
|
|
14
|
+
if (!plain(criterion) || criterion.finding !== undefined || (plain(criterion.acceptance) && criterion.acceptance.baselineCommit !== undefined))
|
|
15
|
+
throw new AgentOpsError("RUN_WORKER_BASELINE_DEFERRED", "New worker baselines are captured only after dependencies enter its checkout; finding pins belong to an existing task.");
|
|
16
|
+
const normalized = { ...criterion, ...(plain(criterion.acceptance) ? { acceptance: { ...criterion.acceptance, baselineCommit: "0".repeat(40) } } : {}) };
|
|
17
|
+
const checked = validateCriterion(normalized, `$.criteria[${index}]`);
|
|
18
|
+
if (!checked.ok || ids.has(checked.value.id))
|
|
19
|
+
throw new AgentOpsError("RUN_WORKER_PLAN_INVALID", "Worker criteria must be unique valid contracts.");
|
|
20
|
+
ids.add(checked.value.id);
|
|
21
|
+
// TaskService.create resolves the real checkout SHA when this worker starts.
|
|
22
|
+
return structuredClone(criterion);
|
|
23
|
+
});
|
|
24
|
+
return { taskId, title: value.title, intent: value.intent, criteria, dependencies: [...value.dependencies] };
|
|
25
|
+
}
|