@cassiomc1/forgeloop 1.3.0 → 1.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/.github/copilot-instructions.md +1 -0
- package/AGENTS.md +1 -0
- package/CLAUDE.md +1 -0
- package/DOCS_INDEX.md +20 -8
- package/EXECUTION_STATE.md +60 -0
- package/LOOP_ENGINEERING.md +135 -5
- package/LOOP_SYSTEM_DESIGN.md +54 -1
- package/PROTOCOL_INTEGRATION.md +87 -0
- package/QUALITY_SCORECARD.md +2 -0
- package/README.md +69 -9
- package/TERMINOLOGY.md +15 -0
- package/THIRD_PARTY_NOTICES.md +30 -0
- package/THREAT_MODEL.md +59 -1
- package/docs/ARTIFACT_REFERENCE.md +183 -0
- package/docs/CLI_REFERENCE.md +391 -6
- package/docs/CROSS_HARNESS_CONTINUITY.md +23 -0
- package/docs/DIAGNOSTIC_MODEL.md +181 -0
- package/docs/DOCUMENTATION_GUIDE.md +36 -13
- package/docs/EXECUTION_TRACE.md +76 -0
- package/docs/GETTING_STARTED.md +1 -0
- package/docs/MCP.md +159 -0
- package/docs/RECIPES.md +149 -0
- package/docs/RELEASE_CHECKLIST_1_4.md +38 -0
- package/docs/RELEASE_CHECKLIST_1_5_MCP.md +78 -0
- package/docs/TROUBLESHOOTING.md +217 -3
- package/docs/UNIVERSAL_INTEGRATION.md +48 -0
- package/docs/assets/diagrams/forgeloop-engineering-flow.html +13797 -0
- package/docs/assets/diagrams/forgeloop-engineering-flow.receipt.json +37 -0
- package/docs/assets/diagrams/forgeloop-engineering-flow.svg +5002 -0
- package/docs/diagrams/README.md +55 -0
- package/docs/diagrams/forgeloop-engineering-flow.workflow.json +122 -0
- package/docs/diagrams/manifest.json +42 -0
- package/docs/diagrams/reviews/forgeloop-engineering-flow.review.json +20 -0
- package/package.json +21 -8
- package/schemas/action.schema.json +100 -0
- package/schemas/approval.schema.json +51 -0
- package/schemas/capability-policy.schema.json +41 -0
- package/schemas/diagnostic-case.schema.json +85 -0
- package/schemas/execution-receipt.schema.json +16 -0
- package/schemas/hypothesis-disposition.schema.json +16 -0
- package/schemas/intervention.schema.json +27 -0
- package/schemas/policy-lock.schema.json +1 -0
- package/schemas/policy-snapshot.schema.json +2 -0
- package/schemas/task-recovery.schema.json +61 -0
- package/schemas/trajectory-evaluation.schema.json +64 -0
- package/schemas/trajectory-scenario.schema.json +42 -0
- package/src/cli.js +267 -347
- package/src/commands/action-authorize.js +41 -0
- package/src/commands/action-propose.js +10 -0
- package/src/commands/action-reconcile.js +10 -0
- package/src/commands/action-record.js +47 -0
- package/src/commands/action-show.js +10 -0
- package/src/commands/action-verify.js +10 -0
- package/src/commands/advance.js +7 -2
- package/src/commands/approval-request.js +64 -0
- package/src/commands/approval-resolve.js +10 -0
- package/src/commands/audit.js +5 -0
- package/src/commands/baseline.js +3 -3
- package/src/commands/eval.js +6 -0
- package/src/commands/history.js +18 -0
- package/src/commands/init.js +2 -2
- package/src/commands/inspect.js +55 -0
- package/src/commands/metrics.js +7 -0
- package/src/commands/next.js +8 -2
- package/src/commands/policy-discover.js +2 -2
- package/src/commands/progress.js +6 -2
- package/src/commands/record-diagnosis.js +37 -1
- package/src/commands/record-hypothesis-disposition.js +45 -0
- package/src/commands/record-intervention.js +35 -0
- package/src/commands/reflect.js +38 -0
- package/src/commands/report.js +9 -1
- package/src/commands/run-action.js +18 -0
- package/src/commands/status.js +17 -0
- package/src/commands/task-create.js +39 -1
- package/src/commands/task-list.js +14 -1
- package/src/commands/task-lock-status.js +2 -2
- package/src/commands/task-recover.js +202 -0
- package/src/commands/task-repair-legacy-recovery.js +417 -0
- package/src/commands/task-resume.js +172 -0
- package/src/commands/task-scope.js +23 -4
- package/src/commands/task-show.js +18 -4
- package/src/commands/trace.js +34 -0
- package/src/commands/validate-protocol.js +40 -15
- package/src/core/action-authorization.js +106 -0
- package/src/core/action-constants.js +86 -0
- package/src/core/action-execution.js +105 -0
- package/src/core/action-ledger-projection.js +302 -0
- package/src/core/action-model.js +581 -0
- package/src/core/action-readiness.js +141 -0
- package/src/core/action-reconciliation-policy.js +49 -0
- package/src/core/action-reconciliation.js +66 -0
- package/src/core/action-verification.js +111 -0
- package/src/core/actions.js +462 -0
- package/src/core/approvals.js +405 -0
- package/src/core/artifact-registry.js +60 -0
- package/src/core/audit.js +45 -4
- package/src/core/bundles.js +30 -0
- package/src/core/capability-policy.js +226 -0
- package/src/core/cli-command-definitions.js +260 -5
- package/src/core/command-executors.js +543 -0
- package/src/core/command-input.js +107 -0
- package/src/core/command-runtime.js +117 -0
- package/src/core/completion-artifacts.js +39 -15
- package/src/core/completion-ownership.js +88 -0
- package/src/core/completion-recovery-rebind.js +194 -0
- package/src/core/completion.js +70 -0
- package/src/core/continuity-reconciliation.js +24 -5
- package/src/core/diagnostic-model.js +396 -0
- package/src/core/diagnostic-projection.js +51 -0
- package/src/core/diagnostic-record.js +360 -0
- package/src/core/error-codes.js +461 -1
- package/src/core/events.js +171 -2
- package/src/core/execution-prerequisites.js +4 -1
- package/src/core/execution.js +26 -188
- package/src/core/failure-signature.js +70 -0
- package/src/core/failure-surface.js +57 -0
- package/src/core/filesystem.js +55 -6
- package/src/core/history.js +110 -0
- package/src/core/hypothesis-projection.js +85 -0
- package/src/core/information-gain-projection.js +283 -0
- package/src/core/information-gain.js +138 -0
- package/src/core/inspect.js +132 -7
- package/src/core/integration-invocation-policy.js +217 -0
- package/src/core/integration-limits.js +20 -0
- package/src/core/integration-resources.js +178 -0
- package/src/core/next-action-model.js +94 -0
- package/src/core/next-action.js +490 -3
- package/src/core/phase.js +42 -22
- package/src/core/policy-engine.js +113 -6
- package/src/core/preflight-consistency.js +31 -5
- package/src/core/preflight.js +19 -2
- package/src/core/prepared-execution.js +227 -0
- package/src/core/progress.js +41 -4
- package/src/core/project-root.js +21 -0
- package/src/core/protocol-info.js +61 -0
- package/src/core/protocol.js +14 -0
- package/src/core/receipt.js +1 -0
- package/src/core/reconcile-closure.js +35 -10
- package/src/core/recovery-history.js +116 -0
- package/src/core/reflection.js +305 -0
- package/src/core/resumability.js +57 -3
- package/src/core/schema-validation.js +9 -0
- package/src/core/strategy-analysis.js +97 -0
- package/src/core/task-claim-state.js +272 -0
- package/src/core/task-command.js +5 -1
- package/src/core/task-conflict-inspection.js +321 -0
- package/src/core/task-context.js +32 -29
- package/src/core/task-discovery.js +14 -1
- package/src/core/task-lock.js +216 -22
- package/src/core/task-paths.js +31 -2
- package/src/core/task-recovery-migration.js +192 -0
- package/src/core/task-recovery.js +205 -0
- package/src/core/task-scope.js +33 -1
- package/src/core/task-snapshot.js +53 -0
- package/src/core/templates.js +9 -0
- package/src/core/trace.js +548 -0
- package/src/core/trajectory-evaluation.js +71 -0
- package/src/core/trajectory-metrics.js +80 -0
- package/src/core/transaction.js +36 -2
- package/src/core/work-state.js +10 -5
- package/src/integration.js +47 -0
- package/docs/assets/forgeloop-flow.svg +0 -1
- package/docs/forgeloop-flow.mmd +0 -51
package/src/core/events.js
CHANGED
|
@@ -14,7 +14,25 @@ import { taskArtifactPath } from "./task-paths.js";
|
|
|
14
14
|
import { getActiveTaskTransaction, withTaskTransaction } from "./transaction.js";
|
|
15
15
|
|
|
16
16
|
import { assertDiagnosisDetails } from "./diagnosis-model.js";
|
|
17
|
+
import {
|
|
18
|
+
assertActionEventDetails,
|
|
19
|
+
assertApprovalEventDetails,
|
|
20
|
+
isActionEventName,
|
|
21
|
+
isApprovalEventName,
|
|
22
|
+
} from "./action-model.js";
|
|
23
|
+
import {
|
|
24
|
+
assertDiagnosticCaseDetails,
|
|
25
|
+
assertInterventionDetails,
|
|
26
|
+
assertHypothesisDispositionDetails,
|
|
27
|
+
} from "./diagnostic-model.js";
|
|
17
28
|
import { assertDecisionCriterionDetails } from "./settlement-model.js";
|
|
29
|
+
import {
|
|
30
|
+
LEGACY_RECOVERY_MIGRATION_EVENT,
|
|
31
|
+
assertLegacyMigrationDetails,
|
|
32
|
+
isLegacyRecoveryDetailsShape,
|
|
33
|
+
isLegacyRecoveryEventShape,
|
|
34
|
+
legacyRecoveryMigrationId,
|
|
35
|
+
} from "./task-recovery-migration.js";
|
|
18
36
|
|
|
19
37
|
const EVENT_SCHEMA_VERSION = 1;
|
|
20
38
|
export const LIFECYCLE_MILESTONES = Object.freeze([
|
|
@@ -39,6 +57,7 @@ const REPEATABLE_MILESTONES = new Set([
|
|
|
39
57
|
"VERIFICATION_RECORDED",
|
|
40
58
|
"REVIEW_STARTED",
|
|
41
59
|
"TERMINAL_RESULT_RECORDED",
|
|
60
|
+
"PREFLIGHT_READY",
|
|
42
61
|
]);
|
|
43
62
|
|
|
44
63
|
function eventIndexPath(eventsPath) {
|
|
@@ -91,17 +110,93 @@ export function validateKnownEventDetails(event) {
|
|
|
91
110
|
case "DIAGNOSIS_RECORDED":
|
|
92
111
|
assertDiagnosisDetails(event.details);
|
|
93
112
|
return;
|
|
113
|
+
case "DIAGNOSTIC_CASE_RECORDED":
|
|
114
|
+
assertDiagnosticCaseDetails(event.details);
|
|
115
|
+
return;
|
|
116
|
+
case "INTERVENTION_RECORDED":
|
|
117
|
+
assertInterventionDetails(event.details);
|
|
118
|
+
return;
|
|
119
|
+
case "HYPOTHESIS_DISPOSITION_RECORDED":
|
|
120
|
+
assertHypothesisDispositionDetails(event.details);
|
|
121
|
+
return;
|
|
94
122
|
case "DECISION_CRITERION_RECORDED":
|
|
95
123
|
assertDecisionCriterionDetails(event.details);
|
|
96
124
|
return;
|
|
97
125
|
case "CHECKPOINT_RECONCILED":
|
|
98
126
|
assertReconcileClosureDetails(event.details);
|
|
99
127
|
return;
|
|
128
|
+
case "TASK_RECOVERY_RECORDED":
|
|
129
|
+
assertRecoveryRecordedDetails(event.details);
|
|
130
|
+
return;
|
|
131
|
+
case "OPERATOR_RECOVERY_RECORDED":
|
|
132
|
+
// The exact known legacy defect signature is tolerated here so the
|
|
133
|
+
// ledger can be parsed and classified. It only becomes valid through an
|
|
134
|
+
// official migration event (enforced by validateEventLedger).
|
|
135
|
+
if (!event.details?.recoveryId && isLegacyRecoveryDetailsShape(event.details)) return;
|
|
136
|
+
assertRecoveryRecordedDetails(event.details);
|
|
137
|
+
return;
|
|
138
|
+
case "LEGACY_RECOVERY_MIGRATION_RECORDED":
|
|
139
|
+
assertLegacyMigrationDetails(event.details);
|
|
140
|
+
return;
|
|
141
|
+
case "TASK_RECOVERY_RESUMED":
|
|
142
|
+
assertRecoveryResumedDetails(event.details);
|
|
143
|
+
return;
|
|
144
|
+
case "TRAJECTORY_EVALUATED":
|
|
145
|
+
if (!event.details || !/^eval-[A-Za-z0-9_-]+$/.test(event.details.evaluationId ?? "")
|
|
146
|
+
|| typeof event.details.scenarioId !== "string" || !/^[a-f0-9]{64}$/.test(event.details.evaluationFingerprint ?? "")
|
|
147
|
+
|| event.fingerprint !== event.details.evaluationFingerprint) {
|
|
148
|
+
throw protocolError("E_EVENT_INVALID", "TRAJECTORY_EVALUATED requires a bound evaluationId, scenarioId, and fingerprint");
|
|
149
|
+
}
|
|
150
|
+
return;
|
|
100
151
|
default:
|
|
152
|
+
if (isActionEventName(event.event)) {
|
|
153
|
+
assertActionEventDetails(event);
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
if (isApprovalEventName(event.event)) {
|
|
157
|
+
assertApprovalEventDetails(event);
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
101
160
|
return;
|
|
102
161
|
}
|
|
103
162
|
}
|
|
104
163
|
|
|
164
|
+
function assertStringList(value, label) {
|
|
165
|
+
if (!Array.isArray(value) || value.some((item) => typeof item !== "string" || !item)) {
|
|
166
|
+
throw protocolError("E_EVENT_INVALID", `${label} must be an array of non-empty strings`);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function assertRecoveryRecordedDetails(details) {
|
|
171
|
+
if (!details || typeof details !== "object" || Array.isArray(details)) {
|
|
172
|
+
throw protocolError("E_EVENT_INVALID", "recovery event requires structured details");
|
|
173
|
+
}
|
|
174
|
+
for (const key of ["recoveryId", "classification", "previousPhase", "authorityKind"]) {
|
|
175
|
+
if (typeof details[key] !== "string" || !details[key]) {
|
|
176
|
+
throw protocolError("E_EVENT_INVALID", `recovery event details.${key} must be a non-empty string`);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
if (!Number.isInteger(details.previousRevision) || details.previousRevision < 0) {
|
|
180
|
+
throw protocolError("E_EVENT_INVALID", "recovery event details.previousRevision must be a non-negative integer");
|
|
181
|
+
}
|
|
182
|
+
if (!["STALE", "ABANDONED"].includes(details.classification)) {
|
|
183
|
+
throw protocolError("E_EVENT_INVALID", "recovery event details.classification must be STALE or ABANDONED");
|
|
184
|
+
}
|
|
185
|
+
if (!["CALLER_ACKNOWLEDGED", "HOST_ATTESTED"].includes(details.authorityKind)) {
|
|
186
|
+
throw protocolError("E_EVENT_INVALID", "recovery event details.authorityKind is invalid");
|
|
187
|
+
}
|
|
188
|
+
assertStringList(details.reasonCodes, "recovery event details.reasonCodes");
|
|
189
|
+
assertStringList(details.releasedClaims, "recovery event details.releasedClaims");
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function assertRecoveryResumedDetails(details) {
|
|
193
|
+
if (!details || typeof details !== "object" || Array.isArray(details)
|
|
194
|
+
|| typeof details.recoveryId !== "string" || !details.recoveryId) {
|
|
195
|
+
throw protocolError("E_EVENT_INVALID", "TASK_RECOVERY_RESUMED requires details.recoveryId");
|
|
196
|
+
}
|
|
197
|
+
assertStringList(details.reacquiredClaims, "TASK_RECOVERY_RESUMED details.reacquiredClaims");
|
|
198
|
+
}
|
|
199
|
+
|
|
105
200
|
function assertReconcileClosureDetails(details) {
|
|
106
201
|
if (!details || typeof details !== "object" || Array.isArray(details)) {
|
|
107
202
|
throw protocolError("E_EVENT_INVALID", "CHECKPOINT_RECONCILED requires structured details");
|
|
@@ -121,7 +216,7 @@ function assertReconcileClosureDetails(details) {
|
|
|
121
216
|
}
|
|
122
217
|
}
|
|
123
218
|
|
|
124
|
-
function eventHash(event) {
|
|
219
|
+
export function eventHash(event) {
|
|
125
220
|
const { hash, ...body } = event;
|
|
126
221
|
return canonicalFingerprint(body);
|
|
127
222
|
}
|
|
@@ -242,6 +337,73 @@ export async function appendProtocolEvent(target, input, packageRoot, options =
|
|
|
242
337
|
return event;
|
|
243
338
|
}
|
|
244
339
|
|
|
340
|
+
/**
|
|
341
|
+
* Validates the append-only pairing between unmigrated legacy recovery events
|
|
342
|
+
* and their official migration events. Strict by default; the official repair
|
|
343
|
+
* command validates intermediate state with `allowUnmigratedLegacyRecoveryEvents`
|
|
344
|
+
* before appending the migration events.
|
|
345
|
+
*/
|
|
346
|
+
function validateLegacyRecoveryMigrations(events, errors, { allowUnmigratedLegacyRecoveryEvents = false } = {}) {
|
|
347
|
+
const migrationBySeq = new Map();
|
|
348
|
+
for (const event of events) {
|
|
349
|
+
if (event.event !== LEGACY_RECOVERY_MIGRATION_EVENT) continue;
|
|
350
|
+
try {
|
|
351
|
+
assertLegacyMigrationDetails(event.details);
|
|
352
|
+
} catch (err) {
|
|
353
|
+
errors.push({ code: err.code ?? "E_EVENT_INVALID", message: `event ${event.seq} (${event.event}): ${err.message}` });
|
|
354
|
+
continue;
|
|
355
|
+
}
|
|
356
|
+
if (migrationBySeq.has(event.details.legacyEventSeq)) {
|
|
357
|
+
errors.push({
|
|
358
|
+
code: "E_EVENT_INVALID",
|
|
359
|
+
message: `event ${event.seq} (${event.event}): duplicate migration for legacy recovery event seq ${event.details.legacyEventSeq}`,
|
|
360
|
+
});
|
|
361
|
+
continue;
|
|
362
|
+
}
|
|
363
|
+
migrationBySeq.set(event.details.legacyEventSeq, event);
|
|
364
|
+
}
|
|
365
|
+
for (const event of events) {
|
|
366
|
+
if (!isLegacyRecoveryEventShape(event)) continue;
|
|
367
|
+
const migration = migrationBySeq.get(event.seq);
|
|
368
|
+
if (!migration) {
|
|
369
|
+
if (!allowUnmigratedLegacyRecoveryEvents) {
|
|
370
|
+
errors.push({
|
|
371
|
+
code: "E_EVENT_INVALID",
|
|
372
|
+
message: `legacy recovery event ${event.seq} is not officially migrated (run forgeloop task-repair-legacy-recovery)`,
|
|
373
|
+
});
|
|
374
|
+
}
|
|
375
|
+
continue;
|
|
376
|
+
}
|
|
377
|
+
migrationBySeq.delete(event.seq);
|
|
378
|
+
const expectedRecoveryId = legacyRecoveryMigrationId({ taskId: event.taskId, seq: event.seq, hash: event.hash });
|
|
379
|
+
// Tail-binding: the migration event is appended at the ledger tail and may
|
|
380
|
+
// sit anywhere after its historical source. It binds by reference only.
|
|
381
|
+
if (migration.seq <= event.seq) {
|
|
382
|
+
errors.push({
|
|
383
|
+
code: "E_EVENT_INVALID",
|
|
384
|
+
message: `migration event ${migration.seq} must follow legacy recovery event ${event.seq}`,
|
|
385
|
+
});
|
|
386
|
+
}
|
|
387
|
+
if (migration.taskId !== event.taskId
|
|
388
|
+
|| migration.details.legacyTaskId !== event.taskId
|
|
389
|
+
|| migration.details.recoveryId !== expectedRecoveryId
|
|
390
|
+
|| migration.details.legacyEventHash !== event.hash
|
|
391
|
+
|| migration.details.legacyEventAt !== event.at
|
|
392
|
+
|| migration.details.legacyEventType !== event.event) {
|
|
393
|
+
errors.push({
|
|
394
|
+
code: "E_LEDGER_HASH_INVALID",
|
|
395
|
+
message: `migration event ${migration.seq} does not bind legacy recovery event ${event.seq}`,
|
|
396
|
+
});
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
for (const [legacySeq, migration] of migrationBySeq) {
|
|
400
|
+
errors.push({
|
|
401
|
+
code: "E_EVENT_INVALID",
|
|
402
|
+
message: `migration event ${migration.seq} references unknown legacy recovery event seq ${legacySeq}`,
|
|
403
|
+
});
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
245
407
|
export async function validateEventLedger(target, packageRoot, options = {}) {
|
|
246
408
|
const relPath = options?.eventsPath ?? options?.relativePath ?? (options?.taskId ? taskArtifactPath(options.taskId, "events") : ARTIFACT_PATHS.events);
|
|
247
409
|
let events;
|
|
@@ -284,7 +446,11 @@ export async function validateEventLedger(target, packageRoot, options = {}) {
|
|
|
284
446
|
code: "E_PHASE_CHRONOLOGY_INVALID",
|
|
285
447
|
message: `${event.event} is missing prerequisite milestone: ${LIFECYCLE_MILESTONES[lastMilestone + 1]}`,
|
|
286
448
|
});
|
|
287
|
-
} else if (milestoneIndex < lastMilestone && event.event !== "VERIFICATION_STARTED"
|
|
449
|
+
} else if (milestoneIndex < lastMilestone && event.event !== "VERIFICATION_STARTED"
|
|
450
|
+
&& event.event !== "PREFLIGHT_READY") {
|
|
451
|
+
// VERIFICATION_STARTED re-enters per verification cycle; PREFLIGHT_READY
|
|
452
|
+
// may be refreshed mid-lifecycle (policy/contract evolution) after its
|
|
453
|
+
// prerequisites were already satisfied by the earlier occurrence.
|
|
288
454
|
errors.push({ code: "E_PHASE_CHRONOLOGY_INVALID", message: `${event.event} is out of lifecycle order` });
|
|
289
455
|
} else if (milestoneIndex === lastMilestone && !REPEATABLE_MILESTONES.has(event.event)) {
|
|
290
456
|
errors.push({ code: "E_PHASE_CHRONOLOGY_INVALID", message: `lifecycle milestone must not repeat: ${event.event}` });
|
|
@@ -323,6 +489,9 @@ export async function validateEventLedger(target, packageRoot, options = {}) {
|
|
|
323
489
|
errors.push({ code: "E_PHASE_CHRONOLOGY_INVALID", message: "completion rejected before verification started" });
|
|
324
490
|
}
|
|
325
491
|
}
|
|
492
|
+
validateLegacyRecoveryMigrations(events, errors, {
|
|
493
|
+
allowUnmigratedLegacyRecoveryEvents: options?.allowUnmigratedLegacyRecoveryEvents === true,
|
|
494
|
+
});
|
|
326
495
|
return { valid: errors.length === 0, events, errors };
|
|
327
496
|
}
|
|
328
497
|
|
|
@@ -102,7 +102,10 @@ function prerequisiteLedgerErrors(ledger, taskId, preflight, route) {
|
|
|
102
102
|
));
|
|
103
103
|
}
|
|
104
104
|
}
|
|
105
|
-
|
|
105
|
+
// Bind to the latest PREFLIGHT_READY: an append-only lifecycle may contain an
|
|
106
|
+
// older READY event superseded by a BLOCKED outcome and a fresh READY with
|
|
107
|
+
// current details (see preflight-reactivation recovery).
|
|
108
|
+
const preflightEvent = currentEvents.findLast((event) => event.event === "PREFLIGHT_READY");
|
|
106
109
|
if (preflightEvent && (!sameStringSet(preflightEvent.details?.requiredGates, preflight.requiredGates)
|
|
107
110
|
|| !sameStringSet(preflightEvent.details?.satisfiedGates, preflight.satisfiedGates))) {
|
|
108
111
|
errors.push(issue(
|
package/src/core/execution.js
CHANGED
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
-
import { spawn } from "node:child_process";
|
|
3
1
|
import { readdir } from "node:fs/promises";
|
|
4
2
|
import path from "node:path";
|
|
5
3
|
import { ensureWithin, fileExists } from "./filesystem.js";
|
|
@@ -7,17 +5,19 @@ import {
|
|
|
7
5
|
ARTIFACT_PATHS,
|
|
8
6
|
executionArtifactPath,
|
|
9
7
|
readJsonArtifact,
|
|
10
|
-
writeJsonArtifact,
|
|
11
8
|
} from "./artifacts.js";
|
|
12
9
|
import { taskArtifactPath, taskExecutionPath } from "./task-paths.js";
|
|
13
|
-
import {
|
|
14
|
-
resolveExecutionResolution,
|
|
15
|
-
validateVerificationAuthority,
|
|
16
|
-
E_COMMAND_RESOLUTION_AMBIGUOUS,
|
|
17
|
-
} from "./verification-capability.js";
|
|
18
10
|
|
|
19
|
-
export { E_COMMAND_RESOLUTION_AMBIGUOUS };
|
|
20
|
-
|
|
11
|
+
export { E_COMMAND_RESOLUTION_AMBIGUOUS } from "./verification-capability.js";
|
|
12
|
+
import {
|
|
13
|
+
prepareCommandExecution,
|
|
14
|
+
runPreparedCommandExecution,
|
|
15
|
+
} from "./prepared-execution.js";
|
|
16
|
+
export {
|
|
17
|
+
prepareCommandExecution,
|
|
18
|
+
runPreparedCommandExecution,
|
|
19
|
+
TERMINATION_GRACE_MS_PREPARED as TERMINATION_GRACE_MS,
|
|
20
|
+
} from "./prepared-execution.js";
|
|
21
21
|
|
|
22
22
|
function executionError(code, message, artifacts = []) {
|
|
23
23
|
const error = new Error(message);
|
|
@@ -26,111 +26,6 @@ function executionError(code, message, artifacts = []) {
|
|
|
26
26
|
return error;
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
-
function normalizeArgv(argv) {
|
|
30
|
-
if (!Array.isArray(argv) || argv.length === 0 || argv.some((item) => typeof item !== "string" || item.trim() === "")) {
|
|
31
|
-
throw executionError("E_EXECUTION_INVALID", "Execution argv must contain at least one non-empty string");
|
|
32
|
-
}
|
|
33
|
-
return [...argv];
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
function validateAuthorityBeforeLaunch({ target, taskId, argv, resolution, details, authorityContext, runtimeContext }) {
|
|
37
|
-
if (!resolution.mayInstall) return;
|
|
38
|
-
const check = {
|
|
39
|
-
kind: "command",
|
|
40
|
-
source: argv[0],
|
|
41
|
-
details: {
|
|
42
|
-
...(details ?? {}),
|
|
43
|
-
execution: { resolution },
|
|
44
|
-
},
|
|
45
|
-
};
|
|
46
|
-
const authority = validateVerificationAuthority(check, {
|
|
47
|
-
target,
|
|
48
|
-
taskId,
|
|
49
|
-
authorityContext,
|
|
50
|
-
runtimeContext,
|
|
51
|
-
});
|
|
52
|
-
if (!authority.valid) {
|
|
53
|
-
throw executionError(authority.error.code ?? "E_INSTALLATION_AUTHORITY_REQUIRED", authority.error.message);
|
|
54
|
-
}
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
const MAX_CAPTURED_OUTPUT_BYTES = 64 * 1024;
|
|
58
|
-
export const TERMINATION_GRACE_MS = 1_000;
|
|
59
|
-
|
|
60
|
-
function digest(bytes) {
|
|
61
|
-
return createHash("sha256").update(bytes).digest("hex");
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
function executeProcess(argv, cwd, { timeoutMs = null } = {}) {
|
|
65
|
-
return new Promise((resolve) => {
|
|
66
|
-
let spawnError = null;
|
|
67
|
-
let timedOut = false;
|
|
68
|
-
let settled = false;
|
|
69
|
-
let timeout = null;
|
|
70
|
-
let forceTermination = null;
|
|
71
|
-
const stdout = [];
|
|
72
|
-
const stderr = [];
|
|
73
|
-
let stdoutBytes = 0;
|
|
74
|
-
let stderrBytes = 0;
|
|
75
|
-
let outputTruncated = false;
|
|
76
|
-
const capture = (chunks, chunk, total) => {
|
|
77
|
-
const available = MAX_CAPTURED_OUTPUT_BYTES - total;
|
|
78
|
-
if (available <= 0) {
|
|
79
|
-
outputTruncated = true;
|
|
80
|
-
return total;
|
|
81
|
-
}
|
|
82
|
-
if (chunk.length > available) {
|
|
83
|
-
chunks.push(chunk.subarray(0, available));
|
|
84
|
-
outputTruncated = true;
|
|
85
|
-
return total + available;
|
|
86
|
-
}
|
|
87
|
-
chunks.push(chunk);
|
|
88
|
-
return total + chunk.length;
|
|
89
|
-
};
|
|
90
|
-
const finish = (result) => {
|
|
91
|
-
if (settled) return;
|
|
92
|
-
settled = true;
|
|
93
|
-
if (timeout) clearTimeout(timeout);
|
|
94
|
-
if (forceTermination) clearTimeout(forceTermination);
|
|
95
|
-
resolve({
|
|
96
|
-
...result,
|
|
97
|
-
timedOut,
|
|
98
|
-
stdout: Buffer.concat(stdout),
|
|
99
|
-
stderr: Buffer.concat(stderr),
|
|
100
|
-
stdoutBytes,
|
|
101
|
-
stderrBytes,
|
|
102
|
-
outputTruncated,
|
|
103
|
-
});
|
|
104
|
-
};
|
|
105
|
-
try {
|
|
106
|
-
const child = spawn(argv[0], argv.slice(1), {
|
|
107
|
-
cwd,
|
|
108
|
-
shell: false,
|
|
109
|
-
stdio: ["ignore", "pipe", "pipe"],
|
|
110
|
-
});
|
|
111
|
-
child.stdout?.on("data", (chunk) => { stdoutBytes = capture(stdout, chunk, stdoutBytes); });
|
|
112
|
-
child.stderr?.on("data", (chunk) => { stderrBytes = capture(stderr, chunk, stderrBytes); });
|
|
113
|
-
child.once("error", (error) => {
|
|
114
|
-
spawnError = error;
|
|
115
|
-
});
|
|
116
|
-
child.once("close", (exitCode, signal) => {
|
|
117
|
-
finish({ exitCode, signal, spawnError });
|
|
118
|
-
});
|
|
119
|
-
if (Number.isInteger(timeoutMs) && timeoutMs > 0) {
|
|
120
|
-
timeout = setTimeout(() => {
|
|
121
|
-
timedOut = true;
|
|
122
|
-
child.kill("SIGTERM");
|
|
123
|
-
forceTermination = setTimeout(() => {
|
|
124
|
-
child.kill("SIGKILL");
|
|
125
|
-
}, TERMINATION_GRACE_MS);
|
|
126
|
-
}, timeoutMs);
|
|
127
|
-
}
|
|
128
|
-
} catch (error) {
|
|
129
|
-
finish({ exitCode: null, signal: null, spawnError: error });
|
|
130
|
-
}
|
|
131
|
-
});
|
|
132
|
-
}
|
|
133
|
-
|
|
134
29
|
/**
|
|
135
30
|
* Resolves where a new execution artifact should be written. Task-scoped
|
|
136
31
|
* execution artifacts require a real modern task namespace (a task.json
|
|
@@ -148,6 +43,12 @@ export async function resolveExecutionArtifactPath(target, taskId, executionId)
|
|
|
148
43
|
return executionArtifactPath(executionId);
|
|
149
44
|
}
|
|
150
45
|
|
|
46
|
+
/**
|
|
47
|
+
* Deterministic pre-launch preparation followed by an exact-argv launch.
|
|
48
|
+
* Kept as the canonical single-command entrypoint for non-durable callers
|
|
49
|
+
* (run-check); durable actions use the two phases separately so that
|
|
50
|
+
* ACTION_STARTED lands exactly on the launch boundary (INV-EXEC-01).
|
|
51
|
+
*/
|
|
151
52
|
export async function runCommandExecution({
|
|
152
53
|
target,
|
|
153
54
|
packageRoot,
|
|
@@ -162,87 +63,24 @@ export async function runCommandExecution({
|
|
|
162
63
|
executionPath,
|
|
163
64
|
timeoutMs = null,
|
|
164
65
|
} = {}) {
|
|
165
|
-
const
|
|
166
|
-
const resolution = await resolveExecutionResolution({
|
|
167
|
-
argv: commandArgv,
|
|
168
|
-
cwd: target,
|
|
169
|
-
});
|
|
170
|
-
|
|
171
|
-
if (
|
|
172
|
-
resolution.resolutionMode === "UNKNOWN"
|
|
173
|
-
&& resolution.mayInstall === true
|
|
174
|
-
&& (
|
|
175
|
-
resolution.reason === "NPM_WORKSPACE_SCRIPT_UNRESOLVED"
|
|
176
|
-
|| resolution.reason === "NPM_SUBCOMMAND_AMBIGUOUS"
|
|
177
|
-
|| resolution.reason === "NPM_COMMAND_UNCLASSIFIED"
|
|
178
|
-
|| resolution.reason === "NPM_OPTION_VALUE_AMBIGUOUS"
|
|
179
|
-
)
|
|
180
|
-
) {
|
|
181
|
-
const error = new Error(
|
|
182
|
-
resolution.reason === "NPM_WORKSPACE_SCRIPT_UNRESOLVED"
|
|
183
|
-
? "npm workspace script execution cannot be proven from the current target. Run ForgeLoop against the selected workspace directory."
|
|
184
|
-
: "Command execution context could not be proven safe before launch."
|
|
185
|
-
);
|
|
186
|
-
error.code = E_COMMAND_RESOLUTION_AMBIGUOUS;
|
|
187
|
-
error.resolution = resolution;
|
|
188
|
-
throw error;
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
validateAuthorityBeforeLaunch({
|
|
66
|
+
const prepared = await prepareCommandExecution({
|
|
192
67
|
target,
|
|
193
|
-
|
|
194
|
-
argv: commandArgv,
|
|
195
|
-
resolution,
|
|
68
|
+
argv,
|
|
196
69
|
details,
|
|
197
70
|
authorityContext,
|
|
198
71
|
runtimeContext,
|
|
199
72
|
});
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
const processResult = await executeProcess(commandArgv, target, { timeoutMs });
|
|
204
|
-
const finishedAt = new Date().toISOString();
|
|
205
|
-
const execution = {
|
|
206
|
-
schemaVersion: 1,
|
|
207
|
-
protocolVersion: 1,
|
|
208
|
-
executionId,
|
|
73
|
+
return runPreparedCommandExecution({
|
|
74
|
+
target,
|
|
75
|
+
packageRoot,
|
|
209
76
|
taskId,
|
|
210
77
|
checkId,
|
|
211
78
|
requirement,
|
|
212
79
|
verificationCycle,
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
resolutionMode: resolution.resolutionMode,
|
|
218
|
-
mayInstall: resolution.mayInstall,
|
|
219
|
-
installer: resolution.installer,
|
|
220
|
-
tool: resolution.tool,
|
|
221
|
-
},
|
|
222
|
-
...(resolution.dispatch ? { dispatch: resolution.dispatch } : {}),
|
|
223
|
-
startedAt,
|
|
224
|
-
finishedAt,
|
|
225
|
-
status: processResult.exitCode === 0 && !processResult.spawnError && !processResult.timedOut ? "passed" : "failed",
|
|
226
|
-
exitCode: processResult.exitCode,
|
|
227
|
-
durationMs: Math.max(0, Date.parse(finishedAt) - Date.parse(startedAt)),
|
|
228
|
-
termination: processResult.spawnError ? "spawn-error" : processResult.timedOut ? "timeout" : processResult.signal ? "signal" : "exit",
|
|
229
|
-
signal: processResult.signal ?? null,
|
|
230
|
-
stdoutSha256: digest(processResult.stdout),
|
|
231
|
-
stderrSha256: digest(processResult.stderr),
|
|
232
|
-
stdoutBytes: processResult.stdoutBytes,
|
|
233
|
-
stderrBytes: processResult.stderrBytes,
|
|
234
|
-
outputTruncated: processResult.outputTruncated,
|
|
235
|
-
...(Number.isInteger(timeoutMs) && timeoutMs > 0 ? { timeoutMs, terminationGraceMs: TERMINATION_GRACE_MS } : {}),
|
|
236
|
-
};
|
|
237
|
-
const execPath = executionPath ?? await resolveExecutionArtifactPath(target, taskId, executionId);
|
|
238
|
-
const written = await writeJsonArtifact(target, execPath, execution, "execution", packageRoot);
|
|
239
|
-
return {
|
|
240
|
-
path: written.path,
|
|
241
|
-
execution: written.value,
|
|
242
|
-
result: processResult.spawnError
|
|
243
|
-
? "process failed to start"
|
|
244
|
-
: `process exited with code ${processResult.exitCode}`,
|
|
245
|
-
};
|
|
80
|
+
prepared,
|
|
81
|
+
timeoutMs,
|
|
82
|
+
executionPath,
|
|
83
|
+
});
|
|
246
84
|
}
|
|
247
85
|
|
|
248
86
|
export async function readExecutionArtifact({ target, executionRef, packageRoot, taskId } = {}) {
|
|
@@ -287,7 +125,7 @@ export async function readExecutionArtifact({ target, executionRef, packageRoot,
|
|
|
287
125
|
}
|
|
288
126
|
|
|
289
127
|
export function validateExecutionBinding({ execution, taskId, checkId, requirement, verificationCycle = 1 } = {}) {
|
|
290
|
-
if (!execution || execution.kind !==
|
|
128
|
+
if (!execution || execution.kind !== "COMMAND_EXECUTION"
|
|
291
129
|
|| execution.taskId !== taskId
|
|
292
130
|
|| execution.checkId !== checkId
|
|
293
131
|
|| execution.requirement !== requirement
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { canonicalFingerprint } from "./artifacts.js";
|
|
2
|
+
|
|
3
|
+
export function computeFailureSignature({ requirement, checkKind = null, status, exitCode = null, failureToken = null }) {
|
|
4
|
+
if (!requirement || typeof requirement !== "string") {
|
|
5
|
+
const error = new Error("failure signature requires a requirement");
|
|
6
|
+
error.code = "E_FAILURE_SIGNATURE_INVALID";
|
|
7
|
+
throw error;
|
|
8
|
+
}
|
|
9
|
+
if (!status || typeof status !== "string") {
|
|
10
|
+
const error = new Error("failure signature requires a status");
|
|
11
|
+
error.code = "E_FAILURE_SIGNATURE_INVALID";
|
|
12
|
+
throw error;
|
|
13
|
+
}
|
|
14
|
+
return canonicalFingerprint({
|
|
15
|
+
requirement,
|
|
16
|
+
...(checkKind ? { checkKind } : {}),
|
|
17
|
+
status,
|
|
18
|
+
...(Number.isInteger(exitCode) ? { exitCode } : {}),
|
|
19
|
+
...(failureToken ? { failureToken: String(failureToken) } : {}),
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function failureTokenOf(details) {
|
|
24
|
+
if (typeof details?.failureToken === "string" && details.failureToken) return details.failureToken;
|
|
25
|
+
if (typeof details?.details?.failureToken === "string" && details.details.failureToken) return details.details.failureToken;
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function projectFailureSignatures({ state = null, events = [] } = {}) {
|
|
30
|
+
const byCycle = new Map();
|
|
31
|
+
const record = (cycle, details) => {
|
|
32
|
+
if (details.status !== "failed" && details.status !== "blocked") return;
|
|
33
|
+
const requirement = details.requirement ?? details.id ?? details.checkId;
|
|
34
|
+
if (!requirement) return;
|
|
35
|
+
const signature = computeFailureSignature({
|
|
36
|
+
requirement,
|
|
37
|
+
status: details.status,
|
|
38
|
+
exitCode: Number.isInteger(details.exitCode) ? details.exitCode : null,
|
|
39
|
+
failureToken: failureTokenOf(details),
|
|
40
|
+
});
|
|
41
|
+
if (!byCycle.has(cycle)) byCycle.set(cycle, new Map());
|
|
42
|
+
const cycleMap = byCycle.get(cycle);
|
|
43
|
+
if (!cycleMap.has(signature)) cycleMap.set(signature, { signature, requirements: new Set(), cycles: new Set() });
|
|
44
|
+
cycleMap.get(signature).requirements.add(requirement);
|
|
45
|
+
cycleMap.get(signature).cycles.add(cycle);
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
for (const event of events) {
|
|
49
|
+
if (event.event !== "VERIFICATION_RECORDED") continue;
|
|
50
|
+
record(event.details?.verificationCycle ?? 1, event.details ?? {});
|
|
51
|
+
}
|
|
52
|
+
for (const check of state?.checks ?? []) {
|
|
53
|
+
if (check.status !== "failed" && check.status !== "blocked") continue;
|
|
54
|
+
record(check.details?.verificationCycle ?? state?.verificationCycle ?? 1, check);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const signaturesByIdentity = new Map();
|
|
58
|
+
for (const [, cycleMap] of [...byCycle.entries()].sort(([a], [b]) => a - b)) {
|
|
59
|
+
for (const [signature, entry] of cycleMap) {
|
|
60
|
+
if (!signaturesByIdentity.has(signature)) signaturesByIdentity.set(signature, { signature, cycles: [], requirements: new Set() });
|
|
61
|
+
const aggregated = signaturesByIdentity.get(signature);
|
|
62
|
+
aggregated.cycles.push(...entry.cycles);
|
|
63
|
+
for (const requirement of entry.requirements) aggregated.requirements.add(requirement);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return [...signaturesByIdentity.values()]
|
|
68
|
+
.map((entry) => ({ ...entry, cycles: [...new Set(entry.cycles)].sort((a, b) => a - b), requirements: [...entry.requirements].sort() }))
|
|
69
|
+
.sort((a, b) => a.signature.localeCompare(b.signature));
|
|
70
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
export function projectFailureSurfaces({ state = null, events = [] } = {}) {
|
|
2
|
+
const failedRequirementsByCycle = new Map();
|
|
3
|
+
// Track every canonically verified cycle so a successful verification
|
|
4
|
+
// appears explicitly as surface: [] instead of silently disappearing.
|
|
5
|
+
const knownCycles = new Set();
|
|
6
|
+
const record = (cycle, requirement) => {
|
|
7
|
+
if (!Number.isInteger(cycle)) cycle = Number(cycle) || 1;
|
|
8
|
+
knownCycles.add(cycle);
|
|
9
|
+
if (!failedRequirementsByCycle.has(cycle)) failedRequirementsByCycle.set(cycle, new Set());
|
|
10
|
+
if (!requirement) return;
|
|
11
|
+
failedRequirementsByCycle.get(cycle).add(requirement);
|
|
12
|
+
};
|
|
13
|
+
const knowCycle = (cycle) => {
|
|
14
|
+
if (Number.isInteger(cycle)) knownCycles.add(cycle);
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
for (const event of events) {
|
|
18
|
+
if (event.event === "VERIFICATION_STARTED") {
|
|
19
|
+
knowCycle(event.details?.verificationCycle);
|
|
20
|
+
}
|
|
21
|
+
if (event.event !== "VERIFICATION_RECORDED") continue;
|
|
22
|
+
const d = event.details ?? {};
|
|
23
|
+
if (d.status === "failed" || d.status === "blocked") {
|
|
24
|
+
record(d.verificationCycle ?? 1, d.requirement ?? d.id ?? d.checkId);
|
|
25
|
+
} else {
|
|
26
|
+
knowCycle(d.verificationCycle);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
for (const check of state?.checks ?? []) {
|
|
31
|
+
knowCycle(check.details?.verificationCycle ?? state?.verificationCycle ?? 1);
|
|
32
|
+
if (check.status !== "failed" && check.status !== "blocked") continue;
|
|
33
|
+
record(check.details?.verificationCycle ?? state?.verificationCycle ?? 1, check.requirement ?? check.id ?? check.checkId);
|
|
34
|
+
}
|
|
35
|
+
for (const cycle of knownCycles) {
|
|
36
|
+
if (!failedRequirementsByCycle.has(cycle)) failedRequirementsByCycle.set(cycle, new Set());
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return [...failedRequirementsByCycle.entries()]
|
|
40
|
+
.sort(([a], [b]) => a - b)
|
|
41
|
+
.map(([verificationCycle, requirements]) => ({
|
|
42
|
+
verificationCycle,
|
|
43
|
+
surface: [...requirements].sort(),
|
|
44
|
+
size: requirements.size,
|
|
45
|
+
}));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function compareFailureSurface(previous, current) {
|
|
49
|
+
if (!previous || !current) return { changed: false, direction: "UNCHANGED" };
|
|
50
|
+
const previousSet = new Set(previous);
|
|
51
|
+
const currentSet = new Set(current);
|
|
52
|
+
const removed = [...previousSet].filter((item) => !currentSet.has(item));
|
|
53
|
+
const added = [...currentSet].filter((item) => !previousSet.has(item));
|
|
54
|
+
if (added.length > 0) return { changed: true, direction: "EXPANDED", added, removed };
|
|
55
|
+
if (removed.length > 0) return { changed: true, direction: "REDUCED", added, removed };
|
|
56
|
+
return { changed: false, direction: "UNCHANGED", added, removed };
|
|
57
|
+
}
|