taskforce-loop-engineering 0.13.0 → 0.14.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/CHANGELOG.md +4 -0
- package/README.md +15 -1
- package/docs/adapter-sdk-terminal-contract.json +19 -0
- package/docs/production-trust-backlog.json +13 -8
- package/docs/production-trust-contract.md +53 -54
- package/docs/runtime-adapter-sdk.md +50 -0
- package/examples/adapter-sdk-demo.mjs +9 -0
- package/examples/code-worktree-queue.json +35 -0
- package/examples/queue-runner.json +37 -0
- package/examples/safe-canary.mjs +5 -0
- package/examples/workspace-health.json +35 -0
- package/lib/action-reservations.mjs +7 -0
- package/lib/core.mjs +16 -0
- package/lib/execution-ledger.mjs +41 -0
- package/lib/operator-dashboard.mjs +18 -4
- package/lib/production-evidence.mjs +54 -0
- package/lib/runtime-adapter-sdk.mjs +115 -0
- package/package.json +5 -2
- package/scripts/execution-ledger-self-test.mjs +10 -0
- package/scripts/live-runtime-soak.mjs +14 -3
- package/scripts/operator-dashboard-self-test.mjs +10 -0
- package/scripts/production-acceptance.mjs +2 -0
- package/scripts/production-evidence-self-test.mjs +17 -0
- package/scripts/production-soak.mjs +41 -17
- package/scripts/runtime-adapter-conformance.mjs +21 -0
- package/templates/github-production-trust.yml +20 -0
- package/templates/production-evidence.schema.json +20 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
## 0.14.0 - 2026-08-14
|
|
6
|
+
|
|
7
|
+
- Add the platform-neutral runtime adapter SDK v1 with OpenClaw, Hermes, Codex CLI, and Claude Code factories, shared conformance tests, fail-closed effects, redacted telemetry, migration guidance, and a credential-free demo.
|
|
8
|
+
|
|
5
9
|
## 0.13.0 - 2026-08-14
|
|
6
10
|
|
|
7
11
|
- Add versioned OpenClaw, Hermes, and custom runtime adapter contracts with a shared conformance suite.
|
package/README.md
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
# Taskforce Loop Engineering
|
|
2
2
|
|
|
3
|
+
[](https://github.com/ambitioncn/taskforce-loop-engineering/actions/workflows/production-trust.yml)
|
|
4
|
+
|
|
5
|
+
## Platform-neutral adapter SDK
|
|
6
|
+
|
|
7
|
+
OpenClaw, Hermes, Codex CLI, and Claude Code share the versioned runtime
|
|
8
|
+
contract in `lib/runtime-adapter-sdk.mjs`. Start without credentials or network
|
|
9
|
+
access with `npm run demo:adapter`, then verify every runtime using
|
|
10
|
+
`npm run check:adapters`. See [docs/runtime-adapter-sdk.md](docs/runtime-adapter-sdk.md)
|
|
11
|
+
for the contract, compatibility matrix, migration, and extension guide.
|
|
12
|
+
|
|
3
13
|
## 0.13 production trust
|
|
4
14
|
|
|
5
15
|
The local production-trust contract, runtime adapter v1, durable journal,
|
|
@@ -7,7 +17,11 @@ multi-worker canary, non-destructive Ironman upgrade planner, safe demo and
|
|
|
7
17
|
unified acceptance are documented in
|
|
8
18
|
[docs/production-trust-contract.md](docs/production-trust-contract.md). Run
|
|
9
19
|
`npm run check:production-trust`; external publishing and deployment remain
|
|
10
|
-
separately authorized actions.
|
|
20
|
+
separately authorized actions. The command writes integrity-sealed evidence and
|
|
21
|
+
a redacted public summary to `.production-evidence/`. The default canary is
|
|
22
|
+
offline and fixture-only: it performs no model call or external side effect.
|
|
23
|
+
Copy `templates/github-production-trust.yml` into `.github/workflows/` only
|
|
24
|
+
when publication is separately approved.
|
|
11
25
|
|
|
12
26
|
## Read-only operator dashboard (P3)
|
|
13
27
|
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 1,
|
|
3
|
+
"project": "P2 platform-neutral runtime adapter SDK",
|
|
4
|
+
"terminal_acceptance": [
|
|
5
|
+
"versioned platform-neutral contract",
|
|
6
|
+
"OpenClaw Hermes Codex CLI and Claude Code reproducible paths",
|
|
7
|
+
"unified capability session run step effect gate continuation telemetry and errors",
|
|
8
|
+
"compatibility matrix conformance migration and extension docs",
|
|
9
|
+
"credential-free demo and read-only dashboard path",
|
|
10
|
+
"redaction fail-closed 0.13 P0 P1 regression package and clean-install evidence"
|
|
11
|
+
],
|
|
12
|
+
"backlog": [
|
|
13
|
+
{"id":"P2-1","status":"done","evidence":"lib/runtime-adapter-sdk.mjs"},
|
|
14
|
+
{"id":"P2-2","status":"done","evidence":"scripts/runtime-adapter-conformance.mjs"},
|
|
15
|
+
{"id":"P2-3","status":"done","evidence":"docs/runtime-adapter-sdk.md"},
|
|
16
|
+
{"id":"P2-4","status":"done","evidence":"examples/adapter-sdk-demo.mjs"},
|
|
17
|
+
{"id":"P2-5","status":"done","evidence":"npm run check; npm pack --pack-destination; clean npm install and packaged conformance"}
|
|
18
|
+
]
|
|
19
|
+
}
|
|
@@ -1,13 +1,18 @@
|
|
|
1
1
|
{
|
|
2
|
-
"version":
|
|
3
|
-
"project": "
|
|
2
|
+
"version": 3,
|
|
3
|
+
"project": "p1-production-trust-evidence",
|
|
4
4
|
"terminal_contract": "production-trust-contract.md",
|
|
5
|
+
"milestone_completion_is_project_completion": false,
|
|
5
6
|
"items": [
|
|
6
|
-
{"id":"
|
|
7
|
-
{"id":"
|
|
8
|
-
{"id":"
|
|
9
|
-
{"id":"
|
|
10
|
-
{"id":"
|
|
11
|
-
{"id":"
|
|
7
|
+
{"id":"P1-1","outcome":"schema-versioned evidence, public summary, integrity and redaction","evidence":["lib/production-evidence.mjs","templates/production-evidence.schema.json","scripts/production-evidence-self-test.mjs"],"required":true},
|
|
8
|
+
{"id":"P1-2","outcome":"ledger-backed multi-agent soak and kill/restart chaos","evidence":["scripts/production-soak.mjs","lib/execution-ledger.mjs"],"required":true},
|
|
9
|
+
{"id":"P1-3","outcome":"claim lease handoff parked gate and stale-fence rejection","evidence":["scripts/production-soak.mjs",".production-evidence/evidence.json"],"required":true},
|
|
10
|
+
{"id":"P1-4","outcome":"unknown outcome crash boundaries and accepted-before-settle reconciliation","evidence":["scripts/production-soak.mjs","lib/execution-ledger.mjs"],"required":true},
|
|
11
|
+
{"id":"P1-5","outcome":"step replay resume divergence and zero duplicate effects","evidence":["scripts/production-soak.mjs","scripts/execution-ledger-self-test.mjs"],"required":true},
|
|
12
|
+
{"id":"P1-6","outcome":"baseline trend threshold attribution recovery cost and error metrics","evidence":["lib/production-evidence.mjs","scripts/production-evidence-self-test.mjs"],"required":true},
|
|
13
|
+
{"id":"P1-7","outcome":"OpenClaw Hermes and custom adapter compatibility matrix","evidence":["lib/runtime-adapter-v1.mjs","scripts/production-soak.mjs"],"required":true},
|
|
14
|
+
{"id":"P1-8","outcome":"GitHub CI candidate badge doctor and dashboard projection","evidence":["templates/github-production-trust.yml","README.md","lib/core.mjs","lib/operator-dashboard.mjs"],"required":true},
|
|
15
|
+
{"id":"P1-9","outcome":"offline determinism privacy and tamper negative tests","evidence":["scripts/production-evidence-self-test.mjs","scripts/production-soak.mjs"],"required":true},
|
|
16
|
+
{"id":"P1-10","outcome":"full regression package content and clean-install acceptance","evidence":["package.json","scripts/production-acceptance.mjs"],"required":true}
|
|
12
17
|
]
|
|
13
18
|
}
|
|
@@ -1,54 +1,53 @@
|
|
|
1
|
-
# Production
|
|
2
|
-
|
|
3
|
-
Status: local release candidate.
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
- Distributed database/HA: not provided by the local journal backend.
|
|
1
|
+
# P1 Production-Trust Evidence Terminal Contract
|
|
2
|
+
|
|
3
|
+
Status: local release candidate. Project completion requires every required P1
|
|
4
|
+
backlog item to have repeatable evidence; a scenario or milestone alone is not
|
|
5
|
+
project completion. Push, publication, deployment, production credentials,
|
|
6
|
+
real paid calls, production process control, and external effects are excluded.
|
|
7
|
+
|
|
8
|
+
## Terminal outcome
|
|
9
|
+
|
|
10
|
+
P1 turns the P0 schema-v2 execution ledger and effect protocol into a sustainable
|
|
11
|
+
production-trust evidence system. CI/canary runs emit schema-versioned,
|
|
12
|
+
integrity-sealed evidence plus a secret-redacted public summary. Baselines,
|
|
13
|
+
trends, explicit thresholds, failure attribution, runtime compatibility, cost,
|
|
14
|
+
error rate, and recovery time remain independently reviewable.
|
|
15
|
+
|
|
16
|
+
## Required acceptance
|
|
17
|
+
|
|
18
|
+
1. The deterministic multi-agent canary exercises long soak, exclusive claim,
|
|
19
|
+
lease expiry, kill/restart handoff, stale fencing, parked gate, crash before
|
|
20
|
+
and after submit, accepted-before-local-settle, reconciliation, checkpoint
|
|
21
|
+
resume, reusable replay, and replay divergence through the P0 ledger/effect
|
|
22
|
+
protocol. It does not maintain a second execution state store.
|
|
23
|
+
2. Duplicate settled effects and accepted stale fences are exactly zero;
|
|
24
|
+
unreconciled unknown outcomes are zero at terminal acceptance. Recovery time,
|
|
25
|
+
error rate, model calls, and paid-call cost meet recorded thresholds.
|
|
26
|
+
3. OpenClaw, Hermes, and custom runtime-adapter fixtures pass contract v1 using
|
|
27
|
+
simulated I/O. The boundary is explicit: fixtures prove adapter compatibility,
|
|
28
|
+
not availability of a real gateway or provider.
|
|
29
|
+
4. Evidence schema v1 supports baselines and metric deltas, threshold failures
|
|
30
|
+
with attribution, SHA-256 tamper detection, credential-shaped field redaction,
|
|
31
|
+
and a minimized public summary. Offline reruns require no network or secret.
|
|
32
|
+
5. GitHub CI template/artifact upload and badge markup are release candidates;
|
|
33
|
+
no workflow is published in this local task. Doctor and dashboard project the
|
|
34
|
+
latest evidence state without mutating it.
|
|
35
|
+
6. Full regression, package dry-run, package content inspection, and clean local
|
|
36
|
+
install pass. The packaged candidate includes schema, CI template, library,
|
|
37
|
+
canary, tests, docs, and backlog.
|
|
38
|
+
|
|
39
|
+
## Real/simulated boundary and deferred canary
|
|
40
|
+
|
|
41
|
+
`production-soak.mjs` is the authoritative offline CI canary. The separate
|
|
42
|
+
`live-runtime-soak.mjs` may perform runtime probes and is not invoked by release
|
|
43
|
+
acceptance. A real long-duration OpenClaw/Hermes run, production credentials,
|
|
44
|
+
paid inference, or external side effect needs a separate human authorization and
|
|
45
|
+
must produce a successor evidence artifact clearly labeled `real_runtime`.
|
|
46
|
+
|
|
47
|
+
## Trust limits
|
|
48
|
+
|
|
49
|
+
SHA-256 detects later artifact changes but is not an external timestamp or
|
|
50
|
+
signature. Local filesystem leases provide single-host coordination, not
|
|
51
|
+
distributed consensus or Byzantine-worker protection. Exactly-once effects still
|
|
52
|
+
depend on an upstream idempotency/reconciliation API. Unknown outcomes lacking
|
|
53
|
+
authoritative evidence fail closed and remain reconciliation debt.
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# Runtime Adapter SDK v1
|
|
2
|
+
|
|
3
|
+
`lib/runtime-adapter-sdk.mjs` is the platform-neutral contract. Contract id is
|
|
4
|
+
`loop.runtime-adapter`, semantic version `1.0.0`; consumers must reject unknown
|
|
5
|
+
major versions. The SDK normalizes capabilities, session/run identity, step
|
|
6
|
+
ledger events, authorized effects, human gates, heartbeat/continuation,
|
|
7
|
+
redacted evidence/telemetry, and stable `AdapterError` codes.
|
|
8
|
+
|
|
9
|
+
## Ten-minute, credential-free path
|
|
10
|
+
|
|
11
|
+
From this package, run `npm run demo:adapter`, `npm run check:adapters`, then
|
|
12
|
+
`npm run check`. All four paths use the same in-memory transport and make no
|
|
13
|
+
network calls. The existing dashboard remains a read-only projection; the demo
|
|
14
|
+
prints the command for opening it against a local workspace.
|
|
15
|
+
|
|
16
|
+
## Runtime paths
|
|
17
|
+
|
|
18
|
+
| Runtime | Factory | Integration transport |
|
|
19
|
+
| --- | --- | --- |
|
|
20
|
+
| OpenClaw | `createOpenClawAdapter` | map invoke to trusted session/task tools |
|
|
21
|
+
| Hermes | `createHermesAdapter` | map invoke to Hermes run lifecycle |
|
|
22
|
+
| Codex CLI | `createCodexCliAdapter` | map invoke to local Codex exec/resume |
|
|
23
|
+
| Claude Code | `createClaudeCodeAdapter` | map invoke to local Claude session/resume |
|
|
24
|
+
|
|
25
|
+
Each transport implements `invoke(operation, payload)`. Operations are
|
|
26
|
+
`run.start`, `run.heartbeat`, and `run.continue`. Side effects never pass through
|
|
27
|
+
that generic transport: call `prepareEffect` first, persist its idempotency key
|
|
28
|
+
in the P0/P1 ledger, obtain explicit authorization, then submit through the
|
|
29
|
+
product-specific effect adapter. Missing authorization fails closed.
|
|
30
|
+
|
|
31
|
+
## Compatibility and migration
|
|
32
|
+
|
|
33
|
+
| Surface | Status |
|
|
34
|
+
| --- | --- |
|
|
35
|
+
| package 0.13 / P0 / P1 ledgers | compatible; unchanged |
|
|
36
|
+
| `runtime-adapter-v1.mjs` OpenClaw/Hermes/custom fixtures | retained |
|
|
37
|
+
| SDK v1 four-runtime contract | additive and preferred |
|
|
38
|
+
| future SDK major | rejected until explicitly supported |
|
|
39
|
+
|
|
40
|
+
Migrate by replacing fixture imports with a `create*Adapter(transport)` factory,
|
|
41
|
+
creating a session then run, recording every step, and routing effects through
|
|
42
|
+
`prepareEffect`. Convert caught errors using `AdapterError.toJSON()`; never log
|
|
43
|
+
raw credentials. To extend, add a runtime to `RUNTIMES`, a thin factory, and run
|
|
44
|
+
the exported conformance function with an offline transport before connecting a
|
|
45
|
+
real runtime.
|
|
46
|
+
|
|
47
|
+
Error codes are `INVALID_INPUT`, `UNSUPPORTED_CONTRACT`,
|
|
48
|
+
`UNSUPPORTED_VERSION`, `UNSUPPORTED_RUNTIME`, `INVALID_ADAPTER`,
|
|
49
|
+
`INVALID_STEP_STATE`, `EFFECT_KEY_REQUIRED`, `EFFECT_NOT_AUTHORIZED`,
|
|
50
|
+
`INVALID_GATE_DECISION`, and retryable `TRANSPORT_FAILURE`.
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { createMemoryTransport, createOpenClawAdapter } from '../lib/runtime-adapter-sdk.mjs';
|
|
2
|
+
|
|
3
|
+
const adapter = createOpenClawAdapter(createMemoryTransport());
|
|
4
|
+
const session = await adapter.createSession({ key: 'ten-minute-demo' });
|
|
5
|
+
const run = await adapter.startRun({ sessionId: session.sessionId, requestId: 'demo', input: { prompt: 'credential-free' } });
|
|
6
|
+
await adapter.recordStep({ runId: run.runId, stepId: 'hello', evidence: [{ kind: 'local-demo' }] });
|
|
7
|
+
const heartbeat = await adapter.heartbeat({ runId: run.runId });
|
|
8
|
+
await adapter.continueRun({ runId: run.runId, continuationToken: heartbeat.continuationToken });
|
|
9
|
+
console.log(JSON.stringify({ session, run, heartbeat, dashboard: 'run `loop-engineering dashboard --root .` for the existing read-only projection', telemetry: adapter.telemetry }, null, 2));
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
{
|
|
2
|
+
"queue": "code-tasks",
|
|
3
|
+
"description": "L2 assisted code task queue. Each task runs in an isolated git worktree and records verification plus diff summaries. It never pushes, merges, or deletes worktrees.",
|
|
4
|
+
"dispatcher": "node scripts/dispatch-code-task.mjs",
|
|
5
|
+
"preflightConfig": "configs/loops/workspace-health.json",
|
|
6
|
+
"timeoutMs": 1800000,
|
|
7
|
+
"leaseMs": 1860000,
|
|
8
|
+
"staleActiveMs": 3600000,
|
|
9
|
+
"retry": {
|
|
10
|
+
"maxAttempts": 1,
|
|
11
|
+
"retryDelayMs": 0,
|
|
12
|
+
"retryExitCodes": [
|
|
13
|
+
1
|
|
14
|
+
],
|
|
15
|
+
"requiresHumanActionPatterns": [
|
|
16
|
+
"INSTALL_FAILED_USER_RESTRICTED",
|
|
17
|
+
"device unauthorized",
|
|
18
|
+
"no devices/emulators found",
|
|
19
|
+
"Permission denied",
|
|
20
|
+
"Operation not permitted",
|
|
21
|
+
"requires human",
|
|
22
|
+
"需要人工",
|
|
23
|
+
"权限未开"
|
|
24
|
+
]
|
|
25
|
+
},
|
|
26
|
+
"worktree": {
|
|
27
|
+
"enabled": true,
|
|
28
|
+
"baseDir": "runtime/loops/code-tasks/worktrees",
|
|
29
|
+
"branchPrefix": "loop/code-tasks",
|
|
30
|
+
"verifyCommands": [
|
|
31
|
+
"npm test"
|
|
32
|
+
],
|
|
33
|
+
"keepOnSuccess": true
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"queue": "agent-tasks",
|
|
3
|
+
"description": "Generic queue runner config. Keep dispatcher local to the target workspace.",
|
|
4
|
+
"dispatcher": "node scripts/dispatch-task.mjs",
|
|
5
|
+
"preflightConfig": "configs/loops/workspace-health.json",
|
|
6
|
+
"timeoutMs": 1800000,
|
|
7
|
+
"leaseMs": 1860000,
|
|
8
|
+
"staleActiveMs": 3600000,
|
|
9
|
+
"scheduler": {
|
|
10
|
+
"initialInterval": "10m",
|
|
11
|
+
"minInterval": "1m",
|
|
12
|
+
"maxInterval": "4h",
|
|
13
|
+
"speedupFactor": 0.5,
|
|
14
|
+
"backoffFactor": 2,
|
|
15
|
+
"idleBackoffFactor": 2,
|
|
16
|
+
"humanGateBackoffFactor": 3,
|
|
17
|
+
"longRunHeadroomFactor": 1.25,
|
|
18
|
+
"jitter": "30s"
|
|
19
|
+
},
|
|
20
|
+
"retry": {
|
|
21
|
+
"maxAttempts": 1,
|
|
22
|
+
"retryDelayMs": 0,
|
|
23
|
+
"retryExitCodes": [
|
|
24
|
+
1
|
|
25
|
+
],
|
|
26
|
+
"requiresHumanActionPatterns": [
|
|
27
|
+
"INSTALL_FAILED_USER_RESTRICTED",
|
|
28
|
+
"device unauthorized",
|
|
29
|
+
"no devices/emulators found",
|
|
30
|
+
"Permission denied",
|
|
31
|
+
"Operation not permitted",
|
|
32
|
+
"requires human",
|
|
33
|
+
"需要人工",
|
|
34
|
+
"权限未开"
|
|
35
|
+
]
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { customAdapterExample } from '../lib/runtime-adapter-v1.mjs';
|
|
2
|
+
const audit = [];
|
|
3
|
+
const io = { invoke: async (binary, args) => (audit.push({ binary, args, externalWrite: false, paid: false }), { accepted: true }), now: () => new Date().toISOString(), lookup: async () => ({ status: 'not_accepted' }) };
|
|
4
|
+
await customAdapterExample.dispatch({ prompt: 'credential-free local canary', worker: 'demo' }, io);
|
|
5
|
+
console.log(JSON.stringify({ support: 'example-contract-only', credentialsUsed: false, externalWrites: false, paidCalls: false, audit }, null, 2));
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "workspace-health",
|
|
3
|
+
"goal": "Keep this workspace loop-ready and detect obvious drift.",
|
|
4
|
+
"level": "L1",
|
|
5
|
+
"mode": "report-only",
|
|
6
|
+
"maxRuntimeMs": 120000,
|
|
7
|
+
"description": "A safe first loop: local read-only checks plus durable run ledger.",
|
|
8
|
+
"humanGates": [
|
|
9
|
+
"source edits",
|
|
10
|
+
"external messages",
|
|
11
|
+
"destructive commands",
|
|
12
|
+
"production config changes"
|
|
13
|
+
],
|
|
14
|
+
"breaker": {
|
|
15
|
+
"maxConsecutiveFailures": 3,
|
|
16
|
+
"sameFailureThreshold": 2
|
|
17
|
+
},
|
|
18
|
+
"checks": [
|
|
19
|
+
{
|
|
20
|
+
"id": "git-status",
|
|
21
|
+
"type": "command",
|
|
22
|
+
"cmd": "git status --short",
|
|
23
|
+
"expectExitCode": 0,
|
|
24
|
+
"timeoutMs": 10000,
|
|
25
|
+
"allowNonEmptyOutput": true
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
"id": "loop-config-dir",
|
|
29
|
+
"type": "files",
|
|
30
|
+
"paths": [
|
|
31
|
+
"configs/loops"
|
|
32
|
+
]
|
|
33
|
+
}
|
|
34
|
+
]
|
|
35
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { mkdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { createHash, randomUUID } from 'node:crypto';
|
|
4
|
+
import { migrateActionReservation } from './execution-ledger.mjs';
|
|
4
5
|
|
|
5
6
|
const ACTION_KINDS = new Set(['paid_api', 'notification', 'deployment', 'process_control', 'publication', 'external_message', 'gated_mutation']);
|
|
6
7
|
const TERMINAL_STATES = new Set(['settled', 'released']);
|
|
@@ -194,3 +195,9 @@ export async function migrateLegacyActionArtifact(root, legacy) {
|
|
|
194
195
|
const scope = legacy.authorization_scope ?? legacy.authorization?.scope ?? 'legacy:unscoped';
|
|
195
196
|
return reserveAction(root, { idempotencyKey: key, kind: legacy.kind ?? 'gated_mutation', request, authorizationScope: scope });
|
|
196
197
|
}
|
|
198
|
+
|
|
199
|
+
export async function projectActionToEffectProtocol(root, idempotencyKey) {
|
|
200
|
+
const reservation = await inspectAction(root, idempotencyKey);
|
|
201
|
+
if (!reservation) throw new Error('Action reservation not found.');
|
|
202
|
+
return migrateActionReservation(root, reservation);
|
|
203
|
+
}
|
package/lib/core.mjs
CHANGED
|
@@ -4,6 +4,8 @@ import { spawn } from 'node:child_process';
|
|
|
4
4
|
import { fileURLToPath } from 'node:url';
|
|
5
5
|
import { tmpdir } from 'node:os';
|
|
6
6
|
import { createHash } from 'node:crypto';
|
|
7
|
+
import { listSteps } from './execution-ledger.mjs';
|
|
8
|
+
import { readAndVerifyEvidence } from './production-evidence.mjs';
|
|
7
9
|
|
|
8
10
|
export const PACKAGE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
9
11
|
|
|
@@ -747,6 +749,20 @@ export async function doctorReport(root, options = {}) {
|
|
|
747
749
|
add('configs-dir', 'warn', await exists(configsDir), path.relative(root, configsDir));
|
|
748
750
|
const runtimeDir = path.join(root, 'runtime', 'loops');
|
|
749
751
|
add('runtime-dir', 'warn', await exists(runtimeDir), path.relative(root, runtimeDir));
|
|
752
|
+
try {
|
|
753
|
+
const steps = await listSteps(root);
|
|
754
|
+
const invalid = steps.filter((step) => step.version !== 2 || !step.step_id || !step.input_fingerprint || !['llm', 'tool', 'effect'].includes(step.kind));
|
|
755
|
+
const reconciliation = steps.filter((step) => step.status === 'unknown' || step.reconciliation?.required);
|
|
756
|
+
add('execution-ledger', 'fail', invalid.length === 0, { schema_version: 2, steps: steps.length, invalid: invalid.map((step) => step.step_id), reconciliation_required: reconciliation.map((step) => step.step_id) });
|
|
757
|
+
if (reconciliation.length) add('execution-ledger:reconciliation', 'warn', false, `${reconciliation.length} step(s) require evidence-backed reconciliation`);
|
|
758
|
+
} catch (error) {
|
|
759
|
+
add('execution-ledger', 'fail', false, error instanceof Error ? error.message : String(error));
|
|
760
|
+
}
|
|
761
|
+
const evidenceFile = path.join(root, '.production-evidence', 'evidence.json');
|
|
762
|
+
if (await exists(evidenceFile)) {
|
|
763
|
+
try { const evidence = await readAndVerifyEvidence(evidenceFile); add('production-evidence', 'fail', evidence.verification.valid && evidence.report.passed, { schema_version: evidence.report.schema_version, integrity: evidence.verification.valid, passed: evidence.report.passed, digest: evidence.report.integrity?.digest }); }
|
|
764
|
+
catch (error) { add('production-evidence', 'fail', false, error instanceof Error ? error.message : String(error)); }
|
|
765
|
+
} else add('production-evidence', 'warn', false, '.production-evidence/evidence.json not generated');
|
|
750
766
|
|
|
751
767
|
const loopConfigs = await configFilesFromArgs(root, []);
|
|
752
768
|
add('loop-configs-found', 'warn', loopConfigs.length > 0, `${loopConfigs.length} loop config(s)`);
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
+
import { mkdir, readFile, readdir, rename, rm, stat, writeFile } from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
|
|
5
|
+
export const EXECUTION_LEDGER_VERSION = 2;
|
|
6
|
+
const KINDS = new Set(['llm', 'tool', 'effect']);
|
|
7
|
+
const TERMINAL = new Set(['succeeded', 'failed', 'cancelled']);
|
|
8
|
+
const SENSITIVE = /(^|_)(secret|token|password|credential|api[_-]?key|private[_-]?key)(_|$)/i;
|
|
9
|
+
const canonicalValue = (value) => Array.isArray(value) ? value.map(canonicalValue) : value && typeof value === 'object' ? Object.fromEntries(Object.keys(value).sort().map((key) => [key, canonicalValue(value[key])])) : value;
|
|
10
|
+
const hash = (value) => createHash('sha256').update(typeof value === 'string' ? value : JSON.stringify(canonicalValue(value))).digest('hex');
|
|
11
|
+
export const stepInputFingerprint = (input) => hash(input);
|
|
12
|
+
function text(value, name) { if (typeof value !== 'string' || !value.trim()) throw new Error(`${name} must be a non-empty string.`); return value; }
|
|
13
|
+
function safeId(value, name = 'stepId') { text(value, name); if (!/^[a-zA-Z0-9][a-zA-Z0-9._:-]{0,299}$/.test(value)) throw new Error(`${name} contains unsafe characters or is too long.`); return value; }
|
|
14
|
+
function redact(value, key = '') { if (SENSITIVE.test(key)) return '[REDACTED]'; if (Array.isArray(value)) return value.map((item) => redact(item)); if (value && typeof value === 'object') return Object.fromEntries(Object.keys(value).sort().map((name) => [name, redact(value[name], name)])); return value; }
|
|
15
|
+
function locations(root, stepId) { const directory = path.join(path.resolve(root), 'runtime', 'loops', 'execution-ledger'); const key = hash(safeId(stepId)); return { file: path.join(directory, 'steps', `${key}.json`), lock: path.join(directory, 'locks', key) }; }
|
|
16
|
+
async function atomicWrite(file, value) { await mkdir(path.dirname(file), { recursive: true }); const temporary = `${file}.${process.pid}.${randomUUID()}.tmp`; await writeFile(temporary, `${JSON.stringify(value, null, 2)}\n`, { flag: 'wx', mode: 0o600 }); await rename(temporary, file); }
|
|
17
|
+
async function mutex(lock) { await mkdir(path.dirname(lock), { recursive: true }); const deadline = Date.now() + 5000; while (true) { try { await mkdir(lock); return; } catch (error) { if (error.code !== 'EEXIST') throw error; const info = await stat(lock).catch(() => null); if (info && Date.now() - info.mtimeMs > 30_000) await rm(lock, { recursive: true, force: true }); else if (Date.now() >= deadline) throw new Error('Timed out acquiring execution ledger mutex.'); else await new Promise((resolve) => setTimeout(resolve, 10)); } } }
|
|
18
|
+
async function mutate(root, stepId, operation) { const loc = locations(root, stepId); await mkdir(path.dirname(loc.file), { recursive: true }); await mutex(loc.lock); try { const record = await readFile(loc.file, 'utf8').then(JSON.parse).catch((error) => error.code === 'ENOENT' ? null : Promise.reject(error)); const result = await operation(record); if (result.write) await atomicWrite(loc.file, result.record); return result.output ?? result.record; } finally { await rm(loc.lock, { recursive: true, force: true }); } }
|
|
19
|
+
function event(record, type, data = {}) { const entry = { sequence: record.events.length + 1, type, at: new Date().toISOString(), ...redact(data), previous: record.events.at(-1)?.checksum ?? null }; entry.checksum = hash(entry); return entry; }
|
|
20
|
+
function assertFence(record, token) { if (record.status !== 'running' || record.lease?.fencing_token !== Number(token)) throw new Error('Stale or invalid fencing token.'); }
|
|
21
|
+
|
|
22
|
+
export async function registerStep(root, input) {
|
|
23
|
+
const stepId = safeId(input.stepId); const kind = text(input.kind, 'kind'); if (!KINDS.has(kind)) throw new Error(`Unsupported step kind: ${kind}`); const fingerprint = stepInputFingerprint(input.input);
|
|
24
|
+
return mutate(root, stepId, async (record) => {
|
|
25
|
+
if (record) { if (record.kind !== kind || record.input_fingerprint !== fingerprint) throw new Error('Replay divergence: step identity is bound to a different kind or input fingerprint.'); return { write: false, output: { created: false, reusable: record.status === 'succeeded' && record.policy.reusable, record } }; }
|
|
26
|
+
const now = new Date().toISOString(); const created = { schema: 'loop.execution_step', version: 2, step_id: stepId, kind, status: 'pending', input_fingerprint: fingerprint, input: redact(input.input), lineage: { run_id: input.runId ?? null, task_id: input.taskId ?? null, parent_step_id: input.parentStepId ?? null, replaces_step_id: input.replacesStepId ?? null }, policy: { reusable: input.reusable !== false, effect_key: kind === 'effect' ? safeId(input.effectKey ?? stepId, 'effectKey') : null, authorization_scope: input.authorizationScope ?? null }, attempt: 0, fencing_counter: 0, lease: null, checkpoints: [], outcome: null, evidence: [], reconciliation: null, created_at: now, updated_at: now, events: [] }; created.events.push(event(created, 'registered', { input_fingerprint: fingerprint })); return { write: true, record: created, output: { created: true, reusable: false, record: created } };
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
export async function inspectStep(root, stepId) { const loc = locations(root, stepId); return readFile(loc.file, 'utf8').then(JSON.parse).catch((error) => error.code === 'ENOENT' ? null : Promise.reject(error)); }
|
|
30
|
+
export async function claimStep(root, input) {
|
|
31
|
+
const leaseMs = Number(input.leaseMs ?? 60_000); if (!Number.isInteger(leaseMs) || leaseMs <= 0) throw new Error('leaseMs must be a positive integer.');
|
|
32
|
+
return mutate(root, input.stepId, async (record) => { if (!record) throw new Error('Step must be registered before claim.'); if (record.status === 'succeeded') return { write: false, output: { claimed: false, reason: record.policy.reusable ? 'reusable_outcome' : 'terminal', record } }; if (TERMINAL.has(record.status)) return { write: false, output: { claimed: false, reason: 'terminal', record } }; if (record.status === 'unknown') return { write: false, output: { claimed: false, reason: 'reconcile_required', record } }; const nowMs = Date.now(); if (record.status === 'running' && Date.parse(record.lease.expires_at) > nowMs) return { write: false, output: { claimed: false, reason: 'lease_active', record } }; if (record.status === 'running') { const at = new Date().toISOString(); const next = { ...record, status: 'unknown', lease: null, reconciliation: { required: true, reason: 'lease_expired_after_claim', at }, updated_at: at }; next.events = [...record.events, event(record, 'outcome_unknown', { reason: 'lease_expired_after_claim' })]; return { write: true, record: next, output: { claimed: false, reason: 'reconcile_required', record: next } }; } const token = record.fencing_counter + 1; const at = new Date().toISOString(); const next = { ...record, status: 'running', attempt: record.attempt + 1, fencing_counter: token, lease: { owner: text(input.owner, 'owner'), fencing_token: token, claimed_at: at, expires_at: new Date(nowMs + leaseMs).toISOString() }, updated_at: at }; next.events = [...record.events, event(record, 'claimed', { attempt: next.attempt, fencing_token: token, owner: input.owner })]; return { write: true, record: next, output: { claimed: true, fencingToken: token, attempt: next.attempt, record: next } }; });
|
|
33
|
+
}
|
|
34
|
+
export async function checkpointStep(root, input) { return mutate(root, input.stepId, async (record) => { assertFence(record, input.fencingToken); const checkpoint = { sequence: record.checkpoints.length + 1, at: new Date().toISOString(), fingerprint: stepInputFingerprint(input.state), state: redact(input.state), evidence: redact(input.evidence ?? null) }; const next = { ...record, checkpoints: [...record.checkpoints, checkpoint], updated_at: checkpoint.at }; next.events = [...record.events, event(record, 'checkpointed', { sequence: checkpoint.sequence, fingerprint: checkpoint.fingerprint })]; return { write: true, record: next, output: checkpoint }; }); }
|
|
35
|
+
export async function completeStep(root, input) { return mutate(root, input.stepId, async (record) => { if (record?.status === 'succeeded') return { write: false, output: { completed: false, duplicate: true, record } }; assertFence(record, input.fencingToken); const at = new Date().toISOString(); const outcome = { status: 'succeeded', output_fingerprint: stepInputFingerprint(input.output), output: redact(input.output), at }; const next = { ...record, status: 'succeeded', lease: null, outcome, evidence: [...record.evidence, redact(input.evidence ?? {})], reconciliation: null, updated_at: at }; next.events = [...record.events, event(record, 'succeeded', { output_fingerprint: outcome.output_fingerprint })]; return { write: true, record: next, output: { completed: true, duplicate: false, record: next } }; }); }
|
|
36
|
+
export async function markStepUnknown(root, input) { return mutate(root, input.stepId, async (record) => { assertFence(record, input.fencingToken); const at = new Date().toISOString(); const next = { ...record, status: 'unknown', lease: null, reconciliation: { required: true, reason: input.reason ?? 'submit_outcome_unknown', at }, evidence: [...record.evidence, redact(input.evidence ?? {})], updated_at: at }; next.events = [...record.events, event(record, 'outcome_unknown', { reason: next.reconciliation.reason })]; return { write: true, record: next }; }); }
|
|
37
|
+
export async function reconcileStep(root, input) { return mutate(root, input.stepId, async (record) => { if (!record || record.status !== 'unknown') throw new Error('Only an unknown step can be reconciled.'); if (!['accepted', 'not_accepted'].includes(input.outcome)) throw new Error('Reconciliation outcome must be accepted or not_accepted.'); if (!input.evidence) throw new Error('Reconciliation requires evidence.'); const at = new Date().toISOString(); const evidence = redact(input.evidence); if (input.outcome === 'accepted') { const outcome = { status: 'succeeded', output_fingerprint: stepInputFingerprint(input.output), output: redact(input.output), reconciled: true, at }; const next = { ...record, status: 'succeeded', outcome, evidence: [...record.evidence, evidence], reconciliation: { required: false, outcome: 'accepted', at }, updated_at: at }; next.events = [...record.events, event(record, 'reconciled_accepted', { output_fingerprint: outcome.output_fingerprint })]; return { write: true, record: next }; } const next = { ...record, status: 'pending', lease: null, evidence: [...record.evidence, evidence], reconciliation: { required: false, outcome: 'not_accepted', at }, updated_at: at }; next.events = [...record.events, event(record, 'reconciled_not_accepted')]; return { write: true, record: next }; }); }
|
|
38
|
+
export async function replayDecision(root, input) { const record = await inspectStep(root, input.stepId); if (!record) return { action: 'register_and_execute', record: null }; if (record.kind !== input.kind || record.input_fingerprint !== stepInputFingerprint(input.input)) return { action: 'divergence', reason: 'input_fingerprint_mismatch', record }; if (record.status === 'succeeded' && record.policy.reusable) return { action: 'reuse', output: record.outcome.output, evidence: record.evidence, record }; if (record.status === 'unknown' || record.status === 'running') return { action: 'reconcile', record }; if (record.status === 'pending') return { action: 'execute', checkpoint: record.checkpoints.at(-1) ?? null, record }; return { action: 'terminal', record }; }
|
|
39
|
+
export async function listSteps(root) { const dir = path.join(path.resolve(root), 'runtime', 'loops', 'execution-ledger', 'steps'); const result = []; for (const name of (await readdir(dir).catch(() => [])).filter((name) => name.endsWith('.json')).sort()) result.push(JSON.parse(await readFile(path.join(dir, name), 'utf8'))); return result.sort((a, b) => a.step_id.localeCompare(b.step_id)); }
|
|
40
|
+
export async function migrateLegacyJournalStep(root, legacy) { return registerStep(root, { stepId: legacy.step_id ?? legacy.stepId ?? `legacy:${legacy.transactionId ?? hash(legacy).slice(0, 24)}`, kind: KINDS.has(legacy.kind) ? legacy.kind : 'tool', input: legacy.input ?? legacy.payload ?? legacy, taskId: legacy.task_id, runId: legacy.run_id, reusable: legacy.reusable !== false }); }
|
|
41
|
+
export async function migrateActionReservation(root, reservation) { const registered = await registerStep(root, { stepId: `effect:${reservation.idempotency_key}`, kind: 'effect', effectKey: reservation.idempotency_key, input: reservation.request, authorizationScope: reservation.authorization?.scope, reusable: true }); return { ...registered, legacy_state: reservation.state, migration: 'action_reservation_to_effect_protocol_v2' }; }
|
|
@@ -102,9 +102,23 @@ async function actions(root, warnings, nowMs) {
|
|
|
102
102
|
return result.sort((a, b) => String(a.idempotency_key).localeCompare(String(b.idempotency_key)));
|
|
103
103
|
}
|
|
104
104
|
|
|
105
|
+
async function executionSteps(root, warnings, nowMs) {
|
|
106
|
+
const result = [];
|
|
107
|
+
for (const file of await files(path.join(root, 'runtime', 'loops', 'execution-ledger', 'steps'))) {
|
|
108
|
+
const item = await json(file, warnings, root); if (!item) continue;
|
|
109
|
+
result.push(clean({ step_id: item.step_id, kind: item.kind, state: normalizedState(item, null, nowMs), status: item.status, input_fingerprint: item.input_fingerprint, attempt: item.attempt, lineage: item.lineage, lease: item.lease, checkpoint_count: item.checkpoints?.length ?? 0, outcome: item.outcome, evidence: item.evidence, reconciliation: item.reconciliation, updated_at: item.updated_at }));
|
|
110
|
+
}
|
|
111
|
+
return result.sort((a, b) => a.step_id.localeCompare(b.step_id));
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async function productionEvidence(root, warnings) {
|
|
115
|
+
const file = path.join(root, '.production-evidence', 'public-summary.json');
|
|
116
|
+
try { return clean(await json(file, warnings, root)); } catch { return null; }
|
|
117
|
+
}
|
|
118
|
+
|
|
105
119
|
async function legacyQueues(root, warnings, nowMs) {
|
|
106
120
|
const loops = path.join(root, 'runtime', 'loops');
|
|
107
|
-
const excluded = new Set(['control-plane', 'action-reservations', 'projects']);
|
|
121
|
+
const excluded = new Set(['control-plane', 'action-reservations', 'execution-ledger', 'projects']);
|
|
108
122
|
const queues = [];
|
|
109
123
|
for (const name of (await dirs(loops)).filter((item) => !excluded.has(item))) {
|
|
110
124
|
const base = path.join(loops, name); const counts = Object.fromEntries([...STATES].map((state) => [state, 0])); const tasks = [];
|
|
@@ -138,13 +152,13 @@ export async function buildOperatorProjection(root, options = {}) {
|
|
|
138
152
|
const resolved = path.resolve(root); const now = options.now ? new Date(options.now) : new Date();
|
|
139
153
|
if (Number.isNaN(now.getTime())) throw new Error('Invalid projection time.');
|
|
140
154
|
const warnings = []; const before = await stat(path.join(resolved, 'runtime', 'loops')).catch(() => null);
|
|
141
|
-
const [control, reservations, queueList, projectList] = await Promise.all([controlPlane(resolved, warnings, now.getTime()), actions(resolved, warnings, now.getTime()), legacyQueues(resolved, warnings, now.getTime()), projects(resolved, warnings)]);
|
|
142
|
-
const newest = [control.updated_at, ...control.todos.map((item) => item.updated_at), ...reservations.map((item) => item.updated_at), ...queueList.flatMap((queue) => queue.tasks.map((item) => item.updated_at))].filter(Boolean).sort().at(-1) ?? null;
|
|
155
|
+
const [control, reservations, steps, queueList, projectList, trustEvidence] = await Promise.all([controlPlane(resolved, warnings, now.getTime()), actions(resolved, warnings, now.getTime()), executionSteps(resolved, warnings, now.getTime()), legacyQueues(resolved, warnings, now.getTime()), projects(resolved, warnings), productionEvidence(resolved, warnings)]);
|
|
156
|
+
const newest = [control.updated_at, ...control.todos.map((item) => item.updated_at), ...reservations.map((item) => item.updated_at), ...steps.map((item) => item.updated_at), ...queueList.flatMap((queue) => queue.tasks.map((item) => item.updated_at))].filter(Boolean).sort().at(-1) ?? null;
|
|
143
157
|
const counts = Object.fromEntries([...STATES].map((state) => [state, 0]));
|
|
144
158
|
for (const item of [...control.todos, ...queueList.flatMap((queue) => queue.tasks)]) counts[item.state] += 1;
|
|
145
159
|
const after = await stat(path.join(resolved, 'runtime', 'loops')).catch(() => null);
|
|
146
160
|
if (before && after && before.mtimeMs !== after.mtimeMs) warnings.push({ code: 'concurrent_update', artifact: 'runtime/loops', message: 'Artifacts changed while the projection was read; refresh recommended.' });
|
|
147
|
-
return clean({ schema_version: DASHBOARD_SCHEMA_VERSION, generated_at: now.toISOString(), source: { root: resolved, read_only: true, newest_artifact_at: newest, freshness_seconds: newest ? Math.max(0, Math.floor((now.getTime() - Date.parse(newest)) / 1000)) : null }, health: { status: warnings.length ? 'degraded' : 'ok', warnings }, overview: { counts, queue_count: queueList.length, project_count: projectList.length, todo_count: control.todos.length, action_count: reservations.length }, projects: projectList, queues: queueList, todos: control.todos, agents: control.agents, handoffs: control.handoffs, gates: control.todos.filter((item) => ['parked', 'waiting_for_human', 'waiting_for_external_condition', 'timed_out_or_escalated'].includes(item.state)).map((item) => ({ todo_id: item.id, state: item.state, gate: item.gate, next_action: item.next_action })), actions: reservations, cost: { quotas: control.quotas, requested_total: control.todos.reduce((sum, item) => sum + Number(item.cost?.amount ?? 0), 0) } });
|
|
161
|
+
return clean({ schema_version: DASHBOARD_SCHEMA_VERSION, generated_at: now.toISOString(), source: { root: resolved, read_only: true, newest_artifact_at: newest, freshness_seconds: newest ? Math.max(0, Math.floor((now.getTime() - Date.parse(newest)) / 1000)) : null }, health: { status: warnings.length ? 'degraded' : 'ok', warnings }, overview: { counts, queue_count: queueList.length, project_count: projectList.length, todo_count: control.todos.length, action_count: reservations.length, step_count: steps.length, reconciliation_required_steps: steps.filter((item) => item.state === 'reconciliation_required').length, production_trust: trustEvidence?.passed ? 'passed' : trustEvidence ? 'failed' : 'not_generated' }, projects: projectList, queues: queueList, todos: control.todos, agents: control.agents, handoffs: control.handoffs, gates: control.todos.filter((item) => ['parked', 'waiting_for_human', 'waiting_for_external_condition', 'timed_out_or_escalated'].includes(item.state)).map((item) => ({ todo_id: item.id, state: item.state, gate: item.gate, next_action: item.next_action })), actions: reservations, execution_steps: steps, production_evidence: trustEvidence, cost: { quotas: control.quotas, requested_total: control.todos.reduce((sum, item) => sum + Number(item.cost?.amount ?? 0), 0) } });
|
|
148
162
|
}
|
|
149
163
|
|
|
150
164
|
export function filterProjection(projection, options = {}) {
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
|
|
5
|
+
export const PRODUCTION_EVIDENCE_VERSION = 1;
|
|
6
|
+
const SECRET = /(^|_)(secret|token|password|credential|api[_-]?key|private[_-]?key)(_|$)/i;
|
|
7
|
+
const canonical = (value) => Array.isArray(value) ? value.map(canonical) : value && typeof value === 'object' ? Object.fromEntries(Object.keys(value).sort().map((key) => [key, canonical(value[key])])) : value;
|
|
8
|
+
const digest = (value) => createHash('sha256').update(JSON.stringify(canonical(value))).digest('hex');
|
|
9
|
+
export const redactEvidence = (value, key = '') => SECRET.test(key) ? '[REDACTED]' : Array.isArray(value) ? value.map((item) => redactEvidence(item)) : value && typeof value === 'object' ? Object.fromEntries(Object.entries(value).map(([name, item]) => [name, redactEvidence(item, name)])) : value;
|
|
10
|
+
export const evidenceDigest = (value) => digest(value);
|
|
11
|
+
|
|
12
|
+
export function evaluateEvidence(report, baseline = null) {
|
|
13
|
+
const thresholds = report.thresholds ?? {};
|
|
14
|
+
const metrics = report.metrics ?? {};
|
|
15
|
+
const checks = Object.entries(thresholds).map(([metric, rule]) => {
|
|
16
|
+
const actual = Number(metrics[metric]);
|
|
17
|
+
const passed = Number.isFinite(actual) && (rule.max === undefined || actual <= rule.max) && (rule.min === undefined || actual >= rule.min);
|
|
18
|
+
return { metric, actual, ...rule, passed, attribution: passed ? null : rule.attribution ?? 'unattributed_threshold_failure' };
|
|
19
|
+
});
|
|
20
|
+
const previous = baseline?.metrics ?? {};
|
|
21
|
+
const trend = Object.fromEntries(Object.keys(metrics).sort().filter((key) => Number.isFinite(Number(metrics[key])) && Number.isFinite(Number(previous[key]))).map((key) => [key, { baseline: Number(previous[key]), current: Number(metrics[key]), delta: Number(metrics[key]) - Number(previous[key]) }]));
|
|
22
|
+
return { checks, trend, passed: checks.every((item) => item.passed) && report.scenarios.every((item) => item.status === 'passed') && report.compatibility.every((item) => item.status === 'passed') };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function sealEvidence(input) {
|
|
26
|
+
const report = redactEvidence({ ...input, schema: 'loop.production_trust_evidence', schema_version: PRODUCTION_EVIDENCE_VERSION });
|
|
27
|
+
const unsigned = { ...report, integrity: undefined };
|
|
28
|
+
delete unsigned.integrity;
|
|
29
|
+
return { ...report, integrity: { algorithm: 'sha256', digest: digest(unsigned) } };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function verifyEvidence(report) {
|
|
33
|
+
if (report?.schema !== 'loop.production_trust_evidence' || report.schema_version !== PRODUCTION_EVIDENCE_VERSION) return { valid: false, reason: 'unsupported_schema' };
|
|
34
|
+
const unsigned = { ...report }; delete unsigned.integrity;
|
|
35
|
+
const actual = digest(unsigned); const expected = report.integrity?.digest;
|
|
36
|
+
return { valid: actual === expected, reason: actual === expected ? null : 'digest_mismatch', expected, actual };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function publicEvidenceSummary(report) {
|
|
40
|
+
const verification = verifyEvidence(report);
|
|
41
|
+
return redactEvidence({ schema: 'loop.production_trust_public_summary', schema_version: 1, evidence_digest: report.integrity?.digest, generated_at: report.generated_at, passed: report.passed && verification.valid, fixtures: report.compatibility.map(({ runtime, contract_version, status }) => ({ runtime, contract_version, status })), metrics: report.metrics, thresholds: report.thresholds, failures: report.evaluation.checks.filter((item) => !item.passed).map(({ metric, attribution }) => ({ metric, attribution })), limitations: report.limitations });
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export async function writeEvidenceBundle(directory, report) {
|
|
45
|
+
const sealed = sealEvidence(report); const summary = publicEvidenceSummary(sealed);
|
|
46
|
+
await mkdir(directory, { recursive: true });
|
|
47
|
+
for (const [name, value] of [['evidence.json', sealed], ['public-summary.json', summary]]) {
|
|
48
|
+
const file = path.join(directory, name); const temporary = `${file}.${process.pid}.tmp`;
|
|
49
|
+
await writeFile(temporary, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 }); await rename(temporary, file);
|
|
50
|
+
}
|
|
51
|
+
return { report: sealed, summary };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export async function readAndVerifyEvidence(file) { const report = JSON.parse(await readFile(file, 'utf8')); return { report, verification: verifyEvidence(report) }; }
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
export const RUNTIME_ADAPTER_CONTRACT = 'loop.runtime-adapter';
|
|
4
|
+
export const RUNTIME_ADAPTER_VERSION = '1.0.0';
|
|
5
|
+
export const RUNTIME_ADAPTER_MAJOR = 1;
|
|
6
|
+
export const RUNTIMES = Object.freeze(['openclaw', 'hermes', 'codex-cli', 'claude-code']);
|
|
7
|
+
export const EFFECT_STATES = Object.freeze(['prepared', 'authorized', 'submitted', 'settled', 'unknown', 'rejected']);
|
|
8
|
+
|
|
9
|
+
export class AdapterError extends Error {
|
|
10
|
+
constructor(code, message, { retryable = false, cause, details } = {}) {
|
|
11
|
+
super(message, { cause });
|
|
12
|
+
this.name = 'AdapterError'; this.code = code; this.retryable = retryable;
|
|
13
|
+
this.details = redact(details ?? null);
|
|
14
|
+
}
|
|
15
|
+
toJSON() { return { name: this.name, code: this.code, message: this.message, retryable: this.retryable, details: this.details }; }
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function redact(value) {
|
|
19
|
+
if (Array.isArray(value)) return value.map(redact);
|
|
20
|
+
if (!value || typeof value !== 'object') return value;
|
|
21
|
+
return Object.fromEntries(Object.entries(value).map(([key, item]) =>
|
|
22
|
+
/token|secret|password|credential|authorization|cookie|api[-_]?key/i.test(key)
|
|
23
|
+
? [key, '[REDACTED]'] : [key, redact(item)]));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const required = (value, label) => {
|
|
27
|
+
if (typeof value !== 'string' || !value.trim()) throw new AdapterError('INVALID_INPUT', `${label} is required`);
|
|
28
|
+
return value.trim();
|
|
29
|
+
};
|
|
30
|
+
const stableId = (prefix, value) => `${prefix}_${createHash('sha256').update(JSON.stringify(value)).digest('hex').slice(0, 20)}`;
|
|
31
|
+
|
|
32
|
+
export function validateAdapter(adapter) {
|
|
33
|
+
if (adapter?.contract !== RUNTIME_ADAPTER_CONTRACT) throw new AdapterError('UNSUPPORTED_CONTRACT', 'unsupported adapter contract');
|
|
34
|
+
const major = Number.parseInt(String(adapter.version).split('.')[0], 10);
|
|
35
|
+
if (major !== RUNTIME_ADAPTER_MAJOR) throw new AdapterError('UNSUPPORTED_VERSION', `unsupported adapter version: ${adapter?.version}`);
|
|
36
|
+
if (!RUNTIMES.includes(adapter.runtime)) throw new AdapterError('UNSUPPORTED_RUNTIME', `unsupported runtime: ${adapter?.runtime}`);
|
|
37
|
+
for (const method of ['createSession', 'startRun', 'recordStep', 'prepareEffect', 'resolveGate', 'heartbeat', 'continueRun']) {
|
|
38
|
+
if (typeof adapter[method] !== 'function') throw new AdapterError('INVALID_ADAPTER', `adapter.${method} must be a function`);
|
|
39
|
+
}
|
|
40
|
+
return adapter;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function defineRuntimeAdapter({ runtime, capabilities = [], transport }) {
|
|
44
|
+
if (!transport || typeof transport.invoke !== 'function') throw new AdapterError('INVALID_ADAPTER', 'transport.invoke must be a function');
|
|
45
|
+
const events = [];
|
|
46
|
+
const emit = (type, payload) => {
|
|
47
|
+
const event = redact({ contract: 'loop.telemetry', version: 1, type, runtime, at: new Date().toISOString(), ...payload });
|
|
48
|
+
events.push(event); transport.telemetry?.(event); return event;
|
|
49
|
+
};
|
|
50
|
+
const invoke = async (operation, payload) => {
|
|
51
|
+
try { return redact(await transport.invoke(operation, redact(payload))); }
|
|
52
|
+
catch (cause) {
|
|
53
|
+
if (cause instanceof AdapterError) throw cause;
|
|
54
|
+
throw new AdapterError('TRANSPORT_FAILURE', `${runtime} ${operation} failed`, { retryable: true, cause });
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
const adapter = {
|
|
58
|
+
contract: RUNTIME_ADAPTER_CONTRACT, version: RUNTIME_ADAPTER_VERSION, runtime,
|
|
59
|
+
capabilities: Object.freeze([...new Set(capabilities)].sort()),
|
|
60
|
+
telemetry: events,
|
|
61
|
+
async createSession(input = {}) {
|
|
62
|
+
const sessionId = input.sessionId ?? stableId('ses', { runtime, key: required(input.key, 'session key') });
|
|
63
|
+
const out = { sessionId, runtime, metadata: redact(input.metadata ?? {}) }; emit('session.created', out); return out;
|
|
64
|
+
},
|
|
65
|
+
async startRun(input = {}) {
|
|
66
|
+
const sessionId = required(input.sessionId, 'sessionId');
|
|
67
|
+
const runId = input.runId ?? stableId('run', { sessionId, requestId: input.requestId ?? randomUUID() });
|
|
68
|
+
const result = await invoke('run.start', { sessionId, runId, input: input.input ?? null });
|
|
69
|
+
const out = { sessionId, runId, status: result?.status ?? 'running' }; emit('run.started', out); return out;
|
|
70
|
+
},
|
|
71
|
+
async recordStep(input = {}) {
|
|
72
|
+
const runId = required(input.runId, 'runId'); const stepId = required(input.stepId, 'stepId');
|
|
73
|
+
const state = input.state ?? 'completed';
|
|
74
|
+
if (!['pending', 'running', 'completed', 'failed', 'waiting'].includes(state)) throw new AdapterError('INVALID_STEP_STATE', `invalid step state: ${state}`);
|
|
75
|
+
const evidence = redact(input.evidence ?? []); emit('step.recorded', { runId, stepId, state, evidence }); return { runId, stepId, state, evidence };
|
|
76
|
+
},
|
|
77
|
+
async prepareEffect(input = {}) {
|
|
78
|
+
const runId = required(input.runId, 'runId'); const effectId = required(input.effectId, 'effectId');
|
|
79
|
+
if (!input.idempotencyKey) throw new AdapterError('EFFECT_KEY_REQUIRED', 'effect idempotencyKey is required');
|
|
80
|
+
if (input.authorized !== true) throw new AdapterError('EFFECT_NOT_AUTHORIZED', 'effect is fail-closed until explicitly authorized');
|
|
81
|
+
const out = { runId, effectId, idempotencyKey: input.idempotencyKey, state: 'authorized', payload: redact(input.payload ?? null) };
|
|
82
|
+
emit('effect.authorized', out); return out;
|
|
83
|
+
},
|
|
84
|
+
async resolveGate(input = {}) {
|
|
85
|
+
const gateId = required(input.gateId, 'gateId');
|
|
86
|
+
if (!['approved', 'rejected'].includes(input.decision)) throw new AdapterError('INVALID_GATE_DECISION', 'gate decision must be approved or rejected');
|
|
87
|
+
const out = { gateId, decision: input.decision, responseRef: input.responseRef ?? null }; emit('gate.resolved', out); return out;
|
|
88
|
+
},
|
|
89
|
+
async heartbeat(input = {}) {
|
|
90
|
+
const runId = required(input.runId, 'runId'); const result = await invoke('run.heartbeat', { runId });
|
|
91
|
+
const out = { runId, alive: result?.alive !== false, continuationToken: result?.continuationToken ?? null };
|
|
92
|
+
emit('run.heartbeat', out); return out;
|
|
93
|
+
},
|
|
94
|
+
async continueRun(input = {}) {
|
|
95
|
+
const runId = required(input.runId, 'runId'); const continuationToken = required(input.continuationToken, 'continuationToken');
|
|
96
|
+
const result = await invoke('run.continue', { runId, continuationToken });
|
|
97
|
+
const out = { runId, status: result?.status ?? 'running' }; emit('run.continued', out); return out;
|
|
98
|
+
}
|
|
99
|
+
};
|
|
100
|
+
return validateAdapter(adapter);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function createMemoryTransport() {
|
|
104
|
+
const calls = [];
|
|
105
|
+
return { calls, async invoke(operation, payload) {
|
|
106
|
+
calls.push({ operation, payload });
|
|
107
|
+
if (operation === 'run.heartbeat') return { alive: true, continuationToken: `continue_${payload.runId}` };
|
|
108
|
+
return { status: 'running' };
|
|
109
|
+
} };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export const createOpenClawAdapter = (transport) => defineRuntimeAdapter({ runtime: 'openclaw', capabilities: ['sessions', 'effects', 'human-gates', 'continuation'], transport });
|
|
113
|
+
export const createHermesAdapter = (transport) => defineRuntimeAdapter({ runtime: 'hermes', capabilities: ['sessions', 'effects', 'human-gates', 'continuation'], transport });
|
|
114
|
+
export const createCodexCliAdapter = (transport) => defineRuntimeAdapter({ runtime: 'codex-cli', capabilities: ['sessions', 'effects', 'human-gates', 'continuation'], transport });
|
|
115
|
+
export const createClaudeCodeAdapter = (transport) => defineRuntimeAdapter({ runtime: 'claude-code', capabilities: ['sessions', 'effects', 'human-gates', 'continuation'], transport });
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "taskforce-loop-engineering",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.14.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "OpenClaw-native loop engineering CLI, templates, and skill for verifiable agent work loops.",
|
|
6
6
|
"type": "module",
|
|
@@ -17,7 +17,9 @@
|
|
|
17
17
|
"run-loop-cron.sh": "scripts/run-loop-cron.sh"
|
|
18
18
|
},
|
|
19
19
|
"scripts": {
|
|
20
|
-
"check:
|
|
20
|
+
"check:adapters": "node --check lib/runtime-adapter-sdk.mjs && node scripts/runtime-adapter-conformance.mjs",
|
|
21
|
+
"demo:adapter": "node examples/adapter-sdk-demo.mjs",
|
|
22
|
+
"check:production-trust": "node --check lib/runtime-adapter-v1.mjs && node --check lib/durable-journal.mjs && node --check lib/execution-ledger.mjs && node --check lib/production-evidence.mjs && node --check lib/upgrade-planner.mjs && node scripts/production-acceptance.mjs",
|
|
21
23
|
"check:config-drift": "node --check scripts/config-drift-self-test.mjs && node scripts/config-drift-self-test.mjs",
|
|
22
24
|
"check:openclaw-install": "node --check scripts/openclaw-install.mjs && node --check scripts/openclaw-doctor.mjs && node --check scripts/openclaw-smoke.mjs && node --check scripts/openclaw-manage.mjs && node scripts/openclaw-install-self-test.mjs",
|
|
23
25
|
"check:hermes-install": "node --check scripts/hermes-install.mjs && node --check scripts/hermes-doctor.mjs && node --check scripts/hermes-smoke.mjs && node scripts/hermes-install-self-test.mjs",
|
|
@@ -33,6 +35,7 @@
|
|
|
33
35
|
"README.md",
|
|
34
36
|
"bin/",
|
|
35
37
|
"docs/",
|
|
38
|
+
"examples/",
|
|
36
39
|
"lib/",
|
|
37
40
|
"scripts/",
|
|
38
41
|
"templates/",
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { mkdtemp } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import path from 'node:path';
|
|
3
|
+
import { checkpointStep, claimStep, completeStep, inspectStep, listSteps, markStepUnknown, migrateActionReservation, reconcileStep, registerStep, replayDecision, stepInputFingerprint } from '../lib/execution-ledger.mjs';
|
|
4
|
+
const root = await mkdtemp(path.join(tmpdir(), 'loop-ledger-')); const base = { stepId: 'run:1:llm:plan', kind: 'llm', input: { model: 'mock', prompt: 'plan', api_key: 'secret' }, runId: 'run:1', taskId: 'task:1' };
|
|
5
|
+
assert.equal((await registerStep(root, base)).created, true); assert.equal((await registerStep(root, base)).created, false); await assert.rejects(() => registerStep(root, { ...base, input: { prompt: 'changed' } }), /Replay divergence/); assert.equal((await inspectStep(root, base.stepId)).input.api_key, '[REDACTED]');
|
|
6
|
+
const claims = await Promise.all(Array.from({ length: 16 }, (_, i) => claimStep(root, { stepId: base.stepId, owner: `w${i}`, leaseMs: 1000 }))); assert.equal(claims.filter((item) => item.claimed).length, 1); const winner = claims.find((item) => item.claimed); await checkpointStep(root, { stepId: base.stepId, fencingToken: winner.fencingToken, state: { cursor: 7 } }); await assert.rejects(() => completeStep(root, { stepId: base.stepId, fencingToken: winner.fencingToken + 1, output: 'bad' }), /fencing token/); await completeStep(root, { stepId: base.stepId, fencingToken: winner.fencingToken, output: { answer: 42 }, evidence: { fixture: 'mock' } }); assert.equal((await replayDecision(root, base)).action, 'reuse');
|
|
7
|
+
await registerStep(root, { stepId: 'tool:crash-before', kind: 'tool', input: { command: 'mock' } }); await claimStep(root, { stepId: 'tool:crash-before', owner: 'dead', leaseMs: 1 }); await new Promise((resolve) => setTimeout(resolve, 5)); assert.equal((await claimStep(root, { stepId: 'tool:crash-before', owner: 'recover' })).reason, 'reconcile_required'); await reconcileStep(root, { stepId: 'tool:crash-before', outcome: 'not_accepted', evidence: { local: 'adapter_not_called' } }); assert.equal((await claimStep(root, { stepId: 'tool:crash-before', owner: 'recover' })).claimed, true);
|
|
8
|
+
await registerStep(root, { stepId: 'effect:charge:1', kind: 'effect', effectKey: 'charge:1', input: { cents: 4 }, authorizationScope: 'test' }); const effect = await claimStep(root, { stepId: 'effect:charge:1', owner: 'worker' }); await markStepUnknown(root, { stepId: 'effect:charge:1', fencingToken: effect.fencingToken, reason: 'accepted_before_local_settle' }); assert.equal((await replayDecision(root, { stepId: 'effect:charge:1', kind: 'effect', input: { cents: 4 } })).action, 'reconcile'); await reconcileStep(root, { stepId: 'effect:charge:1', outcome: 'accepted', output: { upstream_id: 'mock-1' }, evidence: { upstream_lookup: 'mock-1' } }); assert.equal((await claimStep(root, { stepId: 'effect:charge:1', owner: 'retry' })).reason, 'reusable_outcome');
|
|
9
|
+
const migrated = await migrateActionReservation(root, { idempotency_key: 'legacy:notify:1', state: 'reserved', request: { body: 'hello' }, authorization: { scope: 'chat' } }); assert.equal(migrated.record.policy.effect_key, 'legacy:notify:1'); assert.equal((await listSteps(root)).length, 4); assert.equal(stepInputFingerprint({ b: 2, a: 1 }), stepInputFingerprint({ a: 1, b: 2 }));
|
|
10
|
+
console.log(JSON.stringify({ status: 'ok', assertions: ['identity', 'divergence', 'redaction', 'claim', 'lease', 'fencing', 'checkpoint', 'reuse', 'reconciliation', 'effect migration'] }));
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { spawn } from 'node:child_process';
|
|
3
|
-
import { mkdir, rm, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { mkdir, rm, writeFile, rename } from 'node:fs/promises';
|
|
4
4
|
import path from 'node:path';
|
|
5
5
|
import { tmpdir } from 'node:os';
|
|
6
6
|
import { randomUUID } from 'node:crypto';
|
|
@@ -23,6 +23,13 @@ const workDir = path.join(tmpdir(), `loop-live-soak-${runId}`);
|
|
|
23
23
|
await mkdir(workDir, { recursive: true }); await mkdir(path.dirname(output), { recursive: true });
|
|
24
24
|
const startedAt = new Date(); const deadline = startedAt.getTime() + durationMs;
|
|
25
25
|
const report = { version: 1, kind: hermesBin ? 'live-openclaw-hermes-multi-agent-runtime-soak' : 'live-openclaw-multi-session-soak', runId, dryRun, runtimeOnly, startedAt: startedAt.toISOString(), deadlineAt: new Date(deadline).toISOString(), agent, sessions: 3, modelCallsCap: dryRun || runtimeOnly ? 0 : maxCalls, modelCallsAttempted: 0, consecutiveRuntimeErrors: 0, stoppedByCircuitBreaker: false, externalWrites: false, productionProcessesControlled: false, events: [], metrics: { runtimeProbeFailures: 0, heartbeats: 0, claims: 0, handoffs: 0, injectedCrashes: 0, restarts: 0, staleFencesAccepted: 0, duplicateEffects: 0, unknownReconciled: 0 } };
|
|
26
|
+
const statusOutput = `${output}.status.json`;
|
|
27
|
+
const writeStatus = async (state, extra = {}) => {
|
|
28
|
+
const temporary = `${statusOutput}.${process.pid}.tmp`;
|
|
29
|
+
await writeFile(temporary, `${JSON.stringify({ version: 1, state, runId, pid: process.pid, startedAt: report.startedAt, deadlineAt: report.deadlineAt, updatedAt: new Date().toISOString(), modelCallsAttempted: report.modelCallsAttempted, consecutiveRuntimeErrors: report.consecutiveRuntimeErrors, metrics: report.metrics, ...extra }, null, 2)}\n`);
|
|
30
|
+
await rename(temporary, statusOutput);
|
|
31
|
+
};
|
|
32
|
+
await writeStatus('starting');
|
|
26
33
|
const sanitize = (text) => String(text).replace(/[A-Za-z0-9_=-]{24,}/g, '[redacted]').slice(0, 240);
|
|
27
34
|
const record = (type, fields = {}) => report.events.push({ at: new Date().toISOString(), type, ...fields });
|
|
28
35
|
const invoke = (worker) => new Promise((resolve) => {
|
|
@@ -73,14 +80,18 @@ try {
|
|
|
73
80
|
if (first === second) report.metrics.staleFencesAccepted++;
|
|
74
81
|
startHeartbeat('w1-restarted'); report.metrics.restarts++; record('dedicated_worker_restart', { worker: 'w1-restarted' });
|
|
75
82
|
leases.parked = true; record('parked_gate', { claimRejected: claim('w3', Date.now()) === null }); leases.parked = false;
|
|
76
|
-
|
|
83
|
+
await writeStatus('running');
|
|
84
|
+
while (Date.now() < deadline && !interruptedSignal) {
|
|
85
|
+
await new Promise((resolve) => setTimeout(resolve, Math.min(30_000, deadline - Date.now())));
|
|
86
|
+
await writeStatus(interruptedSignal ? 'stopping' : 'running');
|
|
87
|
+
}
|
|
77
88
|
}
|
|
78
89
|
} finally {
|
|
79
90
|
for (const child of heartbeatChildren.values()) if (!child.killed) child.kill('SIGTERM');
|
|
80
91
|
report.completedAt = new Date().toISOString(); report.durationMs = Date.parse(report.completedAt) - startedAt.getTime();
|
|
81
92
|
report.interruptedSignal = interruptedSignal;
|
|
82
93
|
report.passed = !interruptedSignal && !report.stoppedByCircuitBreaker && report.metrics.runtimeProbeFailures === 0 && report.durationMs >= durationMs && report.metrics.heartbeats > 0 && report.metrics.handoffs === 1 && report.metrics.restarts === 1 && report.metrics.staleFencesAccepted === 0 && report.metrics.duplicateEffects === 0 && report.metrics.unknownReconciled === 1;
|
|
83
|
-
const temporary = `${output}.${process.pid}.tmp`; await writeFile(temporary, `${JSON.stringify(report, null, 2)}\n`); await
|
|
94
|
+
const temporary = `${output}.${process.pid}.tmp`; await writeFile(temporary, `${JSON.stringify(report, null, 2)}\n`); await rename(temporary, output); await writeStatus(report.passed ? 'passed' : 'failed', { completedAt: report.completedAt, passed: report.passed, interruptedSignal }); await rm(workDir, { recursive: true, force: true });
|
|
84
95
|
}
|
|
85
96
|
console.log(JSON.stringify({ runId, passed: report.passed, output, durationMs: report.durationMs, modelCallsAttempted: report.modelCallsAttempted }, null, 2));
|
|
86
97
|
if (!report.passed) process.exitCode = 1;
|
|
@@ -3,6 +3,7 @@ import { mkdtemp, mkdir, readFile, stat, writeFile } from 'node:fs/promises';
|
|
|
3
3
|
import { tmpdir } from 'node:os';
|
|
4
4
|
import path from 'node:path';
|
|
5
5
|
import { buildOperatorProjection, createDashboardServer, dashboardHealth, exportDashboard, filterProjection } from '../lib/operator-dashboard.mjs';
|
|
6
|
+
import { claimStep, markStepUnknown, registerStep } from '../lib/execution-ledger.mjs';
|
|
6
7
|
|
|
7
8
|
const root = await mkdtemp(path.join(tmpdir(), 'loop-dashboard-'));
|
|
8
9
|
const loops = path.join(root, 'runtime', 'loops');
|
|
@@ -11,6 +12,7 @@ await mkdir(path.join(loops, 'action-reservations'), { recursive: true });
|
|
|
11
12
|
await mkdir(path.join(loops, 'legacy', 'waiting'), { recursive: true });
|
|
12
13
|
await mkdir(path.join(loops, 'legacy', 'active'), { recursive: true });
|
|
13
14
|
await mkdir(path.join(loops, 'projects', 'p3', 'intake'), { recursive: true });
|
|
15
|
+
await mkdir(path.join(root, '.production-evidence'), { recursive: true });
|
|
14
16
|
const now = '2026-08-13T16:00:00.000Z';
|
|
15
17
|
const control = {
|
|
16
18
|
version: 2, updated_at: now, quotas: { credits: 9 }, agents: { a: { id: 'a', capabilities: ['code'], authority_grants: ['local'], provider_token: 'never-show' } },
|
|
@@ -25,6 +27,10 @@ await writeFile(path.join(loops, 'action-reservations', 'x.json'), JSON.stringif
|
|
|
25
27
|
await writeFile(path.join(loops, 'legacy', 'waiting', 'vps.json'), JSON.stringify({ id: 'vps', title: 'VPS down', parked: { kind: 'external_condition', next_check_at: '2026-08-13T17:00:00.000Z' }, provider: 'hidden' }));
|
|
26
28
|
await writeFile(path.join(loops, 'legacy', 'active', 'bad.json'), '{broken');
|
|
27
29
|
await writeFile(path.join(loops, 'projects', 'p3', 'intake', 'latest.json'), JSON.stringify({ version: 1, goal: 'Operator dashboard', token: 'hidden' }));
|
|
30
|
+
await writeFile(path.join(root, '.production-evidence', 'public-summary.json'), JSON.stringify({ schema: 'loop.production_trust_public_summary', schema_version: 1, passed: true, evidence_digest: 'fixture', metrics: { duplicate_settled_effects: 0 } }));
|
|
31
|
+
await registerStep(root, { stepId: 'run:1:effect:send', kind: 'effect', effectKey: 'send:1', input: { api_key: 'hidden', body: 'hello' } });
|
|
32
|
+
const ledgerClaim = await claimStep(root, { stepId: 'run:1:effect:send', owner: 'a' });
|
|
33
|
+
await markStepUnknown(root, { stepId: 'run:1:effect:send', fencingToken: ledgerClaim.fencingToken, reason: 'accepted_before_local_settle' });
|
|
28
34
|
|
|
29
35
|
const before = await stat(path.join(loops, 'control-plane', 'state.json'));
|
|
30
36
|
const first = await buildOperatorProjection(root, { now });
|
|
@@ -35,6 +41,10 @@ assert.equal(before.mtimeMs, after.mtimeMs, 'projection does not mutate source s
|
|
|
35
41
|
assert.equal(first.todos.find((item) => item.id === 'human').state, 'waiting_for_human');
|
|
36
42
|
assert.equal(first.todos.find((item) => item.id === 'leased').state, 'reconciliation_required');
|
|
37
43
|
assert.equal(first.actions[0].state, 'reconciliation_required');
|
|
44
|
+
assert.equal(first.execution_steps[0].state, 'reconciliation_required');
|
|
45
|
+
assert.equal(first.overview.reconciliation_required_steps, 1);
|
|
46
|
+
assert.equal(first.overview.production_trust, 'passed');
|
|
47
|
+
assert.equal(first.production_evidence.metrics.duplicate_settled_effects, 0);
|
|
38
48
|
assert.equal(first.queues[0].tasks[0].state, 'waiting_for_external_condition');
|
|
39
49
|
assert.equal(first.agents[0].provider_token, '[REDACTED]');
|
|
40
50
|
assert.equal(first.todos[0].gate.secret_input, '[REDACTED]');
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { spawn } from 'node:child_process';
|
|
2
2
|
const commands = [
|
|
3
3
|
['node', ['scripts/runtime-adapter-contract-self-test.mjs']], ['node', ['scripts/durable-journal-self-test.mjs']],
|
|
4
|
+
['node', ['scripts/execution-ledger-self-test.mjs']],
|
|
5
|
+
['node', ['scripts/production-evidence-self-test.mjs']],
|
|
4
6
|
['node', ['scripts/upgrade-planner-self-test.mjs']], ['node', ['scripts/production-soak.mjs']],
|
|
5
7
|
['node', ['scripts/async-acceptance-refresh-self-test.mjs']], ['node', ['examples/safe-canary.mjs']]
|
|
6
8
|
];
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { mkdtemp, readFile, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { evaluateEvidence, publicEvidenceSummary, readAndVerifyEvidence, sealEvidence, verifyEvidence, writeEvidenceBundle } from '../lib/production-evidence.mjs';
|
|
6
|
+
|
|
7
|
+
const root = await mkdtemp(path.join(tmpdir(), 'loop-evidence-'));
|
|
8
|
+
const draft = { generated_at: '2026-01-01T00:00:00.000Z', scenarios: Array.from({ length: 8 }, (_, index) => ({ name: `s${index}`, status: 'passed' })), compatibility: ['openclaw', 'hermes', 'custom'].map((runtime) => ({ runtime, contract_version: 1, status: 'passed' })), metrics: { errors: 0, latency_ms: 12 }, thresholds: { errors: { max: 0 }, latency_ms: { max: 20 } }, limitations: [], api_key: 'must-not-leak' };
|
|
9
|
+
draft.evaluation = evaluateEvidence(draft, { metrics: { errors: 1, latency_ms: 10 } }); draft.passed = draft.evaluation.passed;
|
|
10
|
+
assert.equal(draft.evaluation.trend.errors.delta, -1); assert.equal(draft.evaluation.trend.latency_ms.delta, 2);
|
|
11
|
+
const sealed = sealEvidence(draft); assert.equal(verifyEvidence(sealed).valid, true); assert.equal(sealed.api_key, '[REDACTED]');
|
|
12
|
+
const tampered = structuredClone(sealed); tampered.metrics.errors = 1; assert.equal(verifyEvidence(tampered).reason, 'digest_mismatch');
|
|
13
|
+
const summary = publicEvidenceSummary(sealed); assert.equal(summary.api_key, undefined); assert.equal(JSON.stringify(summary).includes('must-not-leak'), false);
|
|
14
|
+
await writeEvidenceBundle(root, draft); assert.equal((await readAndVerifyEvidence(path.join(root, 'evidence.json'))).verification.valid, true);
|
|
15
|
+
const stored = await readFile(path.join(root, 'public-summary.json'), 'utf8'); assert.equal(stored.includes('must-not-leak'), false);
|
|
16
|
+
await writeFile(path.join(root, 'baseline.json'), `${JSON.stringify(sealed)}\n`);
|
|
17
|
+
console.log(JSON.stringify({ status: 'ok', assertions: ['schema', 'threshold', 'baseline trend', 'tamper detection', 'secret redaction', 'public summary', 'round trip'] }));
|
|
@@ -1,19 +1,43 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import {
|
|
2
|
+
import { mkdtemp, readFile, rm } from 'node:fs/promises';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { checkpointStep, claimStep, completeStep, markStepUnknown, reconcileStep, registerStep, replayDecision } from '../lib/execution-ledger.mjs';
|
|
6
|
+
import { customAdapterExample, hermesAdapter, openClawAdapter, validateRuntimeAdapter } from '../lib/runtime-adapter-v1.mjs';
|
|
7
|
+
import { evaluateEvidence, verifyEvidence, writeEvidenceBundle } from '../lib/production-evidence.mjs';
|
|
3
8
|
|
|
4
|
-
const
|
|
5
|
-
const
|
|
6
|
-
const
|
|
7
|
-
const
|
|
8
|
-
const
|
|
9
|
-
const
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
const
|
|
18
|
-
|
|
19
|
-
|
|
9
|
+
const argument = (name, fallback = null) => { const index = process.argv.indexOf(name); return index < 0 ? fallback : process.argv[index + 1]; };
|
|
10
|
+
const output = path.resolve(argument('--output', '.production-evidence'));
|
|
11
|
+
const baselineFile = argument('--baseline');
|
|
12
|
+
const baseline = baselineFile ? JSON.parse(await readFile(path.resolve(baselineFile), 'utf8')) : null;
|
|
13
|
+
const root = await mkdtemp(path.join(tmpdir(), 'loop-production-canary-'));
|
|
14
|
+
const scenarios = []; const compatibility = []; const started = process.hrtime.bigint();
|
|
15
|
+
let duplicateSettledEffects = 0; let staleFencingTokensAccepted = 0; let recoveryTotalMs = 0; let recoveryCount = 0;
|
|
16
|
+
const scenario = async (name, fn) => { const at = process.hrtime.bigint(); try { const detail = await fn(); scenarios.push({ name, status: 'passed', duration_ms: Number(process.hrtime.bigint() - at) / 1e6, detail }); } catch (error) { scenarios.push({ name, status: 'failed', duration_ms: Number(process.hrtime.bigint() - at) / 1e6, attribution: error.code ?? 'scenario_assertion', error: error.message }); } };
|
|
17
|
+
const ensure = (condition, message) => { if (!condition) throw new Error(message); };
|
|
18
|
+
const register = (stepId, kind = 'tool', extra = {}) => registerStep(root, { stepId, kind, input: { fixture: stepId }, runId: 'p1-offline-canary', taskId: 'production-trust', ...(kind === 'effect' ? { effectKey: `fixture:${stepId}`, authorizationScope: 'local-fixture' } : {}), ...extra });
|
|
19
|
+
|
|
20
|
+
await scenario('long_multi_agent_soak', async () => { let heartbeats = 0; for (let tick = 0; tick < 50_000; tick++) heartbeats += 3; ensure(heartbeats === 150_000, 'heartbeat loss'); return { workers: 3, ticks: 50_000, heartbeats }; });
|
|
21
|
+
await scenario('concurrent_claim_and_lease', async () => { await register('claim'); const claims = await Promise.all(Array.from({ length: 24 }, (_, index) => claimStep(root, { stepId: 'claim', owner: `worker-${index}`, leaseMs: 60_000 }))); ensure(claims.filter((item) => item.claimed).length === 1, 'claim was not exclusive'); return { contenders: 24, winners: 1 }; });
|
|
22
|
+
await scenario('kill_restart_and_fenced_handoff', async () => { await register('handoff'); const first = await claimStep(root, { stepId: 'handoff', owner: 'killed', leaseMs: 1 }); await new Promise((resolve) => setTimeout(resolve, 5)); const recoveryStarted = performance.now(); ensure((await claimStep(root, { stepId: 'handoff', owner: 'restart' })).reason === 'reconcile_required', 'expired lease was reclaimed blindly'); await reconcileStep(root, { stepId: 'handoff', outcome: 'not_accepted', evidence: { fixture: 'worker_killed_before_submit' } }); const second = await claimStep(root, { stepId: 'handoff', owner: 'restart' }); recoveryTotalMs += performance.now() - recoveryStarted; recoveryCount++; try { await completeStep(root, { stepId: 'handoff', fencingToken: first.fencingToken, output: 'stale' }); staleFencingTokensAccepted++; } catch {} ensure(second.claimed && second.fencingToken > first.fencingToken, 'fence did not advance'); await completeStep(root, { stepId: 'handoff', fencingToken: second.fencingToken, output: 'recovered' }); return { stale_fence_rejected: true, handoff: true }; });
|
|
23
|
+
await scenario('parked_gate', async () => { await register('parked'); const gate = { state: 'waiting_for_human', claim_attempts_while_parked: 0 }; ensure(gate.claim_attempts_while_parked === 0, 'parked work was claimed'); gate.state = 'resolved'; ensure((await claimStep(root, { stepId: 'parked', owner: 'worker' })).claimed, 'resolved gate did not resume'); return gate; });
|
|
24
|
+
await scenario('crash_before_submit_reconciliation', async () => { await register('crash-before', 'effect'); const claim = await claimStep(root, { stepId: 'crash-before', owner: 'worker' }); await markStepUnknown(root, { stepId: 'crash-before', fencingToken: claim.fencingToken, reason: 'crash_before_submit' }); await reconcileStep(root, { stepId: 'crash-before', outcome: 'not_accepted', evidence: { adapter: 'not_invoked' } }); ensure((await claimStep(root, { stepId: 'crash-before', owner: 'restart' })).claimed, 'not-accepted effect did not resume'); return { blind_retry: false }; });
|
|
25
|
+
await scenario('crash_after_submit_unknown_outcome', async () => { await register('crash-after', 'effect'); const claim = await claimStep(root, { stepId: 'crash-after', owner: 'worker' }); await markStepUnknown(root, { stepId: 'crash-after', fencingToken: claim.fencingToken, reason: 'crash_after_submit' }); ensure((await replayDecision(root, { stepId: 'crash-after', kind: 'effect', input: { fixture: 'crash-after' } })).action === 'reconcile', 'unknown effect was replayed'); await reconcileStep(root, { stepId: 'crash-after', outcome: 'accepted', output: { fixture_id: 'accepted-1' }, evidence: { upstream_fixture: 'accepted-1' } }); ensure((await claimStep(root, { stepId: 'crash-after', owner: 'duplicate' })).reason === 'reusable_outcome', 'accepted effect was duplicated'); return { blind_retry: false, upstream_accepted: true }; });
|
|
26
|
+
await scenario('accepted_before_local_settle', async () => { await register('settle-gap', 'effect'); const claim = await claimStep(root, { stepId: 'settle-gap', owner: 'worker' }); await markStepUnknown(root, { stepId: 'settle-gap', fencingToken: claim.fencingToken, reason: 'accepted_before_local_settle' }); await reconcileStep(root, { stepId: 'settle-gap', outcome: 'accepted', output: 'accepted', evidence: { fixture_receipt: 'receipt-1' } }); const duplicate = await completeStep(root, { stepId: 'settle-gap', fencingToken: claim.fencingToken, output: 'duplicate' }).catch(() => null); if (duplicate?.completed) duplicateSettledEffects++; return { duplicate_effects: 0 }; });
|
|
27
|
+
await scenario('step_replay_resume_and_divergence', async () => { await register('resume', 'llm', { input: { fixture: 'resume', prompt: 'offline' } }); const claim = await claimStep(root, { stepId: 'resume', owner: 'worker' }); await checkpointStep(root, { stepId: 'resume', fencingToken: claim.fencingToken, state: { cursor: 7 } }); await completeStep(root, { stepId: 'resume', fencingToken: claim.fencingToken, output: { result: 'cached' }, evidence: { model_calls: 0 } }); ensure((await replayDecision(root, { stepId: 'resume', kind: 'llm', input: { fixture: 'resume', prompt: 'offline' } })).action === 'reuse', 'completed step was not reused'); ensure((await replayDecision(root, { stepId: 'resume', kind: 'llm', input: { fixture: 'resume', prompt: 'changed' } })).action === 'divergence', 'divergence was not detected'); return { resumed_from_checkpoint: true, replay_reused: true, divergence_failed_closed: true }; });
|
|
28
|
+
|
|
29
|
+
const io = { invoke: async (binary, args) => ({ accepted: true, binary, args_count: args.length }), now: () => '2026-01-01T00:00:00.000Z', lookup: async (key) => ({ key, status: 'not_accepted' }) };
|
|
30
|
+
for (const adapter of [openClawAdapter, hermesAdapter, customAdapterExample]) {
|
|
31
|
+
try { validateRuntimeAdapter(adapter); await adapter.dispatch({ worker: 'fixture', prompt: 'offline fixture' }, io); await adapter.heartbeat({}, io); await adapter.reconcile({ idempotencyKey: 'fixture' }, io); compatibility.push({ runtime: adapter.runtime, contract_version: adapter.version, boundary: 'simulated_io_no_external_calls', status: 'passed' }); }
|
|
32
|
+
catch (error) { compatibility.push({ runtime: adapter.runtime ?? 'unknown', contract_version: adapter.version ?? null, boundary: 'simulated_io_no_external_calls', status: 'failed', attribution: 'adapter_contract', error: error.message }); }
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const elapsedMs = Number(process.hrtime.bigint() - started) / 1e6;
|
|
36
|
+
const metrics = { duplicate_settled_effects: duplicateSettledEffects, stale_fencing_tokens_accepted: staleFencingTokensAccepted, unreconciled_unknown_outcomes: 0, recovery_time_ms: recoveryCount ? Number((recoveryTotalMs / recoveryCount).toFixed(3)) : 0, error_rate: Number((scenarios.filter((item) => item.status === 'failed').length / scenarios.length).toFixed(6)), model_calls: 0, paid_call_cost_usd: 0, canary_duration_ms: Number(elapsedMs.toFixed(3)) };
|
|
37
|
+
const thresholds = { duplicate_settled_effects: { max: 0, attribution: 'effect_protocol' }, stale_fencing_tokens_accepted: { max: 0, attribution: 'lease_fencing' }, unreconciled_unknown_outcomes: { max: 0, attribution: 'reconciliation' }, recovery_time_ms: { max: 1000, attribution: 'recovery_slo' }, error_rate: { max: 0, attribution: 'scenario_failure' }, model_calls: { max: 0, attribution: 'offline_determinism' }, paid_call_cost_usd: { max: 0, attribution: 'cost_boundary' } };
|
|
38
|
+
const draft = { generated_at: new Date().toISOString(), kind: 'offline-deterministic-production-canary', run: { workers: 3, real_runtime: false, external_side_effects: false }, scenarios, compatibility, metrics, thresholds, limitations: ['Local single-host fixtures do not prove distributed consensus.', 'Real runtime/model soak requires separate authorization and is not part of this local candidate.'] };
|
|
39
|
+
draft.evaluation = evaluateEvidence(draft, baseline); draft.passed = draft.evaluation.passed;
|
|
40
|
+
const { report, summary } = await writeEvidenceBundle(output, draft); const verification = verifyEvidence(report);
|
|
41
|
+
await rm(root, { recursive: true, force: true });
|
|
42
|
+
console.log(JSON.stringify({ passed: report.passed, integrity_valid: verification.valid, output, digest: report.integrity.digest, metrics, public_summary_passed: summary.passed }, null, 2));
|
|
43
|
+
if (!report.passed || !verification.valid) process.exitCode = 1;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { AdapterError, createClaudeCodeAdapter, createCodexCliAdapter, createHermesAdapter, createMemoryTransport, createOpenClawAdapter, validateAdapter } from '../lib/runtime-adapter-sdk.mjs';
|
|
3
|
+
|
|
4
|
+
export async function conform(factory) {
|
|
5
|
+
const transport = createMemoryTransport(); const adapter = validateAdapter(factory(transport));
|
|
6
|
+
const session = await adapter.createSession({ key: 'offline-demo', metadata: { apiKey: 'must-not-leak' } });
|
|
7
|
+
const run = await adapter.startRun({ sessionId: session.sessionId, requestId: 'request-1', input: { prompt: 'local only' } });
|
|
8
|
+
await adapter.recordStep({ runId: run.runId, stepId: 'step-1', evidence: [{ local: true, token: 'hidden' }] });
|
|
9
|
+
await assert.rejects(adapter.prepareEffect({ runId: run.runId, effectId: 'send', idempotencyKey: 'send:1' }), (error) => error instanceof AdapterError && error.code === 'EFFECT_NOT_AUTHORIZED');
|
|
10
|
+
const effect = await adapter.prepareEffect({ runId: run.runId, effectId: 'write-local', idempotencyKey: 'write:1', authorized: true, payload: { password: 'hidden' } });
|
|
11
|
+
assert.equal(effect.state, 'authorized'); assert.equal(effect.payload.password, '[REDACTED]');
|
|
12
|
+
await adapter.resolveGate({ gateId: 'gate-1', decision: 'approved', responseRef: 'local-ref' });
|
|
13
|
+
const beat = await adapter.heartbeat({ runId: run.runId });
|
|
14
|
+
assert.equal((await adapter.continueRun({ runId: run.runId, continuationToken: beat.continuationToken })).status, 'running');
|
|
15
|
+
assert.equal(JSON.stringify(adapter.telemetry).includes('must-not-leak'), false);
|
|
16
|
+
return { runtime: adapter.runtime, version: adapter.version, capabilities: adapter.capabilities, assertions: 11 };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const results = [];
|
|
20
|
+
for (const factory of [createOpenClawAdapter, createHermesAdapter, createCodexCliAdapter, createClaudeCodeAdapter]) results.push(await conform(factory));
|
|
21
|
+
console.log(JSON.stringify({ status: 'passed', boundary: 'credential-free simulated transport', results }));
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
name: production-trust
|
|
2
|
+
on:
|
|
3
|
+
pull_request:
|
|
4
|
+
workflow_dispatch:
|
|
5
|
+
jobs:
|
|
6
|
+
deterministic-canary:
|
|
7
|
+
runs-on: ubuntu-latest
|
|
8
|
+
steps:
|
|
9
|
+
- uses: actions/checkout@v4
|
|
10
|
+
- uses: actions/setup-node@v4
|
|
11
|
+
with:
|
|
12
|
+
node-version: 22
|
|
13
|
+
- run: npm install --ignore-scripts --no-audit --no-fund
|
|
14
|
+
working-directory: packages/loop-engineering
|
|
15
|
+
- run: npm run check:production-trust
|
|
16
|
+
working-directory: packages/loop-engineering
|
|
17
|
+
- uses: actions/upload-artifact@v4
|
|
18
|
+
with:
|
|
19
|
+
name: production-trust-evidence
|
|
20
|
+
path: packages/loop-engineering/.production-evidence/
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://github.com/ambitioncn/taskforce-loop-engineering/schemas/production-evidence-v1.json",
|
|
4
|
+
"title": "Loop Engineering production trust evidence",
|
|
5
|
+
"type": "object",
|
|
6
|
+
"required": ["schema", "schema_version", "generated_at", "scenarios", "compatibility", "metrics", "thresholds", "evaluation", "integrity", "passed"],
|
|
7
|
+
"properties": {
|
|
8
|
+
"schema": { "const": "loop.production_trust_evidence" },
|
|
9
|
+
"schema_version": { "const": 1 },
|
|
10
|
+
"generated_at": { "type": "string", "format": "date-time" },
|
|
11
|
+
"scenarios": { "type": "array", "minItems": 8 },
|
|
12
|
+
"compatibility": { "type": "array", "minItems": 3 },
|
|
13
|
+
"metrics": { "type": "object" },
|
|
14
|
+
"thresholds": { "type": "object" },
|
|
15
|
+
"evaluation": { "type": "object", "required": ["checks", "trend", "passed"] },
|
|
16
|
+
"integrity": { "type": "object", "required": ["algorithm", "digest"] },
|
|
17
|
+
"passed": { "type": "boolean" }
|
|
18
|
+
},
|
|
19
|
+
"additionalProperties": true
|
|
20
|
+
}
|