@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
|
@@ -4,6 +4,12 @@ import { renderReviewResult } from "../../../../runtime/src/review/render.js";
|
|
|
4
4
|
import { reviewReportResults } from "../../../../runtime/src/review/report.js";
|
|
5
5
|
import { findPriorFailingFindings, findReviewAttestation, invalidateReviewAttestation, readReviewReportArtifact, reviewReportDigest, saveReviewAttestation, saveReviewReportArtifact } from "../../../../runtime/src/review/attestation.js";
|
|
6
6
|
import { detectHostTarget, planReviewTargets, resolveReviewRole } from "../../../../runtime/src/review/roles.js";
|
|
7
|
+
import { taskContractHash, treeContractHash, goalHash } from "../../../../runtime/src/task/contract.js";
|
|
8
|
+
import { acceptanceCoverage, coverageDigest } from "../../../../runtime/src/verify/acceptance-coverage.js";
|
|
9
|
+
import { resolveGitCommit } from "../../../../runtime/src/verify/change-surface.js";
|
|
10
|
+
import { sha256 } from "../../../../runtime/src/fs/hash.js";
|
|
11
|
+
import { readPrivateFile, writePrivateFile } from "../../../../runtime/src/security/permissions.js";
|
|
12
|
+
import { redactSecrets } from "../../../../runtime/src/security/redact.js";
|
|
7
13
|
import { AgentOpsError } from "../../../../runtime/src/fs/paths.js";
|
|
8
14
|
import { assertSafeSupportingPaths, isReviewerPolicyPath, resolveReviewScope, reviewScopeSignature } from "../../../../runtime/src/review/scope.js";
|
|
9
15
|
import { calculateSourceFingerprint } from "../../../../runtime/src/verify/source-fingerprint.js";
|
|
@@ -70,8 +76,11 @@ async function taskContext(options) {
|
|
|
70
76
|
throw new AgentOpsError("REVIEW_CRITERIA_NOT_FOUND", "Every requested review criterion must exist on the selected task.");
|
|
71
77
|
}
|
|
72
78
|
return {
|
|
79
|
+
records: [record],
|
|
73
80
|
taskId: record.task.id,
|
|
74
|
-
title: record.task.
|
|
81
|
+
title: record.task.intent === undefined
|
|
82
|
+
? record.task.title
|
|
83
|
+
: `${record.task.title}\nModification intent: ${record.task.intent}`,
|
|
75
84
|
active: record.status === "active",
|
|
76
85
|
policyConfigHash: record.policyConfigHash,
|
|
77
86
|
evidence: record.evidence,
|
|
@@ -80,6 +89,60 @@ async function taskContext(options) {
|
|
|
80
89
|
allCriteriaReviewed: criteria.length === record.task.criteria.length
|
|
81
90
|
};
|
|
82
91
|
}
|
|
92
|
+
async function treeContext(options) {
|
|
93
|
+
if (options.tasks === undefined || options.taskId === undefined) {
|
|
94
|
+
throw new AgentOpsError("REVIEW_TREE_TASK_REQUIRED", "Tree review requires a parent task.");
|
|
95
|
+
}
|
|
96
|
+
const all = await options.tasks.list();
|
|
97
|
+
const root = all.find(({ task }) => task.id === options.taskId);
|
|
98
|
+
if (root === undefined || root.status === "archived") {
|
|
99
|
+
throw new AgentOpsError("REVIEW_TREE_TASK_REQUIRED", "The selected review tree does not exist.");
|
|
100
|
+
}
|
|
101
|
+
const selected = new Set([root.task.id]);
|
|
102
|
+
for (let changed = true; changed;) {
|
|
103
|
+
changed = false;
|
|
104
|
+
for (const record of all) {
|
|
105
|
+
if (record.status !== "archived" && record.task.parentTaskId !== undefined &&
|
|
106
|
+
selected.has(record.task.parentTaskId) && !selected.has(record.task.id)) {
|
|
107
|
+
selected.add(record.task.id);
|
|
108
|
+
changed = true;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
const treeTasks = all.filter(({ task }) => selected.has(task.id));
|
|
113
|
+
const owners = new Map();
|
|
114
|
+
const evidence = {};
|
|
115
|
+
const criteria = treeTasks.flatMap((record) => record.task.criteria.map((criterion) => {
|
|
116
|
+
const id = `${record.task.id}:${criterion.id}`;
|
|
117
|
+
owners.set(id, { taskId: record.task.id, criterionId: criterion.id });
|
|
118
|
+
evidence[id] = record.evidence[criterion.id] ?? [];
|
|
119
|
+
return { id, description: `${record.task.title}: ${criterion.description}`, verifierIds: [...criterion.verifierIds] };
|
|
120
|
+
}));
|
|
121
|
+
if (treeTasks.some(({ policyConfigHash }) => policyConfigHash !== root.policyConfigHash)) {
|
|
122
|
+
throw new AgentOpsError("REVIEW_TREE_POLICY_MISMATCH", "Tree tasks must share one verifier policy.");
|
|
123
|
+
}
|
|
124
|
+
const historical = new Set([root.task.id]);
|
|
125
|
+
for (let size = 0; size !== historical.size;) {
|
|
126
|
+
size = historical.size;
|
|
127
|
+
for (const record of all)
|
|
128
|
+
if (record.task.parentTaskId !== undefined && historical.has(record.task.parentTaskId))
|
|
129
|
+
historical.add(record.task.id);
|
|
130
|
+
}
|
|
131
|
+
return {
|
|
132
|
+
historyRecords: all.filter(record => historical.has(record.task.id) && record.supersededBy !== undefined),
|
|
133
|
+
records: treeTasks,
|
|
134
|
+
taskId: root.task.id,
|
|
135
|
+
title: [root.task.title, ...treeTasks.map(({ task }) => `Task ${task.id}${task.parentTaskId === undefined ? "" : ` under ${task.parentTaskId}`}: ${task.title}\nModification intent: ${task.intent ?? task.title}`)].join("\n\n"),
|
|
136
|
+
active: treeTasks.every(({ status }) => status === "active"),
|
|
137
|
+
policyConfigHash: root.policyConfigHash,
|
|
138
|
+
evidence,
|
|
139
|
+
failureFingerprint: treeTasks.find(({ failureFingerprint }) => failureFingerprint !== null)?.failureFingerprint ?? null,
|
|
140
|
+
criteria,
|
|
141
|
+
allCriteriaReviewed: true,
|
|
142
|
+
treeTasks,
|
|
143
|
+
criterionOwners: owners
|
|
144
|
+
};
|
|
145
|
+
}
|
|
83
146
|
function newestEvidence(values) {
|
|
84
147
|
return [...values].sort((left, right) => {
|
|
85
148
|
const leftTime = Date.parse(left.evidence.finishedAt);
|
|
@@ -111,11 +174,15 @@ async function currentEvidence(options, context, criterionId, commandId, configH
|
|
|
111
174
|
continue;
|
|
112
175
|
}
|
|
113
176
|
const evidence = validation.value;
|
|
114
|
-
|
|
115
|
-
|
|
177
|
+
const owner = context.criterionOwners?.get(criterionId);
|
|
178
|
+
if (evidence.taskId !== (owner?.taskId ?? context.taskId) ||
|
|
179
|
+
evidence.criterionId !== (owner?.criterionId ?? criterionId) ||
|
|
116
180
|
evidence.commandId !== commandId ||
|
|
117
181
|
evidence.configHash !== configHash ||
|
|
118
|
-
evidence.sourceFingerprint !== sourceFingerprint
|
|
182
|
+
evidence.sourceFingerprint !== sourceFingerprint ||
|
|
183
|
+
(context.records.some(r => r.task.id === (owner?.taskId ?? context.taskId) &&
|
|
184
|
+
(r.task.goal !== undefined || (r.task.contractRevision ?? 0) > 0 || r.task.criteria.some(c => c.acceptance !== undefined))) &&
|
|
185
|
+
evidence.taskContractHash !== taskContractHash(context.records.find(r => r.task.id === (owner?.taskId ?? context.taskId)).task))) {
|
|
119
186
|
stale = true;
|
|
120
187
|
continue;
|
|
121
188
|
}
|
|
@@ -142,14 +209,18 @@ async function preflightReview(options, context, sourceFingerprint) {
|
|
|
142
209
|
const configHash = calculateConfigHash(options.config);
|
|
143
210
|
const commands = [];
|
|
144
211
|
for (const criterion of context.criteria) {
|
|
145
|
-
|
|
212
|
+
const owner = context.criterionOwners?.get(criterion.id);
|
|
213
|
+
const typed = context.records.find(r => r.task.id === (owner?.taskId ?? context.taskId))?.task.criteria
|
|
214
|
+
.find(c => c.id === (owner?.criterionId ?? criterion.id))?.acceptance !== undefined;
|
|
215
|
+
for (const commandId of new Set([...(criterion.verifierIds ?? []),
|
|
216
|
+
...(typed ? options.config.verification.commands.filter(c => c.required).map(c => c.id) : [])])) {
|
|
146
217
|
const command = options.config.verification.commands.find((candidate) => candidate.id === commandId);
|
|
147
218
|
if (command === undefined) {
|
|
148
219
|
return { ok: false, reason: "missing-verification-evidence" };
|
|
149
220
|
}
|
|
150
221
|
const found = await currentEvidence(options, context, criterion.id, commandId, configHash, sourceFingerprint);
|
|
151
222
|
const compatible = command.evidence.kind === "test-count"
|
|
152
|
-
? found.current.filter(({ evidence }) => evidence.schemaVersion
|
|
223
|
+
? found.current.filter(({ evidence }) => evidence.schemaVersion >= 3)
|
|
153
224
|
: found.current;
|
|
154
225
|
if (command.required && found.current.length > 0 && compatible.length === 0) {
|
|
155
226
|
return { ok: false, reason: "stale-verification" };
|
|
@@ -210,9 +281,11 @@ export async function hasFreshVerification(options, taskId, base) {
|
|
|
210
281
|
if (context === undefined) {
|
|
211
282
|
return false;
|
|
212
283
|
}
|
|
284
|
+
options = { ...options, noChangePaths: options.noChangePaths ?? context.records.find(r => r.task.id === context.taskId)?.noChangePaths };
|
|
213
285
|
const scope = await resolveReviewScope({
|
|
214
286
|
root: options.root,
|
|
215
287
|
runner: options.gitRunner,
|
|
288
|
+
...(options.noChangePaths === undefined ? {} : { noChangePaths: options.noChangePaths }),
|
|
216
289
|
...(base === undefined ? {} : { base })
|
|
217
290
|
});
|
|
218
291
|
const fingerprint = await calculateSourceFingerprint(options.root, scope, options.gitRunner);
|
|
@@ -304,7 +377,16 @@ async function reusableReview(options, sourceFingerprint, context) {
|
|
|
304
377
|
return null;
|
|
305
378
|
}
|
|
306
379
|
const attestation = await findReviewAttestation(options.root, sourceFingerprint, context?.taskId);
|
|
307
|
-
if (attestation === null || attestation.taskId !== context?.taskId
|
|
380
|
+
if (attestation === null || attestation.taskId !== context?.taskId ||
|
|
381
|
+
(context?.binding !== undefined && (attestation.schemaVersion !== 3 ||
|
|
382
|
+
JSON.stringify(attestation.taskContracts) !== JSON.stringify(context.binding.taskContracts) ||
|
|
383
|
+
attestation.coverageDigest !== context.binding.coverageDigest))) {
|
|
384
|
+
return null;
|
|
385
|
+
}
|
|
386
|
+
if (context?.treeTasks !== undefined &&
|
|
387
|
+
(attestation.tree?.rootTaskId !== context.taskId ||
|
|
388
|
+
JSON.stringify(attestation.tree.taskIds) !== JSON.stringify(context.treeTasks.map(({ task }) => task.id)) ||
|
|
389
|
+
JSON.stringify(attestation.tree.criterionIds) !== JSON.stringify(context.criteria.map(({ id }) => id)))) {
|
|
308
390
|
return null;
|
|
309
391
|
}
|
|
310
392
|
// The task's own policy baseline must still be the active one. This is
|
|
@@ -355,9 +437,19 @@ async function persistReviewEvidence(options, result, sourceFingerprint, context
|
|
|
355
437
|
reason: "adversarial-review-missing"
|
|
356
438
|
}
|
|
357
439
|
: result;
|
|
358
|
-
|
|
440
|
+
const taskIds = complete && context?.treeTasks !== undefined
|
|
441
|
+
? context.treeTasks.map(({ task }) => task.id)
|
|
442
|
+
: [context?.taskId];
|
|
443
|
+
const tree = context?.treeTasks === undefined ? undefined : {
|
|
444
|
+
rootTaskId: context.taskId,
|
|
445
|
+
taskIds: context.treeTasks.map(({ task }) => task.id),
|
|
446
|
+
criterionIds: context.criteria.map(({ id }) => id)
|
|
447
|
+
};
|
|
448
|
+
const reportArtifacts = [];
|
|
359
449
|
try {
|
|
360
|
-
|
|
450
|
+
for (const taskId of taskIds) {
|
|
451
|
+
reportArtifacts.push({ taskId, path: await saveReviewReportArtifact(options.root, artifactResult, sourceFingerprint, taskId, complete ? tree : undefined, context?.binding === undefined ? undefined : { ...context.binding, taskContractHash: context.binding.taskContracts[taskId ?? context.taskId] }, context?.coverage) });
|
|
452
|
+
}
|
|
361
453
|
}
|
|
362
454
|
catch {
|
|
363
455
|
return {
|
|
@@ -375,21 +467,25 @@ async function persistReviewEvidence(options, result, sourceFingerprint, context
|
|
|
375
467
|
try {
|
|
376
468
|
const attempts = result.attempts;
|
|
377
469
|
const plannedTargets = result.plannedTargets;
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
470
|
+
for (const artifact of reportArtifacts) {
|
|
471
|
+
await saveReviewAttestation(options.root, {
|
|
472
|
+
schemaVersion: context?.binding === undefined ? 2 : 3,
|
|
473
|
+
...(context?.binding === undefined ? {} : { ...context.binding, taskContractHash: context.binding.taskContracts[artifact.taskId ?? context.taskId] }),
|
|
474
|
+
...(artifact.taskId === undefined ? {} : { taskId: artifact.taskId }),
|
|
475
|
+
...(tree === undefined ? {} : { tree }),
|
|
476
|
+
harness: result.harness,
|
|
477
|
+
status: "PASS",
|
|
478
|
+
sourceFingerprint,
|
|
479
|
+
reviewTargets: [plannedTargets[0], plannedTargets[1]],
|
|
480
|
+
reviewSessionIds: [attempts[0].sessionId, attempts[1].sessionId],
|
|
481
|
+
sessionIsolation: "fresh",
|
|
482
|
+
primaryReportDigest: reviewReportDigest(result.report),
|
|
483
|
+
adversarialReportDigest: reviewReportDigest(result.adversarial.report),
|
|
484
|
+
reportArtifact: artifact.path,
|
|
485
|
+
...(result.hostTarget === undefined ? {} : { hostTarget: result.hostTarget }),
|
|
486
|
+
createdAt: new Date().toISOString()
|
|
487
|
+
});
|
|
488
|
+
}
|
|
393
489
|
}
|
|
394
490
|
catch {
|
|
395
491
|
return {
|
|
@@ -432,7 +528,10 @@ export async function runReviewCommand(options) {
|
|
|
432
528
|
reason: targetPlan.reason
|
|
433
529
|
});
|
|
434
530
|
}
|
|
435
|
-
const context =
|
|
531
|
+
const context = options.args.tree === true
|
|
532
|
+
? await treeContext(options)
|
|
533
|
+
: await taskContext(options);
|
|
534
|
+
options = { ...options, noChangePaths: options.noChangePaths ?? context?.records.find(r => r.task.id === context.taskId)?.noChangePaths };
|
|
436
535
|
const evidenceRequirements = (options.args.evidence ?? []).map((value) => {
|
|
437
536
|
const separator = value.indexOf("=");
|
|
438
537
|
return {
|
|
@@ -448,6 +547,7 @@ export async function runReviewCommand(options) {
|
|
|
448
547
|
scope = await resolveReviewScope({
|
|
449
548
|
root: options.root,
|
|
450
549
|
runner: options.gitRunner,
|
|
550
|
+
...(options.noChangePaths === undefined ? {} : { noChangePaths: options.noChangePaths }),
|
|
451
551
|
...(options.args.base === undefined ? {} : { base: options.args.base })
|
|
452
552
|
});
|
|
453
553
|
}
|
|
@@ -505,7 +605,7 @@ export async function runReviewCommand(options) {
|
|
|
505
605
|
// source was reviewed; it says nothing about the policy in force now or
|
|
506
606
|
// about verification that has failed since, and both of those are
|
|
507
607
|
// decided above.
|
|
508
|
-
const reused = options.args.rerun
|
|
608
|
+
const reused = options.args.rerun || context?.treeTasks !== undefined || context?.records.some(r => r.task.goal !== undefined || r.task.criteria.some(c => c.acceptance !== undefined) || (r.task.contractRevision ?? 0) > 0)
|
|
509
609
|
? null
|
|
510
610
|
: await reusableReview(options, sourceFingerprint, context);
|
|
511
611
|
if (reused !== null) {
|
|
@@ -518,16 +618,75 @@ export async function runReviewCommand(options) {
|
|
|
518
618
|
})
|
|
519
619
|
});
|
|
520
620
|
}
|
|
521
|
-
|
|
621
|
+
for (const taskId of context?.treeTasks?.map(({ task }) => task.id) ?? [context?.taskId]) {
|
|
622
|
+
await invalidateReviewAttestation(options.root, sourceFingerprint, taskId);
|
|
623
|
+
}
|
|
522
624
|
pendingInvalidation = false;
|
|
523
625
|
}
|
|
524
626
|
}
|
|
627
|
+
if (context !== undefined && sourceFingerprint !== undefined && options.config !== undefined &&
|
|
628
|
+
options.evidenceStore !== undefined && options.gitRunner !== undefined) {
|
|
629
|
+
const rows = [];
|
|
630
|
+
for (const record of context.records) {
|
|
631
|
+
const coverage = await acceptanceCoverage(record, options.config, sourceFingerprint, options.evidenceStore);
|
|
632
|
+
rows.push(...coverage.map(row => ({ ...row, criterionId: context.treeTasks === undefined
|
|
633
|
+
? row.criterionId : record.task.id + ":" + row.criterionId })));
|
|
634
|
+
}
|
|
635
|
+
context.coverage = rows;
|
|
636
|
+
context.binding = {
|
|
637
|
+
taskContractHash: taskContractHash(context.records.find(r => r.task.id === context.taskId).task),
|
|
638
|
+
taskContracts: Object.fromEntries(context.records.map(r => [r.task.id, taskContractHash(r.task)])),
|
|
639
|
+
...(context.treeTasks === undefined ? {} : { treeContractHash: treeContractHash(context.records.map(r => r.task)) }),
|
|
640
|
+
goalHash: goalHash(context.records.find(r => r.task.id === context.taskId).task),
|
|
641
|
+
candidateCommit: await resolveGitCommit(options.gitRunner, "HEAD"), coverageDigest: coverageDigest(rows)
|
|
642
|
+
};
|
|
643
|
+
}
|
|
525
644
|
const criteria = [
|
|
526
645
|
...(context?.criteria ?? GENERIC_CRITERIA)
|
|
527
646
|
];
|
|
647
|
+
if (context?.treeTasks !== undefined && criteria.length > 128) {
|
|
648
|
+
return notRunEnvelope({ ...resultBase, status: "NOT_RUN", reason: "scope-too-large" });
|
|
649
|
+
}
|
|
528
650
|
let packet;
|
|
529
651
|
try {
|
|
652
|
+
let contractManifest;
|
|
653
|
+
const contractArtifacts = [];
|
|
654
|
+
if (context?.binding !== undefined && options.root !== undefined) {
|
|
655
|
+
const historicalRefs = new Set([
|
|
656
|
+
...[...context.records, ...(context.historyRecords ?? [])].flatMap(r => (r.revisions ?? []).flatMap(revision => Object.values(revision.previousEvidence).flat())),
|
|
657
|
+
...(context.historyRecords ?? []).flatMap(r => Object.values(r.evidence).flat())
|
|
658
|
+
]);
|
|
659
|
+
const refs = new Set([...context.records.flatMap(r => Object.values(r.evidence).flat()), ...historicalRefs].filter(ref => !ref.startsWith("review:")));
|
|
660
|
+
for (const path of refs) {
|
|
661
|
+
const loaded = await options.evidenceStore?.load(path);
|
|
662
|
+
if (loaded == null)
|
|
663
|
+
continue;
|
|
664
|
+
if (!historicalRefs.has(path) && loaded.sourceFingerprint !== sourceFingerprint)
|
|
665
|
+
continue;
|
|
666
|
+
const content = await readPrivateFile(options.root + "/" + path, options.root);
|
|
667
|
+
if (content !== null)
|
|
668
|
+
contractArtifacts.push({ path, digest: sha256(content), content });
|
|
669
|
+
const executionPath = loaded.acceptance?.executionArtifact;
|
|
670
|
+
if (executionPath !== undefined && !contractArtifacts.some(artifact => artifact.path === executionPath)) {
|
|
671
|
+
const execution = await readPrivateFile(options.root + "/" + executionPath, options.root);
|
|
672
|
+
if (execution !== null)
|
|
673
|
+
contractArtifacts.push({ path: executionPath, digest: sha256(execution), content: execution });
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
const content = redactSecrets(JSON.stringify({ binding: context.binding, coverage: context.coverage,
|
|
677
|
+
executionPolicy: options.config,
|
|
678
|
+
artifacts: contractArtifacts.map(({ path, digest }) => ({ path, digest })),
|
|
679
|
+
tasks: context.records.map(r => ({ task: r.task, revisions: r.revisions ?? [] })),
|
|
680
|
+
superseded: context.historyRecords ?? [] }, null, 2));
|
|
681
|
+
const digest = sha256(content);
|
|
682
|
+
const path = ".agent-ops/tasks/review-contracts/" + digest + ".json";
|
|
683
|
+
contractManifest = { path, digest, content };
|
|
684
|
+
// Validated before any manifest is written or handed to a snapshot.
|
|
685
|
+
buildReviewPacket({ request: context.title, criteria, artifactRefs: [], evidenceRequirements, contractManifest, contractArtifacts });
|
|
686
|
+
await writePrivateFile(options.root + "/" + path, content, options.root);
|
|
687
|
+
}
|
|
530
688
|
packet = buildReviewPacket({
|
|
689
|
+
...(contractManifest === undefined ? {} : { contractManifest, contractArtifacts }),
|
|
531
690
|
request: context?.title ?? GENERIC_REQUEST,
|
|
532
691
|
criteria,
|
|
533
692
|
artifactRefs: scope?.changedFiles ?? [],
|
|
@@ -602,6 +761,7 @@ export async function runReviewCommand(options) {
|
|
|
602
761
|
const postflight = await resolveReviewScope({
|
|
603
762
|
root: options.root,
|
|
604
763
|
runner: options.gitRunner,
|
|
764
|
+
...(options.noChangePaths === undefined ? {} : { noChangePaths: options.noChangePaths }),
|
|
605
765
|
...(options.args.base === undefined ? {} : { base: options.args.base })
|
|
606
766
|
});
|
|
607
767
|
const currentHash = options.currentPolicyConfigHash === undefined
|
|
@@ -610,7 +770,12 @@ export async function runReviewCommand(options) {
|
|
|
610
770
|
const postflightFingerprint = await calculateSourceFingerprint(options.root, postflight, options.gitRunner);
|
|
611
771
|
if (reviewScopeSignature(scope) !== reviewScopeSignature(postflight) ||
|
|
612
772
|
(options.policyConfigHash !== undefined && currentHash !== options.policyConfigHash) ||
|
|
613
|
-
postflightFingerprint !== sourceFingerprint
|
|
773
|
+
postflightFingerprint !== sourceFingerprint ||
|
|
774
|
+
(context?.binding?.treeContractHash !== undefined && options.tasks !== undefined &&
|
|
775
|
+
await options.tasks.treeContract(context.taskId) !== context.binding.treeContractHash) ||
|
|
776
|
+
(context !== undefined && options.tasks !== undefined &&
|
|
777
|
+
(await Promise.all(context.records.map(async (r) => taskContractHash((await options.tasks.status({ taskId: r.task.id })).task))))
|
|
778
|
+
.some((hash, i) => hash !== taskContractHash(context.records[i].task)))) {
|
|
614
779
|
const changed = await persistReviewEvidence(options, sourceChangedResult(result), sourceFingerprint, context);
|
|
615
780
|
return notRunEnvelope(changed);
|
|
616
781
|
}
|
|
@@ -632,12 +797,14 @@ export async function runReviewCommand(options) {
|
|
|
632
797
|
finalResult.status === "PASS" &&
|
|
633
798
|
context.active &&
|
|
634
799
|
finalResult.results !== undefined) {
|
|
635
|
-
await options.tasks.
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
800
|
+
for (const record of context.treeTasks ?? [await options.tasks.status({ taskId: context.taskId })]) {
|
|
801
|
+
await options.tasks.recordEvidence(record.task.id, Object.fromEntries(record.task.criteria.map((criterion) => {
|
|
802
|
+
const id = context.treeTasks === undefined
|
|
803
|
+
? criterion.id : `${record.task.id}:${criterion.id}`;
|
|
804
|
+
const result = finalResult.results?.find((item) => item.criterionId === id);
|
|
805
|
+
return [criterion.id, (result?.evidence ?? []).map((reference) => `review:${finalResult.harness}:${reference}`)];
|
|
806
|
+
})), scope?.mode === "base" ? scope.resolvedBase : null, scope?.mode === "base" && scope.noChange === true ? scope.changedFiles : undefined);
|
|
807
|
+
}
|
|
641
808
|
}
|
|
642
809
|
const message = finalResult.status === "PASS"
|
|
643
810
|
? "Independent review passed."
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { AgentOpsError, resolveContainedPath } from "../../../../runtime/src/fs/paths.js";
|
|
3
|
+
import { errorEnvelope, okEnvelope } from "../output.js";
|
|
4
|
+
function dynamic(args) {
|
|
5
|
+
return args;
|
|
6
|
+
}
|
|
7
|
+
function actionOf(args) {
|
|
8
|
+
const value = dynamic(args).runAction ?? dynamic(args).action;
|
|
9
|
+
if (value === undefined || value === "start")
|
|
10
|
+
return "start";
|
|
11
|
+
if (["status", "logs", "resume", "stop", "respond"].includes(String(value)))
|
|
12
|
+
return value;
|
|
13
|
+
throw new AgentOpsError("RUN_ACTION_INVALID", `Unsupported run action: ${String(value)}`);
|
|
14
|
+
}
|
|
15
|
+
function runIdOf(args) {
|
|
16
|
+
const runId = args.runId ?? dynamic(args).taskId;
|
|
17
|
+
if (typeof runId !== "string" || runId.length === 0)
|
|
18
|
+
throw new AgentOpsError("RUN_ID_REQUIRED", "This run action requires a run id.");
|
|
19
|
+
return runId;
|
|
20
|
+
}
|
|
21
|
+
function textFor(action, state, message) {
|
|
22
|
+
const active = state.workers.filter((worker) => ["assigned", "starting", "running", "idle", "handing-off"].includes(worker.status)).length;
|
|
23
|
+
return [
|
|
24
|
+
message,
|
|
25
|
+
`Status: ${state.status}`,
|
|
26
|
+
`Host: ${state.host}`,
|
|
27
|
+
`Workers: ${active}/${state.jobs}`,
|
|
28
|
+
`Budget: ${Math.max(0, state.budget.limitMs - state.budget.accumulatedMs)}ms remaining`,
|
|
29
|
+
`Usage: ${state.budget.usage.tokens ?? "unknown"} tokens; ${state.budget.usage.usd === null ? "unknown" : "$" + state.budget.usage.usd} cost (${state.budget.usage.completeness})`,
|
|
30
|
+
action === "logs" ? "" : "Native goal completion is not final proof; verify, review, task state, and receipt are still required."
|
|
31
|
+
].filter((line) => line.length > 0).join("\n");
|
|
32
|
+
}
|
|
33
|
+
function data(action, result) {
|
|
34
|
+
return {
|
|
35
|
+
action,
|
|
36
|
+
runId: result.state.runId,
|
|
37
|
+
state: result.state,
|
|
38
|
+
message: result.message,
|
|
39
|
+
text: textFor(action, result.state, result.message)
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
function stateData(action, state, message) {
|
|
43
|
+
return { action, runId: state.runId, state, message, text: textFor(action, state, message) };
|
|
44
|
+
}
|
|
45
|
+
async function goalFromArgs(args, root) {
|
|
46
|
+
if (args.goal !== undefined && args.goalFile !== undefined)
|
|
47
|
+
throw new AgentOpsError("RUN_GOAL_CONFLICT", "Use either a positional goal or --goal-file, not both.");
|
|
48
|
+
if (args.goalFile !== undefined) {
|
|
49
|
+
const path = await resolveContainedPath(root, args.goalFile);
|
|
50
|
+
return (await readFile(path, "utf8")).trim();
|
|
51
|
+
}
|
|
52
|
+
if (args.goal === undefined || args.goal.trim() === "")
|
|
53
|
+
throw new AgentOpsError("RUN_GOAL_REQUIRED", "Run start requires a non-empty goal.");
|
|
54
|
+
return args.goal.trim();
|
|
55
|
+
}
|
|
56
|
+
async function answerFromArgs(args, root) {
|
|
57
|
+
if (args.answer !== undefined && args.answerFile !== undefined)
|
|
58
|
+
throw new AgentOpsError("RUN_ANSWER_CONFLICT", "Use either --answer or --answer-file, not both.");
|
|
59
|
+
if (args.answerFile !== undefined)
|
|
60
|
+
return (await readFile(await resolveContainedPath(root, args.answerFile), "utf8")).trim();
|
|
61
|
+
if (args.answer === undefined || args.answer.trim() === "")
|
|
62
|
+
throw new AgentOpsError("RUN_ANSWER_REQUIRED", "Run respond requires a non-empty answer.");
|
|
63
|
+
return args.answer.trim();
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* External lifecycle surface. The command only starts/controls a supervisor;
|
|
67
|
+
* it never marks the task complete or treats a native process exit as proof.
|
|
68
|
+
*/
|
|
69
|
+
export async function runRunCommand(options) {
|
|
70
|
+
const action = actionOf(options.args);
|
|
71
|
+
try {
|
|
72
|
+
if (action === "start") {
|
|
73
|
+
const result = await options.service.start({
|
|
74
|
+
root: options.root,
|
|
75
|
+
commonDir: options.commonDir,
|
|
76
|
+
targetBranch: options.targetBranch,
|
|
77
|
+
goal: await goalFromArgs(options.args, options.root),
|
|
78
|
+
host: options.args.host ?? "codex",
|
|
79
|
+
ownerSessionId: options.ownerSessionId,
|
|
80
|
+
jobs: options.args.jobs,
|
|
81
|
+
timeBudgetMs: options.args.timeBudgetMs
|
|
82
|
+
});
|
|
83
|
+
const state = options.args.wait === true && options.waitForCompletion !== undefined
|
|
84
|
+
? await options.waitForCompletion(result.state.runId)
|
|
85
|
+
: result.state;
|
|
86
|
+
const response = data(action, { ...result, state });
|
|
87
|
+
return okEnvelope(options.args.wait === true ? "RUN_FINISHED" : "RUN_STARTED", response);
|
|
88
|
+
}
|
|
89
|
+
const runId = runIdOf(options.args);
|
|
90
|
+
if (action === "status")
|
|
91
|
+
return okEnvelope("RUN_STATUS", stateData(action, await options.service.status(runId), `Read run ${runId}.`));
|
|
92
|
+
if (action === "logs") {
|
|
93
|
+
const state = await options.service.status(runId);
|
|
94
|
+
const events = await options.service.logs(runId);
|
|
95
|
+
return okEnvelope("RUN_LOGS", { ...stateData(action, state, `Read ${events.length} event(s) for run ${runId}.`), events });
|
|
96
|
+
}
|
|
97
|
+
if (action === "resume")
|
|
98
|
+
return okEnvelope("RUN_RESUMED", data(action, await options.service.resume(runId)));
|
|
99
|
+
if (action === "stop")
|
|
100
|
+
return okEnvelope("RUN_STOP_REQUESTED", data(action, await options.service.stop(runId, options.args.answer ?? "user requested stop")));
|
|
101
|
+
const questionId = options.args.questionId;
|
|
102
|
+
if (questionId === undefined || questionId.trim() === "")
|
|
103
|
+
throw new AgentOpsError("RUN_QUESTION_REQUIRED", "Run respond requires --question-id.");
|
|
104
|
+
return okEnvelope("RUN_RESPONDED", data(action, await options.service.respond(runId, questionId, await answerFromArgs(options.args, options.root))));
|
|
105
|
+
}
|
|
106
|
+
catch (cause) {
|
|
107
|
+
if (cause instanceof AgentOpsError)
|
|
108
|
+
return errorEnvelope(cause.code, cause.message);
|
|
109
|
+
throw cause;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { lstat, readFile } from "node:fs/promises";
|
|
2
|
+
import { taskContractHash } from "../../../../runtime/src/task/contract.js";
|
|
1
3
|
import { AgentOpsError } from "../../../../runtime/src/fs/paths.js";
|
|
2
4
|
import { renderTaskMarkdown, safeTaskText } from "../../../../runtime/src/task/render.js";
|
|
3
5
|
import { errorEnvelope, okEnvelope } from "../output.js";
|
|
@@ -12,20 +14,21 @@ function parseCriterion(source) {
|
|
|
12
14
|
catch {
|
|
13
15
|
throw new AgentOpsError("TASK_CRITERION_INVALID", "Each --criterion value must be a JSON criterion object.");
|
|
14
16
|
}
|
|
15
|
-
if (!isRecord(value)
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
17
|
+
if (!isRecord(value))
|
|
18
|
+
throw new AgentOpsError("TASK_CRITERION_INVALID", "Criterion must be an object.");
|
|
19
|
+
// TaskService normalizes the creation baseline, then uses the shared strict validator.
|
|
20
|
+
return value;
|
|
21
|
+
}
|
|
22
|
+
async function jsonFile(path) {
|
|
23
|
+
const status = await lstat(path);
|
|
24
|
+
if (!status.isFile() || status.isSymbolicLink() || status.size > 65536)
|
|
25
|
+
throw new AgentOpsError("TASK_PAYLOAD_INVALID", "Payload must be a regular JSON file at most 64 KiB.");
|
|
26
|
+
try {
|
|
27
|
+
return JSON.parse(await readFile(path, "utf8"));
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
throw new AgentOpsError("TASK_PAYLOAD_INVALID", "Invalid JSON payload.");
|
|
23
31
|
}
|
|
24
|
-
return {
|
|
25
|
-
id: value.id,
|
|
26
|
-
description: value.description,
|
|
27
|
-
verifierIds: [...value.verifierIds]
|
|
28
|
-
};
|
|
29
32
|
}
|
|
30
33
|
function parseEvidence(values) {
|
|
31
34
|
const evidence = {};
|
|
@@ -57,7 +60,7 @@ function taskAction(args) {
|
|
|
57
60
|
"complete",
|
|
58
61
|
"create",
|
|
59
62
|
"export",
|
|
60
|
-
"status"
|
|
63
|
+
"status", "revise", "replan", "pin-finding"
|
|
61
64
|
].includes(args.action)) {
|
|
62
65
|
throw new AgentOpsError("TASK_ACTION_INVALID", "A supported task action is required.");
|
|
63
66
|
}
|
|
@@ -68,7 +71,8 @@ function taskEnvelope(action, code, message, record) {
|
|
|
68
71
|
action,
|
|
69
72
|
message,
|
|
70
73
|
record,
|
|
71
|
-
|
|
74
|
+
contractHash: taskContractHash(record.task),
|
|
75
|
+
text: renderTaskMarkdown(record) + "\nContract: " + taskContractHash(record.task)
|
|
72
76
|
});
|
|
73
77
|
}
|
|
74
78
|
function renderTaskList(records) {
|
|
@@ -92,7 +96,11 @@ export async function runTaskCommand(options) {
|
|
|
92
96
|
: await options.sessionWorktree(sessionId);
|
|
93
97
|
const record = await (worktree?.service ?? options.service).create({
|
|
94
98
|
title: options.args.title,
|
|
95
|
-
|
|
99
|
+
...(options.args.intent === undefined ? {} : { intent: options.args.intent }),
|
|
100
|
+
criteria: [...(options.args.criteria ?? []).map(parseCriterion),
|
|
101
|
+
...await Promise.all((options.args.criterionFiles ?? []).map(async (path) => await jsonFile(path)))]
|
|
102
|
+
.map(c => c.acceptance === undefined || options.args.baseline === undefined ? c :
|
|
103
|
+
{ ...c, acceptance: { ...c.acceptance, baselineCommit: c.acceptance.baselineCommit ?? options.args.baseline } }),
|
|
96
104
|
...(options.policyConfigHash === undefined
|
|
97
105
|
? {}
|
|
98
106
|
: { policyConfigHash: options.policyConfigHash }),
|
|
@@ -126,6 +134,35 @@ export async function runTaskCommand(options) {
|
|
|
126
134
|
}
|
|
127
135
|
};
|
|
128
136
|
}
|
|
137
|
+
if (action === "revise") {
|
|
138
|
+
const payload = await jsonFile(options.args.criteriaFile);
|
|
139
|
+
if (!Array.isArray(payload))
|
|
140
|
+
throw new AgentOpsError("TASK_PAYLOAD_INVALID", "Criteria file must contain an array.");
|
|
141
|
+
const record = await options.service.revise(requireTaskId(options.args), {
|
|
142
|
+
expectedContractHash: options.args.expectedContract, criteria: payload,
|
|
143
|
+
reason: options.args.reason, ...(options.args.baseline === undefined ? {} : { baseline: options.args.baseline })
|
|
144
|
+
});
|
|
145
|
+
return taskEnvelope(action, "TASK_REVISED", "Contract revised; obtain new verification and fresh review.", record);
|
|
146
|
+
}
|
|
147
|
+
if (action === "replan") {
|
|
148
|
+
const payload = await jsonFile(options.args.planFile);
|
|
149
|
+
if (!Array.isArray(payload))
|
|
150
|
+
throw new AgentOpsError("TASK_PAYLOAD_INVALID", "Plan file must contain split task objects.");
|
|
151
|
+
const records = await options.service.replan(requireTaskId(options.args), {
|
|
152
|
+
expectedTreeContractHash: options.args.expectedTreeContract, reason: options.args.reason,
|
|
153
|
+
tasks: payload
|
|
154
|
+
});
|
|
155
|
+
return okEnvelope("TASK_REPLANNED", { action, message: "Task tree split atomically; reverify and review.", records, text: renderTaskList(records) });
|
|
156
|
+
}
|
|
157
|
+
if (action === "pin-finding") {
|
|
158
|
+
const criterion = parseCriterion(JSON.stringify(await jsonFile(options.args.criterionFiles[0])));
|
|
159
|
+
const pinned = await options.service.pinFinding(requireTaskId(options.args), options.args.expectedContract, options.args.findingReference, criterion, options.args.reason);
|
|
160
|
+
if (pinned.record === null)
|
|
161
|
+
return { code: "NEEDS_REPLAN", status: "error",
|
|
162
|
+
data: { action, message: "Pin requires atomic replan to retain two to five criteria.", pendingCriterion: pinned.pendingCriterion, text: "NEEDS_REPLAN: include the pending criterion in task replan; no task was changed." },
|
|
163
|
+
errors: [{ code: "NEEDS_REPLAN", message: "Replan the full current tree and pending pin." }] };
|
|
164
|
+
return taskEnvelope(action, "TASK_FINDING_PINNED", "Pinned saved finding; reverify and review the new contract.", pinned.record);
|
|
165
|
+
}
|
|
129
166
|
if (action === "status") {
|
|
130
167
|
if (options.args.taskId === undefined && sessionId === undefined) {
|
|
131
168
|
const parentTaskId = options.args.parentTaskId;
|
|
@@ -142,7 +179,15 @@ export async function runTaskCommand(options) {
|
|
|
142
179
|
const record = await options.service.status(options.args.taskId === undefined
|
|
143
180
|
? { sessionId }
|
|
144
181
|
: { taskId: options.args.taskId });
|
|
145
|
-
|
|
182
|
+
const coverage = await options.service.coverage(record.task.id);
|
|
183
|
+
const proven = coverage.rows.filter(row => row.status === "proven").length;
|
|
184
|
+
const mechanical = coverage.rows.filter(row => row.mode === "behavioral" || row.mode === "invariant").length;
|
|
185
|
+
const reviewOnly = coverage.rows.filter(row => row.mode === "review-only").length;
|
|
186
|
+
const legacy = coverage.rows.filter(row => row.mode === "legacy").length;
|
|
187
|
+
const envelope = taskEnvelope(action, "TASK_STATUS", `Read task ${record.task.id}.`, record);
|
|
188
|
+
return { ...envelope, data: { ...envelope.data, coverage, treeContractHash: await options.service.treeContract(record.task.id),
|
|
189
|
+
text: envelope.data.text + "\nAcceptance: " + proven + "/" + mechanical + " mechanical proven; " + reviewOnly + " review-only; " + legacy + " legacy; originally mechanical " + coverage.originalMechanical +
|
|
190
|
+
"; reviewed " + coverage.reviewed + "; undischarged " + coverage.rows.filter(row => row.status !== "proven" && !coverage.reviewed).length } };
|
|
146
191
|
}
|
|
147
192
|
if (action === "attach") {
|
|
148
193
|
if (sessionId === undefined) {
|