@intentius/chant 0.124.30 → 0.124.32
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/bin/chant +7 -2
- package/dist/cli/commands/check-lexicon-plugin.d.ts +7 -4
- package/dist/cli/commands/check-lexicon-plugin.d.ts.map +1 -1
- package/dist/cli/commands/check-lexicon-plugin.js +10 -7
- package/dist/cli/commands/check-lexicon.d.ts.map +1 -1
- package/dist/cli/commands/check-lexicon.js +18 -12
- package/dist/cli/commands/import.d.ts +10 -0
- package/dist/cli/commands/import.d.ts.map +1 -1
- package/dist/cli/commands/import.js +37 -0
- package/dist/cli/commands/init-lexicon/templates/project.d.ts.map +1 -1
- package/dist/cli/commands/init-lexicon/templates/project.js +4 -3
- package/dist/cli/commands/onboard.d.ts.map +1 -1
- package/dist/cli/commands/onboard.js +8 -4
- package/dist/cli/commands/update.d.ts.map +1 -1
- package/dist/cli/commands/update.js +2 -1
- package/dist/cli/dependencies.d.ts +18 -0
- package/dist/cli/dependencies.d.ts.map +1 -0
- package/dist/cli/dependencies.js +120 -0
- package/dist/cli/handlers/build.d.ts.map +1 -1
- package/dist/cli/handlers/build.js +23 -4
- package/dist/cli/handlers/fan-out.d.ts.map +1 -1
- package/dist/cli/handlers/fan-out.js +12 -4
- package/dist/cli/handlers/graph.d.ts.map +1 -1
- package/dist/cli/handlers/graph.js +16 -6
- package/dist/cli/handlers/lint.d.ts.map +1 -1
- package/dist/cli/handlers/lint.js +6 -0
- package/dist/cli/handlers/operator.d.ts +2 -2
- package/dist/cli/handlers/operator.js +3 -3
- package/dist/cli/handlers/pr.d.ts +14 -0
- package/dist/cli/handlers/pr.d.ts.map +1 -1
- package/dist/cli/handlers/pr.js +488 -32
- package/dist/cli/handlers/promote.d.ts.map +1 -1
- package/dist/cli/handlers/promote.js +3 -1
- package/dist/cli/handlers/run.d.ts +13 -1
- package/dist/cli/handlers/run.d.ts.map +1 -1
- package/dist/cli/handlers/run.js +38 -15
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/main.js +74 -12
- package/dist/cli/parse-args.d.ts.map +1 -1
- package/dist/cli/parse-args.js +65 -0
- package/dist/cli/registry.d.ts +29 -2
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/cli/registry.js +6 -2
- package/dist/codegen/generate-post-synth-barrel.js +2 -2
- package/dist/codegen/lexicon-regen.d.ts +4 -0
- package/dist/codegen/lexicon-regen.d.ts.map +1 -1
- package/dist/codegen/lexicon-regen.js +1 -1
- package/dist/codegen/pinned-upgrade.js +2 -2
- package/dist/components/capability.d.ts +44 -3
- package/dist/components/capability.d.ts.map +1 -1
- package/dist/components/capability.js +21 -0
- package/dist/components/changed-files.d.ts +7 -1
- package/dist/components/changed-files.d.ts.map +1 -1
- package/dist/components/changed-files.js +9 -1
- package/dist/components/driver.d.ts +38 -0
- package/dist/components/driver.d.ts.map +1 -1
- package/dist/components/driver.js +47 -6
- package/dist/components/fan-out-record.d.ts +6 -1
- package/dist/components/fan-out-record.d.ts.map +1 -1
- package/dist/components/fan-out-record.js +24 -0
- package/dist/components/fan-out-run.d.ts +20 -0
- package/dist/components/fan-out-run.d.ts.map +1 -1
- package/dist/components/fan-out-run.js +21 -1
- package/dist/components/pr-pipeline.d.ts +65 -2
- package/dist/components/pr-pipeline.d.ts.map +1 -1
- package/dist/components/pr-pipeline.js +107 -15
- package/dist/components/pr-run.d.ts +127 -29
- package/dist/components/pr-run.d.ts.map +1 -1
- package/dist/components/pr-run.js +290 -70
- package/dist/components/wave-plan.d.ts +5 -2
- package/dist/components/wave-plan.d.ts.map +1 -1
- package/dist/components/wave-plan.js +12 -1
- package/dist/discovery/fold-import.d.ts.map +1 -1
- package/dist/discovery/fold-import.js +16 -6
- package/dist/discovery/sandbox/bundle.d.ts.map +1 -1
- package/dist/discovery/sandbox/bundle.js +6 -0
- package/dist/discovery/specifier-resolve.d.ts.map +1 -1
- package/dist/discovery/specifier-resolve.js +8 -2
- package/dist/fold/fold.d.ts.map +1 -1
- package/dist/fold/fold.js +68 -1
- package/dist/fold/subset.d.ts +37 -0
- package/dist/fold/subset.d.ts.map +1 -1
- package/dist/fold/subset.js +88 -0
- package/dist/lexicon-module.d.ts +10 -0
- package/dist/lexicon-module.d.ts.map +1 -1
- package/dist/lexicon-module.js +53 -8
- package/dist/lexicon-plugin-helpers.d.ts.map +1 -1
- package/dist/lexicon-plugin-helpers.js +6 -4
- package/dist/lexicon.d.ts +2 -0
- package/dist/lexicon.d.ts.map +1 -1
- package/dist/lifecycle/affected.d.ts.map +1 -1
- package/dist/lifecycle/affected.js +7 -1
- package/dist/lifecycle/release-ledger.d.ts +21 -0
- package/dist/lifecycle/release-ledger.d.ts.map +1 -1
- package/dist/lifecycle/release-ledger.js +24 -0
- package/dist/lifecycle/work-dir.d.ts +45 -0
- package/dist/lifecycle/work-dir.d.ts.map +1 -0
- package/dist/lifecycle/work-dir.js +78 -0
- package/dist/lint/discover.d.ts +17 -9
- package/dist/lint/discover.d.ts.map +1 -1
- package/dist/lint/discover.js +62 -54
- package/dist/module-source.d.ts +57 -0
- package/dist/module-source.d.ts.map +1 -0
- package/dist/module-source.js +104 -0
- package/dist/op/activities/factory.js +1 -1
- package/dist/op/discover.d.ts.map +1 -1
- package/dist/op/discover.js +13 -3
- package/dist/op/gate.d.ts.map +1 -1
- package/dist/op/gate.js +3 -0
- package/dist/op/local-executor.d.ts +12 -0
- package/dist/op/local-executor.d.ts.map +1 -1
- package/dist/op/local-executor.js +22 -3
- package/dist/op/op-waves-run.d.ts.map +1 -1
- package/dist/op/op-waves-run.js +20 -5
- package/dist/op/operator.d.ts +2 -2
- package/dist/op/operator.js +2 -2
- package/dist/op/redact.d.ts +34 -0
- package/dist/op/redact.d.ts.map +1 -0
- package/dist/op/redact.js +94 -0
- package/dist/op/step-console.d.ts +2 -0
- package/dist/op/step-console.d.ts.map +1 -1
- package/dist/op/step-console.js +5 -2
- package/dist/op/steward.d.ts +6 -4
- package/dist/op/steward.d.ts.map +1 -1
- package/dist/op/steward.js +5 -3
- package/dist/op/work-lease-run.d.ts +20 -7
- package/dist/op/work-lease-run.d.ts.map +1 -1
- package/dist/op/work-lease-run.js +56 -12
- package/dist/pr-forge.d.ts +27 -0
- package/dist/pr-forge.d.ts.map +1 -1
- package/dist/pr-forge.js +51 -6
- package/dist/pr-loop.d.ts +141 -4
- package/dist/pr-loop.d.ts.map +1 -1
- package/dist/pr-loop.js +122 -6
- package/dist/pr-report-url.d.ts +10 -2
- package/dist/pr-report-url.d.ts.map +1 -1
- package/dist/pr-report-url.js +52 -6
- package/dist/ts-loader.d.ts +38 -4
- package/dist/ts-loader.d.ts.map +1 -1
- package/dist/ts-loader.js +190 -16
- package/dist/wave-runtime.mjs +208 -208
- package/dist/workspace/chant-migrations/fountain-retired-kinds.d.ts +34 -0
- package/dist/workspace/chant-migrations/fountain-retired-kinds.d.ts.map +1 -0
- package/dist/workspace/chant-migrations/fountain-retired-kinds.js +176 -0
- package/dist/workspace/chant-migrations.d.ts.map +1 -1
- package/dist/workspace/chant-migrations.js +2 -0
- package/dist/workspace/checks/boxes.d.ts +6 -6
- package/dist/workspace/checks/boxes.js +9 -9
- package/dist/workspace/ci-green-forge.d.ts +6 -4
- package/dist/workspace/ci-green-forge.d.ts.map +1 -1
- package/dist/workspace/ci-green.d.ts +28 -12
- package/dist/workspace/ci-green.d.ts.map +1 -1
- package/dist/workspace/ci-green.js +49 -21
- package/dist/workspace/ci-list.schema.json +12 -6
- package/dist/workspace/ci-report-url.d.ts +18 -0
- package/dist/workspace/ci-report-url.d.ts.map +1 -0
- package/dist/workspace/ci-report-url.js +33 -0
- package/dist/workspace/ci-run-index.d.ts +108 -0
- package/dist/workspace/ci-run-index.d.ts.map +1 -0
- package/dist/workspace/ci-run-index.js +164 -0
- package/dist/workspace/ci-run-index.schema.json +52 -0
- package/dist/workspace/ci-run.d.ts +159 -9
- package/dist/workspace/ci-run.d.ts.map +1 -1
- package/dist/workspace/ci-run.js +419 -50
- package/dist/workspace/ci-runner-key.d.ts +133 -0
- package/dist/workspace/ci-runner-key.d.ts.map +1 -0
- package/dist/workspace/ci-runner-key.js +237 -0
- package/dist/workspace/ci-secrets.d.ts +65 -0
- package/dist/workspace/ci-secrets.d.ts.map +1 -0
- package/dist/workspace/ci-secrets.js +211 -0
- package/dist/workspace/ci-serve.d.ts +67 -4
- package/dist/workspace/ci-serve.d.ts.map +1 -1
- package/dist/workspace/ci-serve.js +240 -35
- package/dist/workspace/declaration.d.ts +8 -0
- package/dist/workspace/declaration.d.ts.map +1 -1
- package/dist/workspace/declaration.js +11 -2
- package/dist/workspace/declaration.schema.json +7 -2
- package/dist/workspace/export.d.ts +1 -1
- package/dist/workspace/export.d.ts.map +1 -1
- package/dist/workspace/export.js +7 -3
- package/dist/workspace/ls.d.ts +6 -1
- package/dist/workspace/ls.d.ts.map +1 -1
- package/dist/workspace/ls.js +1 -0
- package/dist/workspace/ls.schema.json +7 -1
- package/dist/workspace/pin-integrity.d.ts.map +1 -1
- package/dist/workspace/pin-integrity.js +3 -2
- package/dist/workspace/pr-report.schema.json +74 -7
- package/dist/workspace/reason-codes.d.ts +7 -3
- package/dist/workspace/reason-codes.d.ts.map +1 -1
- package/dist/workspace/reason-codes.js +9 -4
- package/dist/workspace/run-event.schema.json +3 -1
- package/dist/workspace/status.schema.json +3 -1
- package/dist/workspace/trust/trust-write.d.ts +109 -0
- package/dist/workspace/trust/trust-write.d.ts.map +1 -0
- package/dist/workspace/trust/trust-write.js +320 -0
- package/dist/workspace/trust-write.schema.json +265 -0
- package/dist/workspace/write-scope.d.ts +9 -0
- package/dist/workspace/write-scope.d.ts.map +1 -1
- package/dist/workspace/write-scope.js +11 -1
- package/package.json +1 -1
- package/src/__fixtures__/pr-loop/pr-apply-wave-waiting.note.golden.md +48 -0
- package/src/cli/commands/__fixtures__/init-lexicon-output/package.json +3 -3
- package/src/cli/commands/audit.test.ts +2 -2
- package/src/cli/commands/check-lexicon-plugin.test.ts +9 -0
- package/src/cli/commands/check-lexicon-plugin.ts +11 -8
- package/src/cli/commands/check-lexicon.test.ts +1 -1
- package/src/cli/commands/check-lexicon.ts +16 -11
- package/src/cli/commands/import.ts +40 -0
- package/src/cli/commands/init-lexicon/templates/project.ts +4 -3
- package/src/cli/commands/init-lexicon.test.ts +8 -5
- package/src/cli/commands/onboard.test.ts +45 -0
- package/src/cli/commands/onboard.ts +8 -4
- package/src/cli/commands/update.test.ts +6 -0
- package/src/cli/commands/update.ts +4 -1
- package/src/cli/compiled-cli.e2e.test.ts +76 -11
- package/src/cli/dependencies.e2e.test.ts +101 -0
- package/src/cli/dependencies.test.ts +93 -0
- package/src/cli/dependencies.ts +122 -0
- package/src/cli/handlers/build.test.ts +69 -0
- package/src/cli/handlers/build.ts +20 -4
- package/src/cli/handlers/fan-out.test.ts +73 -0
- package/src/cli/handlers/fan-out.ts +11 -4
- package/src/cli/handlers/graph.test.ts +76 -0
- package/src/cli/handlers/graph.ts +19 -12
- package/src/cli/handlers/lint.test.ts +42 -0
- package/src/cli/handlers/lint.ts +6 -0
- package/src/cli/handlers/operator.ts +3 -3
- package/src/cli/handlers/pr-waves.test.ts +449 -0
- package/src/cli/handlers/pr.test.ts +134 -0
- package/src/cli/handlers/pr.ts +523 -31
- package/src/cli/handlers/promote.test.ts +26 -1
- package/src/cli/handlers/promote.ts +3 -2
- package/src/cli/handlers/run-components-exit.test.ts +99 -0
- package/src/cli/handlers/run-signer.test.ts +79 -0
- package/src/cli/handlers/run.ts +34 -16
- package/src/cli/main.test.ts +19 -0
- package/src/cli/main.ts +73 -12
- package/src/cli/parse-args.ts +48 -0
- package/src/cli/registry.ts +35 -4
- package/src/codegen/generate-post-synth-barrel.ts +2 -2
- package/src/codegen/lexicon-regen.ts +1 -1
- package/src/codegen/pinned-upgrade.ts +2 -2
- package/src/codegen/post-synth-barrel-guards.test.ts +6 -11
- package/src/components/capability.ts +51 -3
- package/src/components/changed-files.test.ts +16 -1
- package/src/components/changed-files.ts +9 -1
- package/src/components/driver.test.ts +36 -1
- package/src/components/driver.ts +76 -4
- package/src/components/fan-out-record.ts +29 -1
- package/src/components/fan-out-run.test.ts +35 -0
- package/src/components/fan-out-run.ts +40 -1
- package/src/components/pr-pipeline.test.ts +100 -0
- package/src/components/pr-pipeline.ts +132 -17
- package/src/components/pr-run.test.ts +323 -18
- package/src/components/pr-run.ts +354 -75
- package/src/components/wave-plan.ts +16 -4
- package/src/discovery/fold-host-method.test.ts +87 -0
- package/src/discovery/fold-import.ts +15 -5
- package/src/discovery/fold-package-composite.test.ts +27 -0
- package/src/discovery/fold-specifiers.test.ts +8 -0
- package/src/discovery/sandbox/bundle.ts +6 -0
- package/src/discovery/specifier-resolve.ts +8 -2
- package/src/fold/fold.test.ts +72 -0
- package/src/fold/fold.ts +71 -0
- package/src/fold/subset.test.ts +31 -0
- package/src/fold/subset.ts +99 -0
- package/src/lexicon-dist.e2e.test.ts +254 -0
- package/src/lexicon-module.test.ts +47 -0
- package/src/lexicon-module.ts +49 -7
- package/src/lexicon-plugin-helpers.ts +6 -4
- package/src/lexicon.ts +2 -0
- package/src/lifecycle/affected.test.ts +7 -0
- package/src/lifecycle/affected.ts +6 -1
- package/src/lifecycle/release-ledger.ts +34 -0
- package/src/lifecycle/work-dir.test.ts +77 -0
- package/src/lifecycle/work-dir.ts +82 -0
- package/src/lint/discover.test.ts +46 -2
- package/src/lint/discover.ts +55 -49
- package/src/module-source.test.ts +85 -0
- package/src/module-source.ts +112 -0
- package/src/op/activities/factory.ts +1 -1
- package/src/op/discover-root.test.ts +31 -2
- package/src/op/discover.ts +14 -4
- package/src/op/gate.test.ts +30 -0
- package/src/op/gate.ts +3 -0
- package/src/op/local-executor.ts +31 -3
- package/src/op/op-waves-run.ts +20 -5
- package/src/op/op-waves.test.ts +49 -0
- package/src/op/operator.ts +2 -2
- package/src/op/redact.ts +93 -0
- package/src/op/step-console.ts +7 -2
- package/src/op/steward.ts +6 -4
- package/src/op/work-lease-run.test.ts +88 -2
- package/src/op/work-lease-run.ts +52 -12
- package/src/pr-forge-bundle.test.ts +2 -2
- package/src/pr-forge.test.ts +71 -0
- package/src/pr-forge.ts +70 -6
- package/src/pr-loop-bundle.test.ts +2 -1
- package/src/pr-loop.test.ts +117 -1
- package/src/pr-loop.ts +252 -10
- package/src/pr-report-url.ts +58 -6
- package/src/terraform/__fixtures__/advise.test.ts +21 -2
- package/src/ts-loader.test.ts +89 -1
- package/src/ts-loader.ts +180 -13
- package/src/workspace/changes.test.ts +1 -1
- package/src/workspace/chant-migrations/fountain-retired-kinds.test.ts +33 -0
- package/src/workspace/chant-migrations/fountain-retired-kinds.ts +190 -0
- package/src/workspace/chant-migrations.e2e.test.ts +5 -0
- package/src/workspace/chant-migrations.ts +2 -0
- package/src/workspace/checks/boxes.ts +9 -9
- package/src/workspace/ci-green-forge.ts +6 -4
- package/src/workspace/ci-green-runs.test.ts +53 -11
- package/src/workspace/ci-green.test.ts +1 -1
- package/src/workspace/ci-green.ts +50 -22
- package/src/workspace/ci-list.schema.json +12 -6
- package/src/workspace/ci-report-url.test.ts +220 -0
- package/src/workspace/ci-report-url.ts +33 -0
- package/src/workspace/ci-run-index.schema.json +52 -0
- package/src/workspace/ci-run-index.test.ts +392 -0
- package/src/workspace/ci-run-index.ts +206 -0
- package/src/workspace/ci-run.test.ts +520 -42
- package/src/workspace/ci-run.ts +483 -51
- package/src/workspace/ci-runner-key.test.ts +204 -0
- package/src/workspace/ci-runner-key.ts +275 -0
- package/src/workspace/ci-secrets.test.ts +106 -0
- package/src/workspace/ci-secrets.ts +213 -0
- package/src/workspace/ci-serve.test.ts +282 -39
- package/src/workspace/ci-serve.ts +231 -34
- package/src/workspace/declaration.schema.json +7 -2
- package/src/workspace/declaration.test.ts +23 -0
- package/src/workspace/declaration.ts +19 -3
- package/src/workspace/export.ts +6 -3
- package/src/workspace/ls.schema.json +7 -1
- package/src/workspace/ls.ts +7 -2
- package/src/workspace/pin-integrity.test.ts +5 -0
- package/src/workspace/pin-integrity.ts +3 -2
- package/src/workspace/pr-report.schema.json +74 -7
- package/src/workspace/reason-codes.test.ts +2 -0
- package/src/workspace/reason-codes.ts +9 -4
- package/src/workspace/run-event.schema.json +3 -1
- package/src/workspace/status.schema.json +3 -1
- package/src/workspace/trust/trust-write.test.ts +179 -0
- package/src/workspace/trust/trust-write.ts +377 -0
- package/src/workspace/trust-write.schema.json +265 -0
- package/src/workspace/write-scope.test.ts +29 -1
- package/src/workspace/write-scope.ts +12 -1
package/src/op/local-executor.ts
CHANGED
|
@@ -56,6 +56,7 @@ import { LiveRun, currentLiveRun, withLiveRun } from "./run-live";
|
|
|
56
56
|
import { runEnvOf } from "../lifecycle/run-ledger";
|
|
57
57
|
import { resolveCommitSha } from "../lifecycle/commit-sha";
|
|
58
58
|
import { resolveCiRun, type RunOrigin } from "../lifecycle/release-ledger";
|
|
59
|
+
import { redactDeep, redactText, type Redact } from "./redact";
|
|
59
60
|
import { runEventsOfStepRecord, type RunEventKind, type RunEventRuntime, type RunEventSkipReason } from "../run-event";
|
|
60
61
|
import { openRunEventStore, teeLines } from "../run-event-store";
|
|
61
62
|
import { openRunEventStream, runEventRunOf, type RunEventSink, type RunEventStream } from "../run-event-stream";
|
|
@@ -1377,6 +1378,17 @@ export interface RunOpOptions {
|
|
|
1377
1378
|
* needs one. The local runtime passes the configured lexicons' contracts.
|
|
1378
1379
|
*/
|
|
1379
1380
|
contracts?: ReadonlyMap<string, ActivityContract>;
|
|
1381
|
+
/**
|
|
1382
|
+
* Redacts secret values (#4096) from what the run writes about itself:
|
|
1383
|
+
* each {@link runEvents} line and each line kept on this host, each
|
|
1384
|
+
* {@link stepOutput} line and step label, and the run record, its step
|
|
1385
|
+
* records and its `failure`, before {@link signRecord} signs it and the
|
|
1386
|
+
* ledger stores it. The step records on the result are redacted too. This
|
|
1387
|
+
* is the one place a run's writes pass through, so a writer added here
|
|
1388
|
+
* later is redacted with the rest. `chant ci run` passes one built from
|
|
1389
|
+
* the host's secrets file (../workspace/ci-secrets.ts).
|
|
1390
|
+
*/
|
|
1391
|
+
redact?: Redact;
|
|
1380
1392
|
}
|
|
1381
1393
|
|
|
1382
1394
|
/**
|
|
@@ -1437,7 +1449,11 @@ export async function runOpLocally(
|
|
|
1437
1449
|
const events: { stream?: RunEventStream } = {};
|
|
1438
1450
|
try {
|
|
1439
1451
|
const stepConsole = options.stepOutput
|
|
1440
|
-
? {
|
|
1452
|
+
? {
|
|
1453
|
+
write: options.stepOutput,
|
|
1454
|
+
...(options.stepHeartbeatMs !== undefined ? { heartbeatMs: options.stepHeartbeatMs } : {}),
|
|
1455
|
+
...(options.redact ? { redact: options.redact } : {}),
|
|
1456
|
+
}
|
|
1441
1457
|
: undefined;
|
|
1442
1458
|
return await withLiveRun(live, () =>
|
|
1443
1459
|
withRunConsole(stepConsole, () =>
|
|
@@ -1478,7 +1494,10 @@ async function runOpInTurn(
|
|
|
1478
1494
|
const store = options.ledger
|
|
1479
1495
|
? await openRunEventStore(options.ledger.cwd ?? options.cwd ?? process.cwd(), runId)
|
|
1480
1496
|
: undefined;
|
|
1481
|
-
const
|
|
1497
|
+
const teed = teeLines(options.runEvents?.write, store?.write.bind(store));
|
|
1498
|
+
// #4096: every event line, to the caller and to this host's store alike, is redacted once, here.
|
|
1499
|
+
const redact = options.redact;
|
|
1500
|
+
const eventsWrite = teed && redact ? (line: string) => teed(redactText(line, redact)) : teed;
|
|
1482
1501
|
const stream = eventsWrite
|
|
1483
1502
|
? openRunEventStream(
|
|
1484
1503
|
runEventRunOf({
|
|
@@ -1620,7 +1639,7 @@ async function runOpInTurn(
|
|
|
1620
1639
|
point?: WaitingPoint,
|
|
1621
1640
|
): Promise<OpRunRecord> => {
|
|
1622
1641
|
const ended = options.now ?? new Date().toISOString();
|
|
1623
|
-
|
|
1642
|
+
let input = buildRunRecord(config, settled, {
|
|
1624
1643
|
started: startedAt,
|
|
1625
1644
|
ended,
|
|
1626
1645
|
status,
|
|
@@ -1638,6 +1657,15 @@ async function runOpInTurn(
|
|
|
1638
1657
|
const failure = options.describeFailure(settled);
|
|
1639
1658
|
if (failure) input.failure = failure;
|
|
1640
1659
|
}
|
|
1660
|
+
// #4096: the record is redacted before it is signed, so the stored record is the one the statement covers.
|
|
1661
|
+
// The step records on the result are redacted in place, so a caller printing them prints no secret.
|
|
1662
|
+
if (redact) {
|
|
1663
|
+
input = redactDeep(input, redact);
|
|
1664
|
+
for (const r of settled) {
|
|
1665
|
+
if (r.error !== undefined) r.error = redact(r.error);
|
|
1666
|
+
if (r.args) r.args = redactDeep(r.args, redact);
|
|
1667
|
+
}
|
|
1668
|
+
}
|
|
1641
1669
|
let record: OpRunRecord = { version: 1, ...input, id: runId };
|
|
1642
1670
|
if (options.signRecord) {
|
|
1643
1671
|
try {
|
package/src/op/op-waves-run.ts
CHANGED
|
@@ -68,6 +68,7 @@ import type { GateAnswerRef, PendingGateRecord } from "../lifecycle/gate-ledger"
|
|
|
68
68
|
import type { GatePointAsker } from "../point-decision";
|
|
69
69
|
import type { WaitingPoint } from "./steward-points";
|
|
70
70
|
import { isApprovalSpent, type AppliedGateRecord } from "../lifecycle/gate-applied";
|
|
71
|
+
import { isLifecycleLocalNote } from "../lifecycle/local-note";
|
|
71
72
|
import type { HeadPlans, MergedReview, ReviewSource } from "./gate-review";
|
|
72
73
|
import type { TrustPolicy } from "../workspace/trust/policy";
|
|
73
74
|
import type { GateSigners } from "../workspace/trust/gate-seal";
|
|
@@ -1721,11 +1722,22 @@ async function settleDenials(ctx: WaveContext, decision: OpWaveDecision, listed:
|
|
|
1721
1722
|
}
|
|
1722
1723
|
}
|
|
1723
1724
|
|
|
1725
|
+
/**
|
|
1726
|
+
* Throws unless a spend's record reached the remote the next run reads
|
|
1727
|
+
* (#3985). Without that, the next run reads the approval or override as
|
|
1728
|
+
* unspent. A ledger with no remote is the whole ledger, so a line recorded
|
|
1729
|
+
* there is spent.
|
|
1730
|
+
*/
|
|
1731
|
+
function requireSpendPushed(result: { pushed: boolean; pushWarning?: string }): void {
|
|
1732
|
+
if (result.pushed || isLifecycleLocalNote(result.pushWarning)) return;
|
|
1733
|
+
throw new Error(`the record did not reach the remote the next run reads: ${result.pushWarning ?? "the push did not land"}`);
|
|
1734
|
+
}
|
|
1735
|
+
|
|
1724
1736
|
/**
|
|
1725
1737
|
* Record each override the wave is about to apply under (#3821), in
|
|
1726
1738
|
* `_gates/policy-override/applied.jsonl`, which spends it: written before
|
|
1727
|
-
* anything applies, once. False when the lines could not be written
|
|
1728
|
-
* wave then fails and applies nothing.
|
|
1739
|
+
* anything applies, once. False when the lines could not be written or did
|
|
1740
|
+
* not reach the remote (#3985): the wave then fails and applies nothing.
|
|
1729
1741
|
*/
|
|
1730
1742
|
async function spendOverrides(ctx: WaveContext, decision: OpWaveDecision): Promise<boolean> {
|
|
1731
1743
|
const used = decision.members.filter((m) => m.override);
|
|
@@ -1738,7 +1750,7 @@ async function spendOverrides(ctx: WaveContext, decision: OpWaveDecision): Promi
|
|
|
1738
1750
|
if (!port.appendApplied) throw new Error("this gate ledger cannot record an applied override");
|
|
1739
1751
|
for (const m of used) {
|
|
1740
1752
|
const record = og.appliedOverride({ subject: m.member, digest: m.override!.digest, at: m.override!.at, by: m.override!.by }, { timestamp: now });
|
|
1741
|
-
await port.appendApplied(record as unknown as AppliedGateRecord);
|
|
1753
|
+
requireSpendPushed(await port.appendApplied(record as unknown as AppliedGateRecord));
|
|
1742
1754
|
}
|
|
1743
1755
|
} catch (err) {
|
|
1744
1756
|
failed(ctx, `wave ${k} (${wave.name}) could not record the overrides it applies under, so nothing applied: ${err instanceof Error ? err.message : String(err)}`);
|
|
@@ -2153,9 +2165,11 @@ async function applyStage(
|
|
|
2153
2165
|
* Record that the wave applies under its approval (#3784), which spends it:
|
|
2154
2166
|
* one line in `_gates/<name>/applied.jsonl`, before anything applies, as
|
|
2155
2167
|
* terragucci's wave gate writes it. Only an approved wave under the spend
|
|
2156
|
-
* rule records, and only once. False when the line could not be written
|
|
2168
|
+
* rule records, and only once. False when the line could not be written, or
|
|
2169
|
+
* was written but did not reach the remote the next run reads (#3985): the
|
|
2157
2170
|
* wave then fails and applies nothing, since an apply the record misses
|
|
2158
|
-
* would leave its approval standing.
|
|
2171
|
+
* would leave its approval standing. A ledger with no remote is the whole
|
|
2172
|
+
* ledger, so a line recorded there is spent.
|
|
2159
2173
|
*/
|
|
2160
2174
|
async function spendApproval(options: RunOpWaveOptions, ctx: WaveContext, decision: OpWaveDecision): Promise<boolean> {
|
|
2161
2175
|
if (!decision.spend || decision.status !== "approved" || decision.spentAt || !decision.approvedAt) return true;
|
|
@@ -2177,6 +2191,7 @@ async function spendApproval(options: RunOpWaveOptions, ctx: WaveContext, decisi
|
|
|
2177
2191
|
try {
|
|
2178
2192
|
if (!port.appendApplied) throw new Error("this gate ledger cannot record an applied approval");
|
|
2179
2193
|
const pushed = await port.appendApplied(record);
|
|
2194
|
+
requireSpendPushed(pushed);
|
|
2180
2195
|
if (!pushed.pushed && pushed.pushWarning) log(`wave ${k} (${wave.name}): ${pushed.pushWarning}`);
|
|
2181
2196
|
} catch (err) {
|
|
2182
2197
|
failed(ctx, `wave ${k} (${wave.name}) could not record that it applies under the approval by ${record.approvedBy}, so nothing applied: ${err instanceof Error ? err.message : String(err)}`);
|
package/src/op/op-waves.test.ts
CHANGED
|
@@ -17,6 +17,8 @@ import {
|
|
|
17
17
|
} from "./op-waves-run";
|
|
18
18
|
import { memoryGateLedgerPort } from "./gate";
|
|
19
19
|
import { waveSetDigest } from "../gated-waves";
|
|
20
|
+
import type { AppliedGateRecord } from "../lifecycle/gate-applied";
|
|
21
|
+
import { LIFECYCLE_LOCAL_NOTE } from "../lifecycle/local-note";
|
|
20
22
|
|
|
21
23
|
const spec: OpWavesSpec = {
|
|
22
24
|
name: "migrations",
|
|
@@ -475,6 +477,39 @@ describe("spent approvals (#3784)", () => {
|
|
|
475
477
|
h.done();
|
|
476
478
|
});
|
|
477
479
|
|
|
480
|
+
test("a spend whose record does not reach the remote applies nothing, and the approval stays unspent (#3985)", async () => {
|
|
481
|
+
const h = harness(spending);
|
|
482
|
+
const memory = memoryGateLedgerPort({ resolutions: [approval] });
|
|
483
|
+
const gates = {
|
|
484
|
+
...memory,
|
|
485
|
+
async appendApplied(record: AppliedGateRecord) {
|
|
486
|
+
await memory.appendApplied!(record);
|
|
487
|
+
return { pushed: false, pushWarning: 'the push of chant/lifecycle to "origin" failed' };
|
|
488
|
+
},
|
|
489
|
+
};
|
|
490
|
+
const result = await runOpWave({ spec: spending, specFile: "waves.json", wave: 2, cwd: h.cwd, exec: h.exec, show: h.show, gates, now: "2026-10-11T00:00:00Z" });
|
|
491
|
+
expect(result).toMatchObject({ exitCode: 1, applied: [] });
|
|
492
|
+
expect(result.decision?.spentAt).toBeUndefined();
|
|
493
|
+
expect(h.applies()).toBe(0);
|
|
494
|
+
h.done();
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
test("with no remote the local ledger is the whole ledger, so a spend recorded there applies (#3985)", async () => {
|
|
498
|
+
const h = harness(spending);
|
|
499
|
+
const memory = memoryGateLedgerPort({ resolutions: [approval] });
|
|
500
|
+
const gates = {
|
|
501
|
+
...memory,
|
|
502
|
+
async appendApplied(record: AppliedGateRecord) {
|
|
503
|
+
await memory.appendApplied!(record);
|
|
504
|
+
return { pushed: false, pushWarning: LIFECYCLE_LOCAL_NOTE };
|
|
505
|
+
},
|
|
506
|
+
};
|
|
507
|
+
const result = await runOpWave({ spec: spending, specFile: "waves.json", wave: 2, cwd: h.cwd, exec: h.exec, show: h.show, gates, now: "2026-10-11T00:00:00Z" });
|
|
508
|
+
expect(result).toMatchObject({ exitCode: 0, decision: { status: "approved", spentAt: "2026-10-11T00:00:00Z" } });
|
|
509
|
+
expect(memory.applied).toHaveLength(1);
|
|
510
|
+
h.done();
|
|
511
|
+
});
|
|
512
|
+
|
|
478
513
|
test("a wave that cannot record the approval applies nothing", async () => {
|
|
479
514
|
const h = harness(spending);
|
|
480
515
|
const gates = { ...memoryGateLedgerPort({ resolutions: [approval] }), appendApplied: undefined };
|
|
@@ -878,6 +913,20 @@ describe("gates and holds a product's wave needs (#3821)", () => {
|
|
|
878
913
|
other.done();
|
|
879
914
|
});
|
|
880
915
|
|
|
916
|
+
test("an override spend whose record does not reach the remote applies nothing (#3985)", async () => {
|
|
917
|
+
const { overrideDigest } = await import("../override-gate");
|
|
918
|
+
const od = overrideDigest("vpc", digest("1"), ["no-public-s3"]);
|
|
919
|
+
const h = harness({ vpc: { planDigest: digest("1"), denied: { rules: ["no-public-s3"] } }, dns: { planDigest: digest("2"), empty: true } }, { extra: { overriders: ["github:carol"] }, atBase: { ...tg, waves: [{ ...tg.waves[0]!, gate: "never" }] } });
|
|
920
|
+
const memory = memoryGateLedgerPort({
|
|
921
|
+
resolutions: [{ version: 1, op: "policy-override", gate: "vpc", resolvedBy: "github:carol", timestamp: "2026-10-10T00:00:00Z", planDigest: od, note: "the bucket is the public docs site" }],
|
|
922
|
+
});
|
|
923
|
+
const gates = { ...memory, appendApplied: async () => ({ pushed: false, pushWarning: 'the push of chant/lifecycle to "origin" failed' }) };
|
|
924
|
+
const result = await run(h, gates);
|
|
925
|
+
expect(result).toMatchObject({ exitCode: 1, applied: [] });
|
|
926
|
+
expect(h.calls.some((c) => c.startsWith("tg apply"))).toBe(false);
|
|
927
|
+
h.done();
|
|
928
|
+
});
|
|
929
|
+
|
|
881
930
|
test("a plans file's overridersSource names the list in the refusal and the decision record (#3848)", async () => {
|
|
882
931
|
const mk = (extra: Record<string, unknown>) => harness({ vpc: { planDigest: digest("1"), denied: { rules: ["no-public-s3"] } }, dns: { planDigest: digest("2") } }, { extra });
|
|
883
932
|
const logs: string[] = [];
|
package/src/op/operator.ts
CHANGED
|
@@ -399,8 +399,8 @@ const STEWARD_TURN_POLL_MS = 150;
|
|
|
399
399
|
*
|
|
400
400
|
* Contention never queues past `waitMs` (0 by default): consistent with a
|
|
401
401
|
* round's own leases, which skip and report rather than wait (see this
|
|
402
|
-
* module's doc), and with the fountain form, whose busy
|
|
403
|
-
* refused rather than retried. A caller that wants to ride out a race with a
|
|
402
|
+
* module's doc), and with the fountain form, whose busy steward conversation
|
|
403
|
+
* is likewise refused rather than retried. A caller that wants to ride out a race with a
|
|
404
404
|
* turn about to end passes `waitMs`, and this polls every
|
|
405
405
|
* {@link STEWARD_TURN_POLL_MS} until it acquires or the wait runs out.
|
|
406
406
|
*/
|
package/src/op/redact.ts
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Redaction of secret values (#4096): what a run writes about itself, with
|
|
3
|
+
* each secret value replaced by `***`.
|
|
4
|
+
*
|
|
5
|
+
* The executor (./local-executor.ts) takes a {@link Redact} and applies it
|
|
6
|
+
* where a run writes: each run-event line, each line a step prints, and the
|
|
7
|
+
* run record before it is signed and stored. `chant ci run` builds one from
|
|
8
|
+
* the host's secrets file and the job's secret-looking variables
|
|
9
|
+
* (../workspace/ci-secrets.ts).
|
|
10
|
+
*
|
|
11
|
+
* A value shorter than {@link REDACT_MIN_LENGTH} characters is not
|
|
12
|
+
* redacted: a two- or three-character value matches too much ordinary text.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/** What every redacted value is replaced with. */
|
|
16
|
+
export const REDACTED = "***";
|
|
17
|
+
|
|
18
|
+
/** The shortest value redacted. Shorter values are left as they are. */
|
|
19
|
+
export const REDACT_MIN_LENGTH = 4;
|
|
20
|
+
|
|
21
|
+
/** A text with every secret value it contains replaced by {@link REDACTED}. */
|
|
22
|
+
export type Redact = (text: string) => string;
|
|
23
|
+
|
|
24
|
+
const escapeRegExp = (s: string): string => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The redactor for `values`, or undefined when none is long enough to
|
|
28
|
+
* redact. A value that spans lines is also redacted line by line, since
|
|
29
|
+
* step output reaches a reader one line at a time.
|
|
30
|
+
*/
|
|
31
|
+
export function redactorOf(values: Iterable<string>): Redact | undefined {
|
|
32
|
+
const all = new Set<string>();
|
|
33
|
+
const add = (v: string): void => {
|
|
34
|
+
if (v.length < REDACT_MIN_LENGTH) return;
|
|
35
|
+
all.add(v);
|
|
36
|
+
// As a JSON string spells it, for a value with a quote, a backslash or a control character in it.
|
|
37
|
+
all.add(JSON.stringify(v).slice(1, -1));
|
|
38
|
+
};
|
|
39
|
+
for (const value of values) {
|
|
40
|
+
add(value);
|
|
41
|
+
if (value.includes("\n")) for (const line of value.split(/\r?\n/)) add(line.trim());
|
|
42
|
+
}
|
|
43
|
+
if (all.size === 0) return undefined;
|
|
44
|
+
// Longest first, so a value that contains another is replaced whole.
|
|
45
|
+
const pattern = new RegExp([...all].sort((a, b) => b.length - a.length).map(escapeRegExp).join("|"), "g");
|
|
46
|
+
return (text) => (typeof text === "string" && text.length >= REDACT_MIN_LENGTH ? text.replace(pattern, REDACTED) : text);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** `value` with `redact` applied to every string in it, keys included. Numbers, booleans and nulls are kept. */
|
|
50
|
+
export function redactDeep<T>(value: T, redact: Redact): T {
|
|
51
|
+
if (typeof value === "string") return redact(value) as T;
|
|
52
|
+
if (Array.isArray(value)) return value.map((v) => redactDeep(v, redact)) as T;
|
|
53
|
+
if (value !== null && typeof value === "object") {
|
|
54
|
+
const out: Record<string, unknown> = {};
|
|
55
|
+
for (const [k, v] of Object.entries(value as Record<string, unknown>)) out[redact(k)] = redactDeep(v, redact);
|
|
56
|
+
return out as T;
|
|
57
|
+
}
|
|
58
|
+
return value;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* A JSON value's text, as `JSON.stringify` wrote it, with `redact` applied to
|
|
63
|
+
* its strings: parsed, redacted and written again, so a short value such as
|
|
64
|
+
* `true` that is also a secret doesn't break the document. Undefined when
|
|
65
|
+
* `text` is not JSON.
|
|
66
|
+
*/
|
|
67
|
+
function redactJsonText(text: string, redact: Redact): string | undefined {
|
|
68
|
+
let parsed: unknown;
|
|
69
|
+
try {
|
|
70
|
+
parsed = JSON.parse(text);
|
|
71
|
+
} catch {
|
|
72
|
+
return undefined;
|
|
73
|
+
}
|
|
74
|
+
if (parsed === null || typeof parsed !== "object") return undefined;
|
|
75
|
+
const indent = /^[[{]\r?\n(\s+)/.exec(text)?.[1]?.length;
|
|
76
|
+
const trailing = /\s*$/.exec(text)?.[0] ?? "";
|
|
77
|
+
return `${JSON.stringify(redactDeep(parsed, redact), null, indent)}${trailing}`;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* A file's or line's text redacted: a JSON document as JSON, each line of a
|
|
82
|
+
* JSON Lines text as JSON, and any other text, or line, as text. The text
|
|
83
|
+
* comes back unchanged when it holds no secret.
|
|
84
|
+
*/
|
|
85
|
+
export function redactText(text: string, redact: Redact): string {
|
|
86
|
+
if (redact(text) === text) return text;
|
|
87
|
+
const whole = redactJsonText(text, redact);
|
|
88
|
+
if (whole !== undefined) return whole;
|
|
89
|
+
return text
|
|
90
|
+
.split("\n")
|
|
91
|
+
.map((line) => (redact(line) === line ? line : (redactJsonText(line, redact) ?? redact(line))))
|
|
92
|
+
.join("\n");
|
|
93
|
+
}
|
package/src/op/step-console.ts
CHANGED
|
@@ -41,6 +41,8 @@ interface StepConsole {
|
|
|
41
41
|
export interface RunConsole {
|
|
42
42
|
write: (line: string) => void;
|
|
43
43
|
heartbeatMs?: number;
|
|
44
|
+
/** Applied to each line before it is written, and to a step's call before it is cut to a label (#4096). */
|
|
45
|
+
redact?: (text: string) => string;
|
|
44
46
|
}
|
|
45
47
|
|
|
46
48
|
const store = new AsyncLocalStorage<StepConsole>();
|
|
@@ -60,7 +62,10 @@ function formatArgs(args: Record<string, unknown>): string {
|
|
|
60
62
|
/** The name a step's lines carry: its id, else `fn(args)` on one line, cut to {@link MAX_LABEL}. */
|
|
61
63
|
export function stepLabel(fn: string, args: Record<string, unknown>, id?: string): string {
|
|
62
64
|
if (id) return id;
|
|
63
|
-
|
|
65
|
+
// A secret in the call is redacted before the label is cut, so a cut never leaves part of one.
|
|
66
|
+
const redact = runStore.getStore()?.redact;
|
|
67
|
+
const raw = `${fn}(${formatArgs(args)})`;
|
|
68
|
+
const call = (redact ? redact(raw) : raw).replace(/\s+/g, " ");
|
|
64
69
|
return call.length > MAX_LABEL ? `${call.slice(0, MAX_LABEL - 4)}...)` : call;
|
|
65
70
|
}
|
|
66
71
|
|
|
@@ -72,7 +77,7 @@ export function stepLabel(fn: string, args: Record<string, unknown>, id?: string
|
|
|
72
77
|
export async function withStepConsole<T>(opts: { phase: string; label: string }, fn: () => Promise<T>): Promise<T> {
|
|
73
78
|
const run = runStore.getStore();
|
|
74
79
|
if (!run) return fn();
|
|
75
|
-
const
|
|
80
|
+
const write = run.redact ? (line: string) => run.write(run.redact!(line)) : run.write;
|
|
76
81
|
const prefix = `[${opts.phase}] ${opts.label}`;
|
|
77
82
|
const started = Date.now();
|
|
78
83
|
let spoke = false;
|
package/src/op/steward.ts
CHANGED
|
@@ -5,9 +5,11 @@
|
|
|
5
5
|
* A steward runs a fixed list of Ops, each on its own `schedule` when it has
|
|
6
6
|
* one, one Op at a time. Where it runs is its form:
|
|
7
7
|
*
|
|
8
|
-
* - `fountain`: the fountain lexicon's `Steward` composite, an `acp` Agent
|
|
9
|
-
*
|
|
10
|
-
*
|
|
8
|
+
* - `fountain`: the fountain lexicon's `Steward` composite, an `acp` Agent
|
|
9
|
+
* whose runs `chant run <op> --on fountain` sends to one long-lived
|
|
10
|
+
* conversation, resumed by channel_id. A turn is one `chant run <op>` in
|
|
11
|
+
* that conversation. Managoat has no scheduler, so a scheduled Op's cron
|
|
12
|
+
* fires from a clock outside it (a CI schedule, a CronJob).
|
|
11
13
|
* - `local`: `chant operator --steward <name>` in the box itself, for a box
|
|
12
14
|
* with no Fountain behind it (a docker compose box, a Sprites box). The same
|
|
13
15
|
* Ops on the same crons, each run under its operator lease, and the steward
|
|
@@ -129,7 +131,7 @@ export interface StewardBeside {
|
|
|
129
131
|
}
|
|
130
132
|
|
|
131
133
|
export interface StewardDeclarationConfig {
|
|
132
|
-
/** The steward's name. On Fountain, the Agent's and the
|
|
134
|
+
/** The steward's name. On Fountain, the Agent's, and the key of its conversation. */
|
|
133
135
|
name: string;
|
|
134
136
|
/** The Ops it runs. A scheduled one runs on its cron; any other runs when asked. */
|
|
135
137
|
ops: StewardOpInput[];
|
|
@@ -342,9 +342,10 @@ describe("an Op that changes the checkout", () => {
|
|
|
342
342
|
expect(result.workLease).toMatchObject({ item: "W-7", branch: "chant/work/W-7", released: true, outcome: "done" });
|
|
343
343
|
expect(result.workLease?.holder).toBe(stewardWorkHolder("box", "dispatch", "h1"));
|
|
344
344
|
|
|
345
|
-
// The worktree sat
|
|
346
|
-
expect(worktree.
|
|
345
|
+
// The worktree sat at .chant/work/<item> in the checkout and is gone, with the directories it left empty; the branch keeps the build.
|
|
346
|
+
expect(worktree).toBe(join(dir, ".chant", "work", "W-7"));
|
|
347
347
|
expect(existsSync(worktree)).toBe(false);
|
|
348
|
+
expect(existsSync(join(dir, ".chant"))).toBe(false);
|
|
348
349
|
expect(git(["show", "chant/work/W-7:app/feature.ts"], dir).stdout).toBe("export const feature = true;\n");
|
|
349
350
|
|
|
350
351
|
// The coding agent's checkout: its edit, its lock, HEAD and the index, as they were.
|
|
@@ -403,3 +404,88 @@ describe("an Op that changes the checkout", () => {
|
|
|
403
404
|
});
|
|
404
405
|
});
|
|
405
406
|
});
|
|
407
|
+
|
|
408
|
+
describe("where the worktree is (#2893)", () => {
|
|
409
|
+
const record = (seen: { worktree?: string; status?: string }) =>
|
|
410
|
+
new Map<string, ActivityFn>([
|
|
411
|
+
[
|
|
412
|
+
"build",
|
|
413
|
+
async (args) => {
|
|
414
|
+
seen.worktree = String(args.worktree);
|
|
415
|
+
writeFileSync(join(seen.worktree, "app", "feature.ts"), "export const feature = true;\n");
|
|
416
|
+
seen.status = git(["status", "--porcelain", "--untracked-files=all"], dir()).stdout;
|
|
417
|
+
return {};
|
|
418
|
+
},
|
|
419
|
+
],
|
|
420
|
+
]);
|
|
421
|
+
let current = "";
|
|
422
|
+
const dir = () => current;
|
|
423
|
+
const op = (item: string) => dispatchOp({ workLease: { item }, changesCheckout: true }, [step("build", { worktree: workLeaseOutput("worktree") })]);
|
|
424
|
+
|
|
425
|
+
test("it is .chant/work/<item> in the checkout, info/exclude gets /.chant/work/ once, and git status stays clean while it is there", async () => {
|
|
426
|
+
await withTestDir(async (d) => {
|
|
427
|
+
current = d;
|
|
428
|
+
await initRepo(d);
|
|
429
|
+
const seen: { worktree?: string; status?: string } = {};
|
|
430
|
+
const first = await run(op("W-20"), record(seen), d);
|
|
431
|
+
expect(first.workLease).toMatchObject({ released: true, outcome: "done" });
|
|
432
|
+
expect(seen.worktree).toBe(join(d, ".chant", "work", "W-20"));
|
|
433
|
+
expect(seen.status).toBe("");
|
|
434
|
+
const exclude = () => readFileSync(join(d, ".git", "info", "exclude"), "utf-8").split("\n").filter((l) => l === "/.chant/work/");
|
|
435
|
+
expect(exclude()).toHaveLength(1);
|
|
436
|
+
await run(op("W-21"), record(seen), d);
|
|
437
|
+
expect(exclude()).toHaveLength(1);
|
|
438
|
+
expect(git(["status", "--porcelain", "--untracked-files=all"], d).stdout).toBe("");
|
|
439
|
+
expect(git(["worktree", "list", "--porcelain"], d).stdout).not.toContain(".chant/work");
|
|
440
|
+
});
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
test("info/exclude is left alone when the project already ignores .chant/work", async () => {
|
|
444
|
+
await withTestDir(async (d) => {
|
|
445
|
+
current = d;
|
|
446
|
+
await initRepo(d);
|
|
447
|
+
writeFileSync(join(d, ".gitignore"), ".chant/\n");
|
|
448
|
+
git(["add", ".gitignore"], d);
|
|
449
|
+
git(["commit", "-q", "-m", "ignore"], d);
|
|
450
|
+
const before = existsSync(join(d, ".git", "info", "exclude")) ? readFileSync(join(d, ".git", "info", "exclude"), "utf-8") : "";
|
|
451
|
+
const seen: { worktree?: string; status?: string } = {};
|
|
452
|
+
await run(op("W-22"), record(seen), d);
|
|
453
|
+
expect(seen.status).toBe("");
|
|
454
|
+
const after = existsSync(join(d, ".git", "info", "exclude")) ? readFileSync(join(d, ".git", "info", "exclude"), "utf-8") : "";
|
|
455
|
+
expect(after).toBe(before);
|
|
456
|
+
});
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
test("a worktree a crashed run left under .git/chant-work with the branch checked out is removed, and the run goes on from the branch", async () => {
|
|
460
|
+
await withTestDir(async (d) => {
|
|
461
|
+
current = d;
|
|
462
|
+
await initRepo(d);
|
|
463
|
+
const old = join(d, ".git", "chant-work", "W-23");
|
|
464
|
+
expect(git(["worktree", "add", "-q", "-b", "chant/work/W-23", old, "HEAD"], d).status).toBe(0);
|
|
465
|
+
writeFileSync(join(old, "app", "earlier.ts"), "export const earlier = 1;\n");
|
|
466
|
+
git(["add", "-A"], old);
|
|
467
|
+
git(["commit", "-q", "-m", "an earlier run"], old);
|
|
468
|
+
const seen: { worktree?: string; status?: string } = {};
|
|
469
|
+
const result = await run(op("W-23"), record(seen), d);
|
|
470
|
+
expect(result.workLease).toMatchObject({ released: true, outcome: "done" });
|
|
471
|
+
expect(seen.worktree).toBe(join(d, ".chant", "work", "W-23"));
|
|
472
|
+
expect(existsSync(old)).toBe(false);
|
|
473
|
+
expect(git(["worktree", "list", "--porcelain"], d).stdout).not.toContain("chant-work");
|
|
474
|
+
expect(git(["show", "chant/work/W-23:app/earlier.ts"], d).stdout).toBe("export const earlier = 1;\n");
|
|
475
|
+
});
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
test("a worktree a crashed run left at .chant/work with the branch checked out is replaced", async () => {
|
|
479
|
+
await withTestDir(async (d) => {
|
|
480
|
+
current = d;
|
|
481
|
+
await initRepo(d);
|
|
482
|
+
const left = join(d, ".chant", "work", "W-24");
|
|
483
|
+
expect(git(["worktree", "add", "-q", "-b", "chant/work/W-24", left, "HEAD"], d).status).toBe(0);
|
|
484
|
+
writeFileSync(join(left, "app", "stray.ts"), "stray\n");
|
|
485
|
+
const seen: { worktree?: string; status?: string } = {};
|
|
486
|
+
const result = await run(op("W-24"), record(seen), d);
|
|
487
|
+
expect(result.workLease).toMatchObject({ released: true, outcome: "done" });
|
|
488
|
+
expect(existsSync(left)).toBe(false);
|
|
489
|
+
});
|
|
490
|
+
});
|
|
491
|
+
});
|
package/src/op/work-lease-run.ts
CHANGED
|
@@ -39,10 +39,11 @@
|
|
|
39
39
|
*
|
|
40
40
|
* An Op with `changesCheckout` (applying a build, `chant workspace upgrade`)
|
|
41
41
|
* must declare `workLease`, and its leased steps get a git worktree of their
|
|
42
|
-
* own: `chant/work/<item>` checked out
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
42
|
+
* own: `chant/work/<item>` checked out at `.chant/work/<item>` in the
|
|
43
|
+
* checkout (#2893), which `<git-common-dir>/info/exclude` keeps out of
|
|
44
|
+
* `git status`, from the checkout's HEAD or from the branch as an earlier run
|
|
45
|
+
* left it. The worktree is removed when the run ends and the branch keeps
|
|
46
|
+
* whatever the steps committed, for the runner to deliver. The checkout a coding agent is
|
|
46
47
|
* editing, its index and its uncommitted files, are never touched.
|
|
47
48
|
*
|
|
48
49
|
* A run that ends `not_done` keeps its attempt aside (#3147): whatever the
|
|
@@ -54,7 +55,7 @@
|
|
|
54
55
|
*/
|
|
55
56
|
|
|
56
57
|
import { existsSync, rmSync } from "node:fs";
|
|
57
|
-
import { isAbsolute,
|
|
58
|
+
import { isAbsolute, resolve, sep } from "node:path";
|
|
58
59
|
import { getRuntime } from "../runtime-adapter";
|
|
59
60
|
import {
|
|
60
61
|
claimWorkLease,
|
|
@@ -69,6 +70,7 @@ import {
|
|
|
69
70
|
type WorkLease,
|
|
70
71
|
} from "../lifecycle/work-lease";
|
|
71
72
|
import { resolveMemberLedger } from "../lifecycle/member-ledger";
|
|
73
|
+
import { excludeWorkDir, legacyWorkWorktreePath, LEGACY_WORK_DIR, removeEmptyWorkDirs, WORK_DIR, workWorktreePath } from "../lifecycle/work-dir";
|
|
72
74
|
import { currentHolderId } from "../lifecycle/lease";
|
|
73
75
|
import { parseDuration } from "./duration";
|
|
74
76
|
import { isStepOutputRef, stepOutput, type StepOutputRef } from "./step-output-ref";
|
|
@@ -271,21 +273,36 @@ export class RunWorkLease {
|
|
|
271
273
|
}
|
|
272
274
|
|
|
273
275
|
/**
|
|
274
|
-
* A worktree for `id` on `chant/work/[<member>/]<id>`,
|
|
275
|
-
*
|
|
276
|
-
*
|
|
276
|
+
* A worktree for `id` on `chant/work/[<member>/]<id>`, at
|
|
277
|
+
* `<checkout>/.chant/work/[<member>/]<id>` (#2893): inside the checkout, so
|
|
278
|
+
* a coding agent may write there and `node_modules` resolves, and in
|
|
279
|
+
* `<git-common-dir>/info/exclude`, so the checkout's own `git status` never
|
|
280
|
+
* lists it. Starts from the branch when an earlier run left it, from HEAD
|
|
281
|
+
* otherwise. A worktree an earlier run left on the branch, here or where
|
|
282
|
+
* older versions made it under the git directory, is removed first.
|
|
277
283
|
*/
|
|
278
284
|
private async makeWorktree(id: string, members: readonly string[]): Promise<{ worktree: string; branch: string }> {
|
|
279
285
|
const cwd = this.opts.cwd;
|
|
280
286
|
const common = await git(["rev-parse", "--git-common-dir"], cwd);
|
|
281
287
|
if (!common.ok) throw new Error(`Op "${this.config.name}" changes the checkout, but ${cwd} is not a git checkout: ${common.stderr}`);
|
|
282
288
|
const commonDir = isAbsolute(common.stdout) ? common.stdout : resolve(cwd, common.stdout);
|
|
289
|
+
// The top as the caller spells it (git's --show-toplevel resolves symbolic links).
|
|
290
|
+
const cdup = await git(["rev-parse", "--show-cdup"], cwd);
|
|
291
|
+
if (!cdup.ok) throw new Error(`Op "${this.config.name}" changes the checkout, but ${cwd} has no working tree: ${cdup.stderr}`);
|
|
292
|
+
const top = resolve(cwd, cdup.stdout);
|
|
283
293
|
const scope = members.join("/");
|
|
284
294
|
const branch = `${WORK_BRANCH_PREFIX}${scope ? `${scope}/` : ""}${id}`;
|
|
285
|
-
const worktree =
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
295
|
+
const worktree = workWorktreePath(top, members, id);
|
|
296
|
+
const ignored = (await git(["check-ignore", "-q", `${WORK_DIR}/${id}`], top)).ok;
|
|
297
|
+
excludeWorkDir(commonDir, ignored);
|
|
298
|
+
for (const stale of await this.staleWorktrees(branch, commonDir, members, id)) {
|
|
299
|
+
await git(["worktree", "remove", "--force", stale], cwd);
|
|
300
|
+
rmSync(stale, { recursive: true, force: true });
|
|
301
|
+
}
|
|
302
|
+
for (const dir of [worktree, legacyWorkWorktreePath(commonDir, members, id)]) {
|
|
303
|
+
if (!existsSync(dir)) continue;
|
|
304
|
+
await git(["worktree", "remove", "--force", dir], cwd);
|
|
305
|
+
rmSync(dir, { recursive: true, force: true });
|
|
289
306
|
}
|
|
290
307
|
await git(["worktree", "prune"], cwd);
|
|
291
308
|
const exists = (await git(["rev-parse", "--verify", "--quiet", `refs/heads/${branch}`], cwd)).ok;
|
|
@@ -298,6 +315,28 @@ export class RunWorkLease {
|
|
|
298
315
|
return { worktree, branch };
|
|
299
316
|
}
|
|
300
317
|
|
|
318
|
+
/**
|
|
319
|
+
* The registered worktrees that have `branch` checked out at a path chant
|
|
320
|
+
* makes work worktrees at: `.chant/work/[<member>/]<id>` in any checkout,
|
|
321
|
+
* or `<git-common-dir>/chant-work/[<member>/]<id>` where versions before
|
|
322
|
+
* #2893 made them. A run that crashed leaves one behind, and `git worktree
|
|
323
|
+
* add` then refuses the branch as already checked out.
|
|
324
|
+
*/
|
|
325
|
+
private async staleWorktrees(branch: string, commonDir: string, members: readonly string[], id: string): Promise<string[]> {
|
|
326
|
+
const list = await git(["worktree", "list", "--porcelain"], this.opts.cwd);
|
|
327
|
+
if (!list.ok) return [];
|
|
328
|
+
const tail = [...members, id].join("/");
|
|
329
|
+
const legacy = legacyWorkWorktreePath(commonDir, members, id).split(sep).join("/");
|
|
330
|
+
const out: string[] = [];
|
|
331
|
+
for (const block of list.stdout.split(/\n\s*\n/)) {
|
|
332
|
+
const path = /^worktree (.+)$/m.exec(block)?.[1];
|
|
333
|
+
if (!path || !block.split("\n").includes(`branch refs/heads/${branch}`)) continue;
|
|
334
|
+
const posixPath = path.split(sep).join("/");
|
|
335
|
+
if (posixPath.endsWith(`/${WORK_DIR}/${tail}`) || posixPath === legacy || posixPath.endsWith(`/${LEGACY_WORK_DIR}/${tail}`)) out.push(path);
|
|
336
|
+
}
|
|
337
|
+
return out;
|
|
338
|
+
}
|
|
339
|
+
|
|
301
340
|
private lose(why: string): void {
|
|
302
341
|
if (this.lost !== undefined) return;
|
|
303
342
|
this.lost = why;
|
|
@@ -408,6 +447,7 @@ export class RunWorkLease {
|
|
|
408
447
|
if (lease.worktree) {
|
|
409
448
|
await git(["worktree", "remove", "--force", lease.worktree], this.opts.cwd).catch(() => undefined);
|
|
410
449
|
rmSync(lease.worktree, { recursive: true, force: true });
|
|
450
|
+
removeEmptyWorkDirs(lease.worktree);
|
|
411
451
|
await git(["worktree", "prune"], this.opts.cwd).catch(() => undefined);
|
|
412
452
|
}
|
|
413
453
|
if (this.lost !== undefined) return base;
|
|
@@ -34,7 +34,7 @@ describe("the pr-forge bundle", () => {
|
|
|
34
34
|
expect(inputs.filter((i) => pattern.test(i)), String(pattern)).toEqual([]);
|
|
35
35
|
}
|
|
36
36
|
expect(inputs.map((i) => i.replace(/^.*?packages\//, "packages/")).sort()).toEqual(["packages/core/src/pr-forge.ts"]);
|
|
37
|
-
// About
|
|
38
|
-
expect(result.outputFiles.reduce((n, f) => n + f.contents.byteLength, 0)).toBeLessThan(
|
|
37
|
+
// About 33 KB today, after the pull request authors (#4000).
|
|
38
|
+
expect(result.outputFiles.reduce((n, f) => n + f.contents.byteLength, 0)).toBeLessThan(36 * 1024);
|
|
39
39
|
});
|
|
40
40
|
});
|