diff --git a/Cargo.lock b/Cargo.lock index 1dbe63d..5354eff 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -94,7 +94,7 @@ dependencies = [ [[package]] name = "allstack" -version = "0.1.0" +version = "0.2.0" dependencies = [ "allsource-core", "anyhow", diff --git a/Cargo.toml b/Cargo.toml index 7a24ba4..054b8a2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "allstack" -version = "0.1.0" +version = "0.2.0" edition = "2024" rust-version = "1.95" license = "Apache-2.0" @@ -22,6 +22,10 @@ uuid = { version = "1", features = ["v4"] } [dev-dependencies] tempfile = "3" +[[test]] +name = "workspace" +harness = false + [profile.dev] debug = 0 diff --git a/docs/README.md b/docs/README.md index 0e79aaa..8ad49eb 100644 --- a/docs/README.md +++ b/docs/README.md @@ -2,6 +2,8 @@ ## Design +- [2026-10-25: Native runtime checks and AllSource Prime validation](plans/2026-10-25-native-runtime-checks-design.md) — lessons from real native delivery, bounded command receipts, and authenticated AllSource application qualification. + - [2026-10-25: Spendcheck category plan](plans/2026-10-25-spendcheck-category-plan.md) — real Claude native shaping/planning output for the expense-report application change. - [2026-10-25: Realignment with gstack's specialist workflow model](plans/2026-10-25-gstack-realignment-design.md) — product direction proposal, retained ecosystem strengths, native skills, and application-level acceptance. @@ -14,6 +16,8 @@ ## Reference +- [Native AllSource workflow verification](reports/2026-10-26-native-allsource-verification.md) — installed runtime receipts, Chronis export compatibility, real hosts, and Prime application acceptance. + - [Native skills and complete application verification](reports/2026-10-25-native-skills-application-verification.md) — installed interface, real Codex/Claude use, frozen expense acceptance, and durable publication evidence. - [Native specialist skills](reference/native-skills.md) — direct Codex/Claude methods, standalone project memory, and enrollment into durable delivery. @@ -48,3 +52,5 @@ The repository is an implementation preview. Source-level contracts and fixture - [Allstack delivery plan f2c4e41f-7616-4b34-aa56-4347fd1617fc](plans/allstack-f2c4e41f-7616-4b34-aa56-4347fd1617fc.md) - [Allstack delivery plan 9dc4c81f-73ca-4b92-89a2-be3c2ca44deb](plans/allstack-9dc4c81f-73ca-4b92-89a2-be3c2ca44deb.md) + +- [Allstack delivery plan 26340e4d-9eba-4ad5-bbe0-0832086978b5](plans/allstack-26340e4d-9eba-4ad5-bbe0-0832086978b5.md) diff --git a/docs/plans/2026-10-25-native-runtime-checks-design.md b/docs/plans/2026-10-25-native-runtime-checks-design.md new file mode 100644 index 0000000..b5d2ab8 --- /dev/null +++ b/docs/plans/2026-10-25-native-runtime-checks-design.md @@ -0,0 +1,72 @@ +# Native runtime checks and AllSource Prime validation + +## Outcome + +A developer invokes native Allstack skills in Codex and Claude, verifies an actual application change with inspectable command receipts, and publishes the exact verified commit through one PR per repository. The seven specialist skills remain the main interface. AllSource retains delivery history, Chronis owns claims and completion, and Prime retains source-linked decisions. + +## Evidence from the previous application run + +The merged `wolven-tech/autoresearch-rs` PR #4 established the native flow against a real application. Its reviewed commit was `e7338d04fe9f4835bb852c67ad678ad53b9d3dfe`; merged main contains the same source tree. The retained local qualification is `.allstack/proof/autoresearch-native-e7338d0` in the founder-mode workspace. + +- Cloning a warm Cargo target directory can preserve artifact timestamps that allow a stale application executable to be reused. A successful build invocation alone does not establish source provenance. +- Native host command output can combine stdout and stderr. Expected application failures can appear as host tool errors, even when their status is the required acceptance result. +- Runtime capture required a compiled example helper and custom orchestration. The process supervisor already owns deadlines, separate streams, and child cleanup; native users need a supported entry point to that capability. +- Native adoption prepares and verifies the candidate in the coordinator checkout. An epoch worktree can still contain the earlier base commit. +- Moving a coordinator invalidates path-bound run state. Prefer the primary checkout when it is clean and exclusively owned. An unrelated dirty primary requires an isolated checkout; archive its path-bound evidence before cleanup. Existing Chronis history and project Prime context must survive qualification and cleanup. + +## Native methods + +Update the shared plan, QA, and ship methods and bundled guide. Teach evidence requirements, not application-specific commands: + +1. Bind acceptance to the supplied target when it is already explicit; probe only a missing capability. +2. Distinguish building from executing tests and exercising the actual application. After warming artifacts, establish that the application was rebuilt from the intended source before recording its digest. +3. Capture expected and observed exit status and separate streams, including negative cases. Treat timeout, signal, spawn failure, and lost process ownership as failures. +4. Read the candidate commit in the coordinator checkout returned by native adoption. Preserve the existing tracker and memory stores; keep path-bound archives separate from live state. + +Keep installation additive and conflict-safe. Updating a previously generated method requires reviewing its replacement against the old generated version; do not silently overwrite customized skills. + +## Supported command capture + +Add `allstack check` as a generic helper beneath native skills. It executes an explicitly named executable through the existing Rust supervisor. Proposed interface: + +```text +allstack check --expect-exit 4 --seconds 120 -- /absolute/executable arguments +allstack check --input prompt.txt --seconds 240 -- /absolute/host arguments +``` + +The command requires an ignored private `.allstack/` directory before writing evidence. It creates neither a delivery run nor a Chronis task. It does not open AllSource or Prime. A private receipt records the command, working directory, source revision and dirty state, executable digest, input digest when supplied, expected and actual exit, elapsed measurement, timeout/ownership outcome, and separate stdout/stderr. Preserve raw stream paths and digests; decoded JSON text must not be described as byte-exact output. + +Only a matching normal exit with unchanged executable and retained evidence succeeds. An unexpected exit or other failure retains its receipt, returns a nonzero wrapper status, and names the receipt in the error. A digest binds the observed executable; it does not prove that executable was compiled from the recorded source. Native QA must establish that prerequisite separately. + +Use one operation deadline, including metadata collection, with supervisor cleanup inside the existing CLI ceiling. Reuse existing process ownership and kill-then-reap behavior. No new dependency, per-application policy, retry loop, or host authentication mechanism is needed. + +## Next real application change + +Qualify the improved workflow on `all-source-os/all-source`: reject a zero Prime sync interval before store startup or background task creation. The isolated checkout starts from fetched main `897623e8` because the primary AllSource checkout has unrelated changes. Preserve those changes and use this owned checkout as coordinator throughout qualification. + +The installed Prime 0.22.0 executable accepted `--sync-interval-ms 0`, panicked in its sync worker with `` `period` must be non-zero. ``, answered a genuine MCP `prime_stats` request with `sync.enabled: true`, and exited 0. Its SHA-256 is `3242e3341867d9cbae2b326db2ab0e0160db9afad124177fb407edb77aa0a349`. The isolated store, raw streams, and protocol output are retained privately under `.allstack/proof/allsource-native` in the founder-mode workspace. The destination was loopback port 9 and the key was synthetic; no hosted tenant was contacted. + +Current AllSource source still accepts any `u64` interval in `apps/prime-mcp/src/main.rs` and passes it to both sync and email-ingest Tokio tickers. Inspect supported semantics before choosing the smallest validation seam. Explicit zero and `PRIME_SYNC_INTERVAL_MS=0` must produce an actionable nonzero configuration error before creating store files or contacting a remote. Preserve default 1000 ms and positive values, help/version behavior, existing local-only behavior, and genuine MCP tool responses. Prove valid sync against an isolated loopback Core fixture through the real MCP process. Build the current base as well as the candidate; the older installed binary is supporting evidence, not proof of current-main runtime behavior. + +Claude must inspect and plan the actual application change through native `allstack-plan`; Codex must implement it; Claude must independently review and exercise the application through the updated native methods. Do not change live Prime configuration, hosted stores, release tags, or unrelated AllSource work. + +## Verification and publication + +- Exercise the actual installed `allstack check` boundary: successful exit, expected negative exit, unexpected exit, separate streams, spawn failure, timeout, executable binding, and no managed stores or tasks. +- Validate changed skills and existing installation conflict behavior. Run Allstack tests, formatting, and lint under the machine's exclusive compiler rule. +- Install the tested toolkit and verify its installed digest and bundled methods. Use normal authenticated Codex and Claude without model overrides. +- Freeze application acceptance before implementation; show the failing baseline and passing candidate against the actual built executables. Inspect actual native skill expansion, tool calls, diffs, and runtime receipts before accepting host claims. +- Run application gates against the exact single commit, publish one PR, and read back remote SHA, CI execution, AllSource history, Chronis completion, and source-linked Prime outcome. +- Keep an indexed qualification report in each affected repository. Include before/after behavior, reproducible commands, and limits. Raw host transcripts and private process receipts remain ignored. + +## Chronis Git sync compatibility + +AllSource versions `.chronis/sync/events.jsonl`, the portable export owned by `cn sync --git`. Full initialization and doctor must preserve and permit that exact path while continuing to reject tracked private `.chronis`, `.prime`, and `.allstack` state. Process tests cover both the preserved export and neighboring/private paths. Existing tracker engine data remains untouched. + +## Preserved guarded base files + +AllSource already tracks environment files, including examples and a production-named configuration. Native adoption must preserve these existing base files without copying their contents into Allstack artifacts. Snapshot manifests retain their Git blob identities instead. Git verifies both unchanged worktree contents and the expected base identity before capture or materialization; newly added, edited, or deleted guarded files remain rejected. Snapshot identity includes these references, and recovery validates them. Legacy snapshots without references retain their existing content digest. Process-backed integration tests cover restoration, artifact contents, rejection of changed files including `assume-unchanged`, and base changes invalidating materialization before writes. + +## Authorization + +The user authorized `feat/native-runtime-checks` in `all-source-os/allstack` and `fix/prime-sync-interval` in `all-source-os/all-source`, with one implementation PR in each repository. The user's machine rules forbid trunk pushes and take precedence over AllSource's checked-in direct-main default. Other AllSource PRs belong to separate sessions and remain untouched. Merge and deployment remain separate actions. diff --git a/docs/plans/allstack-26340e4d-9eba-4ad5-bbe0-0832086978b5.md b/docs/plans/allstack-26340e4d-9eba-4ad5-bbe0-0832086978b5.md new file mode 100644 index 0000000..b5d2ab8 --- /dev/null +++ b/docs/plans/allstack-26340e4d-9eba-4ad5-bbe0-0832086978b5.md @@ -0,0 +1,72 @@ +# Native runtime checks and AllSource Prime validation + +## Outcome + +A developer invokes native Allstack skills in Codex and Claude, verifies an actual application change with inspectable command receipts, and publishes the exact verified commit through one PR per repository. The seven specialist skills remain the main interface. AllSource retains delivery history, Chronis owns claims and completion, and Prime retains source-linked decisions. + +## Evidence from the previous application run + +The merged `wolven-tech/autoresearch-rs` PR #4 established the native flow against a real application. Its reviewed commit was `e7338d04fe9f4835bb852c67ad678ad53b9d3dfe`; merged main contains the same source tree. The retained local qualification is `.allstack/proof/autoresearch-native-e7338d0` in the founder-mode workspace. + +- Cloning a warm Cargo target directory can preserve artifact timestamps that allow a stale application executable to be reused. A successful build invocation alone does not establish source provenance. +- Native host command output can combine stdout and stderr. Expected application failures can appear as host tool errors, even when their status is the required acceptance result. +- Runtime capture required a compiled example helper and custom orchestration. The process supervisor already owns deadlines, separate streams, and child cleanup; native users need a supported entry point to that capability. +- Native adoption prepares and verifies the candidate in the coordinator checkout. An epoch worktree can still contain the earlier base commit. +- Moving a coordinator invalidates path-bound run state. Prefer the primary checkout when it is clean and exclusively owned. An unrelated dirty primary requires an isolated checkout; archive its path-bound evidence before cleanup. Existing Chronis history and project Prime context must survive qualification and cleanup. + +## Native methods + +Update the shared plan, QA, and ship methods and bundled guide. Teach evidence requirements, not application-specific commands: + +1. Bind acceptance to the supplied target when it is already explicit; probe only a missing capability. +2. Distinguish building from executing tests and exercising the actual application. After warming artifacts, establish that the application was rebuilt from the intended source before recording its digest. +3. Capture expected and observed exit status and separate streams, including negative cases. Treat timeout, signal, spawn failure, and lost process ownership as failures. +4. Read the candidate commit in the coordinator checkout returned by native adoption. Preserve the existing tracker and memory stores; keep path-bound archives separate from live state. + +Keep installation additive and conflict-safe. Updating a previously generated method requires reviewing its replacement against the old generated version; do not silently overwrite customized skills. + +## Supported command capture + +Add `allstack check` as a generic helper beneath native skills. It executes an explicitly named executable through the existing Rust supervisor. Proposed interface: + +```text +allstack check --expect-exit 4 --seconds 120 -- /absolute/executable arguments +allstack check --input prompt.txt --seconds 240 -- /absolute/host arguments +``` + +The command requires an ignored private `.allstack/` directory before writing evidence. It creates neither a delivery run nor a Chronis task. It does not open AllSource or Prime. A private receipt records the command, working directory, source revision and dirty state, executable digest, input digest when supplied, expected and actual exit, elapsed measurement, timeout/ownership outcome, and separate stdout/stderr. Preserve raw stream paths and digests; decoded JSON text must not be described as byte-exact output. + +Only a matching normal exit with unchanged executable and retained evidence succeeds. An unexpected exit or other failure retains its receipt, returns a nonzero wrapper status, and names the receipt in the error. A digest binds the observed executable; it does not prove that executable was compiled from the recorded source. Native QA must establish that prerequisite separately. + +Use one operation deadline, including metadata collection, with supervisor cleanup inside the existing CLI ceiling. Reuse existing process ownership and kill-then-reap behavior. No new dependency, per-application policy, retry loop, or host authentication mechanism is needed. + +## Next real application change + +Qualify the improved workflow on `all-source-os/all-source`: reject a zero Prime sync interval before store startup or background task creation. The isolated checkout starts from fetched main `897623e8` because the primary AllSource checkout has unrelated changes. Preserve those changes and use this owned checkout as coordinator throughout qualification. + +The installed Prime 0.22.0 executable accepted `--sync-interval-ms 0`, panicked in its sync worker with `` `period` must be non-zero. ``, answered a genuine MCP `prime_stats` request with `sync.enabled: true`, and exited 0. Its SHA-256 is `3242e3341867d9cbae2b326db2ab0e0160db9afad124177fb407edb77aa0a349`. The isolated store, raw streams, and protocol output are retained privately under `.allstack/proof/allsource-native` in the founder-mode workspace. The destination was loopback port 9 and the key was synthetic; no hosted tenant was contacted. + +Current AllSource source still accepts any `u64` interval in `apps/prime-mcp/src/main.rs` and passes it to both sync and email-ingest Tokio tickers. Inspect supported semantics before choosing the smallest validation seam. Explicit zero and `PRIME_SYNC_INTERVAL_MS=0` must produce an actionable nonzero configuration error before creating store files or contacting a remote. Preserve default 1000 ms and positive values, help/version behavior, existing local-only behavior, and genuine MCP tool responses. Prove valid sync against an isolated loopback Core fixture through the real MCP process. Build the current base as well as the candidate; the older installed binary is supporting evidence, not proof of current-main runtime behavior. + +Claude must inspect and plan the actual application change through native `allstack-plan`; Codex must implement it; Claude must independently review and exercise the application through the updated native methods. Do not change live Prime configuration, hosted stores, release tags, or unrelated AllSource work. + +## Verification and publication + +- Exercise the actual installed `allstack check` boundary: successful exit, expected negative exit, unexpected exit, separate streams, spawn failure, timeout, executable binding, and no managed stores or tasks. +- Validate changed skills and existing installation conflict behavior. Run Allstack tests, formatting, and lint under the machine's exclusive compiler rule. +- Install the tested toolkit and verify its installed digest and bundled methods. Use normal authenticated Codex and Claude without model overrides. +- Freeze application acceptance before implementation; show the failing baseline and passing candidate against the actual built executables. Inspect actual native skill expansion, tool calls, diffs, and runtime receipts before accepting host claims. +- Run application gates against the exact single commit, publish one PR, and read back remote SHA, CI execution, AllSource history, Chronis completion, and source-linked Prime outcome. +- Keep an indexed qualification report in each affected repository. Include before/after behavior, reproducible commands, and limits. Raw host transcripts and private process receipts remain ignored. + +## Chronis Git sync compatibility + +AllSource versions `.chronis/sync/events.jsonl`, the portable export owned by `cn sync --git`. Full initialization and doctor must preserve and permit that exact path while continuing to reject tracked private `.chronis`, `.prime`, and `.allstack` state. Process tests cover both the preserved export and neighboring/private paths. Existing tracker engine data remains untouched. + +## Preserved guarded base files + +AllSource already tracks environment files, including examples and a production-named configuration. Native adoption must preserve these existing base files without copying their contents into Allstack artifacts. Snapshot manifests retain their Git blob identities instead. Git verifies both unchanged worktree contents and the expected base identity before capture or materialization; newly added, edited, or deleted guarded files remain rejected. Snapshot identity includes these references, and recovery validates them. Legacy snapshots without references retain their existing content digest. Process-backed integration tests cover restoration, artifact contents, rejection of changed files including `assume-unchanged`, and base changes invalidating materialization before writes. + +## Authorization + +The user authorized `feat/native-runtime-checks` in `all-source-os/allstack` and `fix/prime-sync-interval` in `all-source-os/all-source`, with one implementation PR in each repository. The user's machine rules forbid trunk pushes and take precedence over AllSource's checked-in direct-main default. Other AllSource PRs belong to separate sessions and remain untouched. Merge and deployment remain separate actions. diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 04f692c..208d4a4 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -8,6 +8,8 @@ Use one Unix machine with Git, GitHub CLI, Rust/Cargo, Chronis `cn`, Codex CLI, Install from the reviewed Allstack checkout with `cargo install --path . --locked`. The Rust package requires Rust 1.95 or later. `allstack --help` and each subcommand's `--help` expose the installed interface. +Native command evidence is available without managed delivery: `allstack check --expect-exit 4 --seconds 120 -- /absolute/program arguments`. Omit `--expect-exit` to expect zero; `--input prompt.txt` supplies up to 128 KiB of UTF-8 stdin. The 2..270 second deadline includes source metadata and execution. Ignore `.allstack/` first. JSON returns the receipt location, actual outcome, executable identity, source checkout, and separate raw stream paths/digests. A matching expected nonzero exit succeeds; timeout, signal, changed executable, spawn failure, and unexpected status fail and retain a private receipt. No task, run, or Prime store is created. A binary digest identifies observed bytes; establish its source build independently. + Put the installed executable directory on the `PATH` inherited by both coding hosts. A custom `cargo install --root ` needs `/bin` there as well. Keep the selected policy environment stable from verification through shipping. The [fresh-repository verification](../reports/2026-10-24-fresh-repository-delivery-verification.md) records the complete authenticated journey and actual destination readbacks. Prepare the target repository before starting: @@ -125,3 +127,7 @@ Verification records the prepared commit so a changed checkpoint can be committe `.allstack/` holds AllSource events, Prime graph state, object snapshots, run worktrees, and process receipts. It is local-only and must remain ignored. Read component stores through their APIs or Allstack commands, never by scraping WAL or storage segments. Retain tool versions, run IDs, exact commits, actual test counts and outputs, interruption points, remote/PR readbacks, and private artifact digests when validating the release. Publish only deliberately reviewed evidence in indexed repository documents. Fixture definitions and source tests do not establish that authenticated Codex and Claude Code completed the real journey. + +Initialization and doctor permit Chronis’s tracked `.chronis/sync/events.jsonl` Git sync export. Other tracked files under `.chronis/`, `.prime/`, and `.allstack/` remain rejected as private state. Existing exports are preserved. + +Publishing changes under `.github/workflows/` requires GitHub workflow-write authority. A repository-scoped OAuth token can read the repository and create a PR while still failing the Git push for those files. Use an existing authorized SSH identity for the same repository, or a token with the required scope. Preserve the verified commit and reconcile the actual remote branch before retrying publication. diff --git a/docs/reports/2026-10-26-native-allsource-verification.md b/docs/reports/2026-10-26-native-allsource-verification.md new file mode 100644 index 0000000..2a573ab --- /dev/null +++ b/docs/reports/2026-10-26-native-allsource-verification.md @@ -0,0 +1,58 @@ +# Native Allstack workflow on AllSource Prime + +Status: CURRENT + +The [accepted design](../plans/2026-10-25-native-runtime-checks-design.md) applies lessons from the autoresearch-rs run to native skills and qualifies a real Prime configuration repair. The installed toolkit and application acceptance below were exercised through authenticated Codex and Claude. + +## Installed interface + +Allstack 0.2.0 adds `check`, a standalone supervised command with an explicit expected exit. It records source state, executable identity, stdin identity, separate raw streams, and the actual outcome under ignored `.allstack/checks/`. Timeout, signal, spawn failure, identity change, and an unexpected status fail the check. It opens no delivery, Chronis, or Prime store. + +The final installed executable has SHA-256 `cd69dfa9c3fcabc8b85c42617d0a759e3c5bcc156e1e19120873e3e5538e4bd6`. It initialized the AllSource coordinator with existing Chronis and Prime state. A subsequent `init --check` with toolkit source reads denied returned ready, with every generated file unchanged and the customized Prime verification policy preserved. The global executable was atomically updated to these tested bytes and returned `allstack 0.2.0`. + +Native method updates cover executable freshness after warm cache copies, explicit negative exits, raw stream evidence, coordinator-versus-epoch source identity, absent project memory, and conflict-safe skill replacement. Fifteen previously generated resources were compared against the installed 0.1.0 package before replacement; nine changed. Native skill text remains the host interface. AllSource retains execution history, Chronis retains delivery ownership, and Prime retains source-linked context underneath it. + +## Chronis Git export compatibility + +AllSource tracks `.chronis/sync/events.jsonl`. Initialization previously rejected that portable export as private state. Bootstrap and doctor now permit that exact path while rejecting other tracked files under `.chronis/`, `.prime/`, and `.allstack/`. + +The same two process tests ran against the old and repaired installed binaries. Before: one passed, export-preservation failed. After: both passed. The rejection test covers four private or neighboring paths alongside the allowed export. AllSource's 127,146-byte export remained byte-identical, SHA-256 `5b7825d06aff59801f9cc8cc24a526ff2d8e440e5fa378fba9f8eb11f719bc40`. + +Snapshots also preserve unchanged guarded configuration files through Git blob references, without copying their contents into Allstack artifacts. Capture rejects new, changed, or deleted guarded files, including changes hidden by `assume-unchanged`. Materialization validates preserved identities before writes and refuses guarded contents supplied through artifacts. Explicitly enrolled ignored skill files remain tracked during restoration. + +Four process-backed workspace cases passed under Nextest: restore without copying guarded contents, reject guarded changes, reject changed base identity, and reject guarded artifact contents. The executable supports Nextest's listing and exact-case protocol, so the existing CI gate discovers and executes these cases. + +## Authenticated hosts + +| Stage | Observed host execution | +| --- | --- | +| Plan | Claude session `65266a08-99ef-464c-98e0-5f7440350d1d` expanded `/allstack-plan`; ten read-only tool calls completed without denials. | +| Build | Codex thread `01a17549-24a8-7252-9c98-8d49a1aebbfe` invoked `$allstack-recall`, read the accepted project plan, froze public-process acceptance, and changed Prime's parser and README. | +| Review | Claude session `4cfaa685-9c0f-4af5-afe7-ddaeb288f0ab` reviewed application and toolkit scopes separately. Its application review identified missing positive synced-MCP coverage. A follow-up source review accepted the added delivery test and Chronis compatibility repair with explicit runtime limits. | +| QA | Claude independently ran both prebuilt application harnesses: 18 parser/MCP cases and one real synced session passed. A direct call verified the Prime executable SHA-256 and zero rejection with exit 2 and empty stdout. The coordinator separately confirmed no data directory existed. | + +Normal host authentication and permissions were used. No model override or permission bypass supplied the result. A combined review reached its deadline without a verdict; later bounded, narrower reviews supplied the source assessments. Source review is not runtime acceptance. + +## Current-main defect + +AllSource baseline `897623e8ad7a86a0bfba1c842d73e3a3685f89b6` was built from source with its Prime lockfile, no default features, and a separate warm build directory. Cargo reported a fresh application artifact. The resulting Prime 0.26.1 executable has SHA-256 `a6a42ae82fea213ca1415e3363adbca4271b46597022fc136f764f2a59a26543`. + +Through actual MCP initialization and `prime_stats`, interval zero produced both sync-worker and email-worker panics, `` `period` must be non-zero. ``, while reporting sync enabled and exiting 0. The store was disposable, the destination loopback, and the key synthetic. + +Runtime receipts include metadata and executable hashing in their deadline. Timeout before launch establishes no application result. The completed baseline reproduction and candidate acceptance use budgets that include that work. + +## Acceptance and publication + +The same 19 frozen application cases produced 14 passes and 5 failures on the fresh baseline, then 19 passes on the fresh candidate. The candidate Prime 0.26.1 executable has SHA-256 `5c8973ade235b68e4efa516888b1cf99017c4cfbd6291d108d55dd2917158aa9`. Its full prebuilt binary suite produced 123 passes and 2 explicit skips, for an embedding model and Tesseract. Prime formatting and all-targets Clippy passed. + +The final toolkit suite executed 73 tests successfully, including the four workspace cases; two actual-Chronis opt-in qualifications were skipped. Full toolkit Clippy, formatting, and dependency policy passed. `cargo deny` reported existing duplicate-version and unused-license-allowance warnings without a failing gate. + +Native application enrollment is AllSource run `b0142d4c-92d2-4e82-b3b2-ac6db49c8995`, epoch 1, with Chronis task `t-a5105c`. Project Prime readback returned plan node `18511ebd-0f92-4e8c-90a4-8f102e35af2b`. The run published [AllSource PR #361](https://github.com/all-source-os/all-source/pull/361) at exact verified commit `3467e6488d93326b8cdfced554ce1f78aa88637a`. Both the remote branch and PR head were read back at that SHA, with exactly one commit. Shipping completed the Chronis task and indexed the project Prime outcome. Private raw receipts and host transcripts remain in the founder-mode workspace's ignored `.allstack/proof/allsource-native/` directory. + +This qualification covers sequential authenticated Codex and Claude hosts on one machine. Hosted synchronization, merge, deployment, embedding models, and browser behavior are outside its acceptance boundary. The sync integration test targets a local HTTP boundary rather than a hosted tenant. + +Classic main-branch protection was absent and repository/inherited ruleset queries returned no rulesets in both repositories. CI badges alone do not block merges. Both repositories permit rebase merges only; Allstack verifies its frozen policy before publication. + +GitHub rejected the HTTPS OAuth credential for the CI workflow change because its scopes omitted `workflow`. Existing SSH authentication for the same account published the exact verified commit using a per-command push URL for the same repository. Shared repository configuration and hook behavior were preserved. + +Toolkit native enrollment is run `26340e4d-9eba-4ad5-bbe0-0832086978b5`, epoch 1, with Chronis task `t-c9068d`. Its frozen native policy repeats the complete toolkit, application-example, host-fixture, formatting, lint, and source gates against its prepared single commit. diff --git a/skills/allstack-plan/SKILL.md b/skills/allstack-plan/SKILL.md index b279e86..1e72861 100644 --- a/skills/allstack-plan/SKILL.md +++ b/skills/allstack-plan/SKILL.md @@ -17,7 +17,7 @@ Read the request, repository instructions, relevant code, and actual gates. Trac - **Failure behavior:** follow invalid input, empty results, partial writes, retries, interruption, and duplicate execution where relevant. Name which component detects each failure and what the user sees. - **Compatibility:** inspect current callers, serialized data, command output, and persisted state that the change touches. Specify what remains stable and how compatibility will be exercised. - **Data correctness:** identify units, rounding, ordering, identifiers, and trust boundaries. For money, use exact minor units and checked arithmetic; never hide malformed input behind a favorable result. -- **Verification:** map each user-visible requirement to a real command or runtime action. Include a negative case that would catch the likely defect. State when a build only compiles and when a test actually executes. +- **Verification:** map each user-visible requirement to a real command or runtime action. Include a negative case that would catch the likely defect, with its expected exit and output channels. State when a build only compiles and when a test actually executes. Name how the executable will be rebuilt from the intended source; a digest alone cannot establish source provenance, and cloned warm artifacts can retain stale timestamps. Do not expand the assignment into speculative platforms, services, or catalog entries. Escalate a concrete decision only when choosing without it risks the wrong outcome. Do not translate unknowns into time estimates. @@ -25,4 +25,6 @@ Do not expand the assignment into speculative platforms, services, or catalog en Return the complete human flow, current behavior, proposed behavior, exact files, implementation sequence, acceptance commands, prerequisites, and unresolved permissions. Bind checks to the actual repository tools. Preserve the request's scope when another host implements it. +Choose the durable coordinator before starting delivery. Prefer an exclusively owned clean primary checkout; isolate unrelated dirty work when needed. Preserve the project's existing Chronis history and Prime context. Moving a coordinator does not make its path-bound run history valid at the new path; archive that history before removing an owned checkout. + For standalone work, save and index the plan under docs when requested or required by repository policy. Record accepted source-linked decisions with `allstack remember` when available. For an explicitly requested durable delivery, the host can use the coordinator guide to start and manage it behind this interface. Do not require durable delivery merely to produce a plan. diff --git a/skills/allstack-qa/SKILL.md b/skills/allstack-qa/SKILL.md index a942b84..7b32a34 100644 --- a/skills/allstack-qa/SKILL.md +++ b/skills/allstack-qa/SKILL.md @@ -5,7 +5,7 @@ description: Exercise a real application flow through its UI, API, CLI, or job e # Exercise the real job -Identify the target and the complete user job. Use the host's actual browser or runtime tools; probe availability before choosing a path. This skill runs independently and creates no delivery run or Chronis task. Read repository policy and preserve the user's existing authorization boundaries. +Identify the target and the complete user job. Use an explicit supplied target directly; probe only missing browser or runtime capabilities. This skill runs independently and creates no delivery run or Chronis task. Read repository policy and preserve the user's existing authorization boundaries. ## Choose observable checks @@ -17,6 +17,10 @@ For a UI, use an available browser to perform the interaction and inspect the vi Use an isolated local target when mutations would otherwise affect production. Verify the server or executable belongs to this work, not another session. Respect explicit authorization for external writes. Give every subprocess a deadline no greater than 300 seconds, kill then reap on timeout, and run only one local build at once. +Establish that the application was rebuilt from the intended source before recording its executable digest. Warm build directories can reuse stale binaries after timestamp-preserving copies; invalidate the affected application artifact or otherwise prove the rebuild. Run the actual built program, including negative cases with explicit expected exits. + +When installed, `allstack check --expect-exit 4 --seconds 120 -- /absolute/program arguments` retains a private receipt without creating a delivery run or task. Omit `--expect-exit` for zero; `--input prompt.txt` supplies bounded UTF-8 stdin. The deadline includes metadata. The repository must ignore `.allstack/`. A matching expected negative exit succeeds; timeout, signal, spawn failure, changed executable, and status mismatch fail and retain their receipt. Inspect `outcome`, separated raw stream paths and digests, and the target's result. Decoded text can be lossy; raw streams preserve bytes. Host tool summaries can combine channels and do not replace those files. An executable digest binds observed bytes, not the source that produced them. + Record the target version, command or interaction, input, observed result, expected result, and executed test count where available. A zero-case run, timeout, skipped browser, or unavailable credential remains unverified. Inspect the underlying result rather than trusting a model summary or green wrapper. ## Deliver the outcome diff --git a/skills/allstack-recall/SKILL.md b/skills/allstack-recall/SKILL.md index 8423ae5..1eee73d 100644 --- a/skills/allstack-recall/SKILL.md +++ b/skills/allstack-recall/SKILL.md @@ -9,6 +9,8 @@ Turn the user's question into a specific query. Use connected project Prime tool Inspect the cited source for every result that affects the recommendation. A retained `source_digest` identifies the file version remembered; compare current bytes before carrying an old decision forward. If a source changed or disappeared, label the result historical and inspect its successor. Missing search results do not prove the project never made the decision. +An explicit absent-store result is a verification limit. Continue from the supplied indexed plan and relevant project source; do not search the toolkit's implementation to invent missing memory or create a replacement store merely to satisfy recall. + Distinguish proposals, accepted decisions, observed outcomes, and actual current state. Query AllSource workflow state, Chronis ownership, or GitHub publication through their own tools when that is what the question asks. Prime context cannot establish those facts or grant authority. Return the relevant decision, why it applies, source path, and any conflicting or superseding evidence. Do not dump unrelated memory. Preserve useful accepted knowledge through `allstack remember --kind decision|review|qa --source --summary ` when requested or within an authorized documentation workflow. Never store secrets, proprietary source, or full file contents. diff --git a/skills/allstack-ship/SKILL.md b/skills/allstack-ship/SKILL.md index fa51e88..7c8544a 100644 --- a/skills/allstack-ship/SKILL.md +++ b/skills/allstack-ship/SKILL.md @@ -13,6 +13,10 @@ Use `allstack current` behind the interface. For an active delivery, inspect its Review the candidate's behavior and applicable risk with `allstack-review`, then exercise the real job with `allstack-qa`. These are judgment and runtime checks, not substitutes for repository gates. Keep the accepted scope, source-linked decisions, and verification requirements intact. +For native adoption, inspect the coordinator checkout that holds the candidate and its exact commit. An epoch worktree may still hold the earlier base; do not use its HEAD as evidence of the native candidate. Keep the coordinator at its original path until publication and store readback finish, then archive its path-bound receipts before cleanup. Preserve pre-existing Chronis tasks and required project Prime context. + +Existing guarded configuration files can remain in the tracked base: snapshots bind their Git identities without copying their contents into Allstack artifacts. A changed, added, or deleted guarded file blocks enrollment. Preserve the user's configuration; resolve the actual change rather than removing unrelated tracked files to satisfy adoption. + ## Verify and publish Use the returned run identity and epoch internally. `allstack verify` prepares the owned single commit and runs the frozen policy against that exact SHA. Confirm positive test execution and unchanged candidate/environment. Any edit or amended commit requires fresh affected verification; do not ship stale receipts. A pending host call must be reconciled, never bypassed. diff --git a/src/domain.rs b/src/domain.rs index b96bc53..ce1b26b 100644 --- a/src/domain.rs +++ b/src/domain.rs @@ -58,6 +58,8 @@ pub struct FileEntry { pub struct Snapshot { pub base: String, pub files: BTreeMap, + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub preserved: BTreeMap, } #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/src/infrastructure/bootstrap.rs b/src/infrastructure/bootstrap.rs index 2530fd3..94d2cfb 100644 --- a/src/infrastructure/bootstrap.rs +++ b/src/infrastructure/bootstrap.rs @@ -206,6 +206,18 @@ fn call(scratch: &Scratch, repo: &Path, argv: &[&str]) -> Result { process::checked(&scratch.0, repo, argv) } +fn tracked_private_state(scratch: &Scratch, repo: &Path) -> Result { + let tracked = call( + scratch, + repo, + &["git", "ls-files", "--", ".allstack", ".chronis", ".prime"], + )?; + // Chronis intentionally versions this portable export for Git-backed sync. + Ok(tracked + .lines() + .any(|path| path != ".chronis/sync/events.jsonl")) +} + fn repair_overridden_ignores(scratch: &Scratch, repo: &Path, files: &mut [Change]) -> Result<()> { let outcome = process::run(process::Spec { argv: [ @@ -327,12 +339,7 @@ pub fn initialize(repo: &Path, check: bool, profile: Option<&str>, policy: &str) if call(&scratch, repo, &["cn", "--version", "--toon"]).is_err() { missing.push("Chronis cn executable unavailable".into()); } - let tracked = call( - &scratch, - repo, - &["git", "ls-files", "--", ".allstack", ".chronis", ".prime"], - )?; - if !tracked.is_empty() { + if tracked_private_state(&scratch, repo)? { missing.push("private store files are tracked; resolve before initialization".into()); } let chronis_exists = root.directory(".chronis")?; @@ -462,12 +469,7 @@ pub fn doctor(repo: &Path, policy: &str) -> Result { if check_ignored(&scratch, repo).is_err() { missing.push("private stores are not all ignored".into()); } - let tracked = call( - &scratch, - repo, - &["git", "ls-files", "--", ".allstack", ".chronis", ".prime"], - )?; - if !tracked.is_empty() { + if tracked_private_state(&scratch, repo)? { missing.push("private store files are tracked".into()); } let tools: Vec<_> = ["codex", "claude", "cn", "git", "gh", "cargo", "rustc"].iter().map(|name| { diff --git a/src/infrastructure/checks.rs b/src/infrastructure/checks.rs new file mode 100644 index 0000000..bceaced --- /dev/null +++ b/src/infrastructure/checks.rs @@ -0,0 +1,218 @@ +//! Native command receipts add repository context without opening delivery services. +use super::{atomic_write, bootstrap::Scratch, digest, private_dir, process}; +use anyhow::{Context, Result, ensure}; +use serde_json::{Value, json}; +use sha2::{Digest, Sha256}; +use std::{ + collections::BTreeMap, + fs, + io::Read, + path::Path, + time::{Duration, Instant}, +}; + +struct Budget(Instant); + +impl Budget { + fn remaining(&self) -> Result { + let left = self.0.saturating_duration_since(Instant::now()).as_secs(); + ensure!(left > 0, "command check deadline exhausted"); + Ok(left) + } + + fn run( + &self, + directory: &Path, + repo: &Path, + argv: Vec, + input: Option, + ) -> Result { + process::run(process::Spec { + argv, + cwd: repo.into(), + seconds: self.remaining()?, + environment: BTreeMap::new(), + input, + directory: directory.into(), + }) + } + + fn git(&self, directory: &Path, repo: &Path, args: &[&str]) -> Result { + self.run( + directory, + repo, + std::iter::once("git") + .chain(args.iter().copied()) + .map(str::to_owned) + .collect(), + None, + ) + } +} + +fn fingerprint(path: &Path) -> Result { + let resolved = path.canonicalize()?; + ensure!(resolved.is_file(), "executable is not a file"); + Ok(json!({"path":resolved,"sha256":file_digest(&resolved)?.0})) +} + +fn file_digest(path: &Path) -> Result<(String, u64)> { + let mut file = fs::File::open(path)?; + let mut hasher = Sha256::new(); + let mut size = 0; + let mut buffer = [0; 64 * 1024]; + loop { + let count = file.read(&mut buffer)?; + if count == 0 { + return Ok((format!("{:x}", hasher.finalize()), size)); + } + hasher.update(&buffer[..count]); + size += count as u64; + } +} + +fn streams(directory: &Path) -> Result { + if !directory.exists() { + return Ok(json!([])); + } + let mut values = Vec::new(); + for entry in fs::read_dir(directory)? { + let path = entry?.path(); + for name in ["stdout", "stderr", "supervisor-stderr"] { + let file = path.join(name); + if file.is_file() { + let (sha256, bytes) = file_digest(&file)?; + values.push(json!({"channel":name,"path":file,"bytes":bytes,"sha256":sha256})); + } + } + } + Ok(json!(values)) +} + +pub fn check( + repo: &Path, + argv: &[String], + expected_exit: u8, + seconds: u64, + input: Option<&Path>, +) -> Result { + ensure!( + (2..=270).contains(&seconds), + "check deadline must be 2..=270 seconds including metadata" + ); + ensure!( + !argv.is_empty() && argv.iter().map(String::len).sum::() <= 64 * 1024, + "command must contain 1..64 KiB" + ); + ensure!( + Path::new(&argv[0]).is_absolute(), + "check requires an absolute executable path" + ); + let budget = Budget(Instant::now() + Duration::from_secs(seconds)); + let scratch = Scratch::new()?; + budget + .git( + &scratch.0, + repo, + &["check-ignore", "--quiet", ".allstack/checks/probe"], + )? + .checked() + .context("ignore .allstack/ before retaining command evidence")?; + let directory = repo + .join(".allstack/checks") + .join(uuid::Uuid::new_v4().to_string()); + for path in [ + repo.join(".allstack"), + repo.join(".allstack/checks"), + directory.clone(), + ] { + private_dir(&path)?; + } + let receipt_path = directory.join("receipt.json"); + let command_directory = directory.join("command"); + let mut receipt = json!({ + "schema":1,"receipt":receipt_path,"repository":repo,"argv":argv, + "expected_exit":expected_exit,"deadline_seconds":seconds,"matched":false, + "source":null,"executable_before":null,"executable_after":null,"input":null, + "outcome":null,"error":null,"streams":[], + }); + atomic_write(&receipt_path, &serde_json::to_vec_pretty(&receipt)?)?; + let mut result: Result<()> = (|| { + let head = budget.git( + &directory.join("metadata"), + repo, + &["rev-parse", "--verify", "HEAD"], + )?; + ensure!( + !head.timed_out && !head.owner_lost, + "source revision lookup interrupted" + ); + let status = budget + .git( + &directory.join("metadata"), + repo, + &["status", "--porcelain=v1", "--untracked-files=all"], + )? + .checked()?; + receipt["source"] = json!({"head":if head.success() {Some(head.stdout.trim().to_owned())} else {None},"status":status}); + receipt["executable_before"] = fingerprint(Path::new(&argv[0]))?; + let text = input + .map(|path| -> Result { + let path = if path.is_absolute() { + path.to_owned() + } else { + repo.join(path) + }; + let mut bytes = Vec::new(); + fs::File::open(&path)? + .take(128 * 1024 + 1) + .read_to_end(&mut bytes)?; + ensure!(bytes.len() <= 128 * 1024, "input exceeds 128 KiB"); + receipt["input"] = json!({"path":path,"sha256":digest(&bytes),"bytes":bytes.len()}); + String::from_utf8(bytes).context("input must be UTF-8") + }) + .transpose()?; + atomic_write(&receipt_path, &serde_json::to_vec_pretty(&receipt)?)?; + let outcome = budget.run(&command_directory, repo, argv.to_vec(), text)?; + let matched = outcome.code == Some(i32::from(expected_exit)) + && !outcome.timed_out + && !outcome.owner_lost; + receipt["outcome"] = serde_json::to_value(&outcome)?; + receipt["executable_after"] = fingerprint(Path::new(&argv[0]))?; + ensure!( + receipt["executable_before"] == receipt["executable_after"], + "executable changed during command" + ); + ensure!( + Instant::now() < budget.0, + "command check deadline exhausted" + ); + ensure!( + matched, + "expected exit {expected_exit}; observed exit {:?}, timeout {}, owner lost {}", + outcome.code, + outcome.timed_out, + outcome.owner_lost + ); + Ok(()) + })(); + match streams(&command_directory) { + Ok(value) => receipt["streams"] = value, + Err(error) => { + receipt["streams_error"] = json!(format!("{error:#}")); + if result.is_ok() { + result = Err(error.context("raw stream evidence could not be retained")); + } + } + } + if result.is_ok() && Instant::now() >= budget.0 { + result = Err(anyhow::anyhow!("command check deadline exhausted")); + } + receipt["matched"] = json!(result.is_ok()); + if let Err(error) = &result { + receipt["error"] = json!(format!("{error:#}")); + } + atomic_write(&receipt_path, &serde_json::to_vec_pretty(&receipt)?)?; + result.with_context(|| format!("command check failed; receipt {}", receipt_path.display()))?; + Ok(receipt) +} diff --git a/src/infrastructure/mod.rs b/src/infrastructure/mod.rs index b39f442..740b8b2 100644 --- a/src/infrastructure/mod.rs +++ b/src/infrastructure/mod.rs @@ -2,6 +2,7 @@ pub mod bootstrap; pub mod bootstrap_fs; pub mod build_lock; pub mod catalog; +pub mod checks; pub mod chronis; pub mod forge; pub mod gates; diff --git a/src/infrastructure/workspace.rs b/src/infrastructure/workspace.rs index a089d70..f8d740e 100644 --- a/src/infrastructure/workspace.rs +++ b/src/infrastructure/workspace.rs @@ -34,7 +34,45 @@ pub fn contained(root: &Path, relative: &str) -> Result { Ok(current) } -fn manifest(state: &Path, cwd: &Path) -> Result, bool)>> { +#[derive(PartialEq, Eq)] +struct Manifest { + files: BTreeMap, bool)>, + preserved: BTreeMap, +} + +fn guarded_path(name: &str) -> bool { + name.to_lowercase().split('/').any(|p| { + p == ".env" + || p.starts_with(".env.") + || p == "credentials.json" + || p == "id_rsa" + || p.ends_with(".pem") + }) +} + +fn base_blob(state: &Path, cwd: &Path, base: &str, name: &str) -> Result { + contained(cwd, name)?; + git::call(state, cwd, &["diff", "--quiet", base, "--", name]) + .with_context(|| format!("sensitive path must match tracked base: {name}"))?; + let expected = git::call( + state, + cwd, + &["rev-parse", "--verify", &format!("{base}:{name}")], + ) + .with_context(|| format!("sensitive path must exist in tracked base: {name}"))?; + let actual = git::call( + state, + cwd, + &["hash-object", &format!("--path={name}"), "--", name], + )?; + ensure!( + actual == expected, + "sensitive path must match tracked base: {name}" + ); + Ok(expected) +} + +fn manifest(state: &Path, cwd: &Path) -> Result { let tracked = git::call(state, cwd, &["ls-files", "-z"])?; let others = git::call( state, @@ -46,17 +84,17 @@ fn manifest(state: &Path, cwd: &Path) -> Result, bool) "untracked files require explicit git add before checkpoint; never snapshot them implicitly" ); let mut result = BTreeMap::new(); + let mut preserved = BTreeMap::new(); let mut total = 0; for name in tracked.split('\0').filter(|s| !s.is_empty()) { - let lower = name.to_lowercase(); ensure!( - !lower.split('/').any(|p| p == ".env" - || p.starts_with(".env.") - || p == "credentials.json" - || p == "id_rsa" - || p.ends_with(".pem")), - "sensitive path requires removal from tracked snapshot: {name}" + result.len() + preserved.len() < 10000, + "snapshot exceeds supported bounds" ); + if guarded_path(name) { + preserved.insert(name.to_string(), base_blob(state, cwd, "HEAD", name)?); + continue; + } let path = contained(cwd, name)?; if !path.exists() { continue; @@ -68,7 +106,7 @@ fn manifest(state: &Path, cwd: &Path) -> Result, bool) ); total += meta.len(); ensure!( - total <= 64 * 1024 * 1024 && result.len() < 10000, + total <= 64 * 1024 * 1024, "snapshot exceeds supported bounds" ); result.insert( @@ -76,13 +114,17 @@ fn manifest(state: &Path, cwd: &Path) -> Result, bool) (fs::read(path)?, meta.permissions().mode() & 0o111 != 0), ); } - Ok(result) + Ok(Manifest { + files: result, + preserved, + }) } pub fn capture(state: &Path, cwd: &Path, artifacts: &Artifacts) -> Result { let base = git::call(state, cwd, &["rev-parse", "HEAD"])?; let before = manifest(state, cwd)?; let files = before + .files .iter() .map(|(name, (bytes, executable))| { Ok(( @@ -98,7 +140,11 @@ pub fn capture(state: &Path, cwd: &Path, artifacts: &Artifacts) -> Result Result { @@ -107,14 +153,28 @@ pub fn read_snapshot(artifacts: &Artifacts, key: &str) -> Result { pub fn materialize(state: &Path, cwd: &Path, artifacts: &Artifacts, key: &str) -> Result<()> { let snapshot = read_snapshot(artifacts, key)?; + for (name, blob) in &snapshot.preserved { + ensure!( + guarded_path(name) && !snapshot.files.contains_key(name), + "invalid preserved path" + ); + ensure!( + base_blob(state, cwd, &snapshot.base, name)? == *blob, + "preserved base identity changed: {name}" + ); + } let mut bytes = BTreeMap::new(); for (name, entry) in &snapshot.files { + ensure!( + !guarded_path(name), + "guarded contents cannot be materialized: {name}" + ); contained(cwd, name)?; bytes.insert(name, artifacts.get(&entry.digest)?); } let tracked = git::call(state, cwd, &["ls-files", "-z"])?; for name in tracked.split('\0').filter(|s| !s.is_empty()) { - if !snapshot.files.contains_key(name) { + if !snapshot.files.contains_key(name) && !snapshot.preserved.contains_key(name) { let target = contained(cwd, name)?; if target.exists() { fs::remove_file(target)?; @@ -137,13 +197,16 @@ pub fn materialize(state: &Path, cwd: &Path, artifacts: &Artifacts, key: &str) - // Restored files must enter the index so later snapshots include new source. let names: Vec<&str> = snapshot.files.keys().map(String::as_str).collect(); if !names.is_empty() { - let mut args = vec!["add", "--"]; + let mut args = vec!["add", "-f", "--"]; args.extend(names); git::call(state, cwd, &args)?; } let check = capture(state, cwd, artifacts)?; let actual = read_snapshot(artifacts, &check)?; - ensure!(actual.files == snapshot.files, "restored snapshot differs"); + ensure!( + actual.files == snapshot.files && actual.preserved == snapshot.preserved, + "restored snapshot differs" + ); Ok(()) } @@ -161,7 +224,9 @@ pub fn restore( if path.exists() { let actual = read_snapshot(artifacts, &capture(state, &path, artifacts)?)?; ensure!( - actual.files == snapshot.files && actual.base == snapshot.base, + actual.files == snapshot.files + && actual.preserved == snapshot.preserved + && actual.base == snapshot.base, "interrupted handoff workspace differs; inspect before recovery" ); return Ok(path); @@ -183,7 +248,11 @@ pub fn restore( pub fn content_digest(artifacts: &Artifacts, key: &str) -> Result { let snapshot = read_snapshot(artifacts, key)?; - Ok(digest(&serde_json::to_vec(&snapshot.files)?)) + Ok(digest(&if snapshot.preserved.is_empty() { + serde_json::to_vec(&snapshot.files)? + } else { + serde_json::to_vec(&(snapshot.files, snapshot.preserved))? + })) } pub fn write_plan(state: &Path, cwd: &Path, run: &str, text: &str) -> Result { diff --git a/src/main.rs b/src/main.rs index ee0f15e..aba69c0 100644 --- a/src/main.rs +++ b/src/main.rs @@ -29,6 +29,7 @@ async fn main() { let scratch = if matches!( cli.command, allstack::presentation::Action::Init { .. } + | allstack::presentation::Action::Check { .. } | allstack::presentation::Action::Doctor { .. } | allstack::presentation::Action::Catalog { .. } | allstack::presentation::Action::Skills { .. } diff --git a/src/presentation.rs b/src/presentation.rs index d66c9ed..1af723c 100644 --- a/src/presentation.rs +++ b/src/presentation.rs @@ -25,6 +25,17 @@ pub struct Cli { #[derive(Subcommand)] pub enum Action { + /// Capture a supervised command with an expected exit and private evidence. + Check { + #[arg(long, default_value_t = 0)] + expect_exit: u8, + #[arg(long, default_value_t = 120, value_parser = clap::value_parser!(u64).range(2..=270))] + seconds: u64, + #[arg(long)] + input: Option, + #[arg(last = true, required = true)] + argv: Vec, + }, Skills { skill: Option, }, @@ -163,6 +174,20 @@ pub async fn execute(cli: Cli) -> Result { }; } Action::Skills { skill } => return skills::inspect(skill.as_deref()), + Action::Check { + expect_exit, + seconds, + input, + argv, + } => { + return crate::infrastructure::checks::check( + &repo, + argv, + *expect_exit, + *seconds, + input.as_deref(), + ); + } Action::Remember { kind, source, @@ -186,6 +211,7 @@ pub async fn execute(cli: Cli) -> Result { async fn dispatch(p: &Runtime, action: Action) -> Result { let run = match action { Action::Doctor { .. } + | Action::Check { .. } | Action::Init { .. } | Action::Catalog { .. } | Action::Skills { .. } diff --git a/templates/native-skills.md b/templates/native-skills.md index 7d749f4..8f209d8 100644 --- a/templates/native-skills.md +++ b/templates/native-skills.md @@ -16,8 +16,14 @@ Install just this interface with `allstack init --skills-only --check`, then `al `allstack skills` lists bundled methods without opening stores. `allstack skills review` returns the shared instructions. Native skills use the host's existing tools and permissions; unavailable browser or runtime capabilities remain unverified. A skill is guidance, not a sandbox or permission grant. +For CLI/runtime evidence, `allstack check --expect-exit 4 --seconds 120 -- /absolute/program arguments` records expected and observed exit, source checkout, executable identity, and separate stdout/stderr under ignored `.allstack/checks/`. Omit `--expect-exit` for zero. `--input prompt.txt` supplies up to 128 KiB of UTF-8 stdin, including native host prompts. The 2..270 second deadline covers metadata and execution; all children remain supervised. A matching expected negative exit succeeds. Failures retain a receipt and return nonzero. Raw stream paths and digests preserve evidence even when a host combines channels. This command creates no delivery run, Chronis task, or Prime store. Rebuild the application from intended sources before trusting its digest as candidate evidence. + +Installation preserves conflicting files. To update a generated skill, compare the installed file with its known previous generated version, review the new shared method and host overlay, and preserve local customization. Resolve reviewed replacements explicitly; initialization never silently overwrites them. + Save plans and required reports under indexed repo docs. `allstack remember --kind decision --source docs/plans/change.md --summary 'Accepted decision'` records a bounded source-linked summary and source digest in local Prime. Use `allstack recall 'relevant query'` to retrieve context; inspect the remembered source before relying on it. Memory creates no delivery run or task. Keep private stores ignored; never record secrets or full source contents. For a complete durable delivery, initialize with a project policy using the CLI/recovery guide. `allstack current` discovers the single active run behind the skill interface. Multiple active runs require explicit selection. `allstack adopt --request docs/requests/change.md --plan docs/plans/change.md --policy workflows/verification.toml --host codex --allow-commit --allow-publish` enrolls a completed native candidate and its existing plan without repeating model work. Stage intended new files explicitly before adoption. Initialize real Chronis first; branch, commit, and publication authority still come from the user. AllSource owns managed run history and receipts, Chronis owns delivery claims and completion, and Prime supplies source-linked context. The coordinator retains exact-commit verification, immutable handoffs, bounded process ownership, and destination reconciliation. Its plan/build host calls remain more restricted than independent native execution. Shipping ends at a verified remote commit and open PR; merge and deployment remain separate actions. + +Keep the coordinator at its original path through verification and publication. Native adoption verifies the candidate in that checkout; an epoch worktree may retain the base. Preserve existing project tracker/memory state and archive path-bound history before removing an isolated coordinator. diff --git a/tests/bootstrap.rs b/tests/bootstrap.rs index 959566f..9e3ee63 100644 --- a/tests/bootstrap.rs +++ b/tests/bootstrap.rs @@ -309,6 +309,50 @@ fn check_mode_never_opens_existing_chronis_engine() { assert!(!f.repo.join("workflows").exists()); } +#[test] +fn tracked_chronis_sync_export_survives_initialization_and_doctor() { + let f = Fixture::new(false); + fs::create_dir_all(f.repo.join(".chronis/sync")).unwrap(); + fs::write(f.repo.join(".chronis/protocol"), "fixture").unwrap(); + let export = f.repo.join(".chronis/sync/events.jsonl"); + fs::write(&export, "export fixture\n").unwrap(); + let mut stage = Command::new("git"); + stage + .current_dir(&f.repo) + .args(["add", "-f", ".chronis/sync/events.jsonl"]); + let (ok, _, error) = bounded(stage); + assert!(ok, "{error}"); + assert_eq!(f.json(&["init", "--profile", "rust"])["ready"], true); + assert_eq!(f.json(&["doctor"])["setup_ready"], true); + assert_eq!(fs::read_to_string(export).unwrap(), "export fixture\n"); +} + +#[test] +fn tracked_private_state_still_blocks_setup_with_sync_export_present() { + for private in [ + ".chronis/events.jsonl", + ".chronis/sync/other.jsonl", + ".prime/store", + ".allstack/receipt", + ] { + let f = Fixture::new(false); + for name in [".chronis/sync/events.jsonl", private] { + let path = f.repo.join(name); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(path, "fixture\n").unwrap(); + let mut stage = Command::new("git"); + stage.current_dir(&f.repo).args(["add", "-f", "--", name]); + let (ok, _, error) = bounded(stage); + assert!(ok, "{error}"); + } + let before = f.fingerprint(); + let (ok, _, error) = f.run(&["init", "--profile", "rust"]); + assert!(!ok, "accepted tracked private state: {private}"); + assert!(error.contains("private store files are tracked"), "{error}"); + assert_eq!(f.fingerprint(), before); + } +} + #[test] fn conflicts_and_missing_profile_fail_before_chronis_or_file_writes() { let f = Fixture::new(false); diff --git a/tests/checks.rs b/tests/checks.rs new file mode 100644 index 0000000..92e2668 --- /dev/null +++ b/tests/checks.rs @@ -0,0 +1,299 @@ +use serde_json::Value; +use std::{ + fs, + path::Path, + process::{Command, Output, Stdio}, + time::{Duration, Instant}, +}; + +struct Repository(tempfile::TempDir); + +fn execute(mut command: Command) -> Output { + let mut child = command + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(); + let deadline = Instant::now() + Duration::from_secs(20); + loop { + if child.try_wait().unwrap().is_some() { + return child.wait_with_output().unwrap(); + } + if Instant::now() >= deadline { + child.kill().unwrap(); + let output = child.wait_with_output().unwrap(); + panic!("test command timed out: {output:?}"); + } + std::thread::sleep(Duration::from_millis(10)); + } +} + +impl Repository { + fn new(ignored: bool) -> Self { + let repo = Self(tempfile::tempdir().unwrap()); + let mut git = Command::new("git"); + git.args(["init", "--quiet", "--template="]) + .arg(repo.0.path()); + assert!(execute(git).status.success()); + if ignored { + fs::write(repo.0.path().join(".gitignore"), ".allstack/\n").unwrap(); + } + repo + } + + fn check(&self, args: &[&str]) -> Output { + let mut command = Command::new(env!("CARGO_BIN_EXE_allstack")); + command.arg("-C").arg(self.0.path()).arg("check").args(args); + execute(command) + } + + fn receipt(&self) -> Value { + let checks = self.0.path().join(".allstack/checks"); + let receipts: Vec<_> = fs::read_dir(checks) + .unwrap() + .map(|entry| entry.unwrap().path().join("receipt.json")) + .collect(); + assert_eq!(receipts.len(), 1); + serde_json::from_slice(&fs::read(&receipts[0]).unwrap()).unwrap() + } +} + +fn assert_no_delivery(repo: &Repository) { + assert!(!repo.0.path().join(".chronis").exists()); + let entries: Vec<_> = fs::read_dir(repo.0.path().join(".allstack")) + .unwrap() + .map(|e| e.unwrap().file_name()) + .collect(); + assert_eq!(entries, ["checks"]); +} + +#[test] +fn expected_negative_exit_is_success_with_separate_raw_channels() { + let repo = Repository::new(true); + let output = repo.check(&[ + "--expect-exit", + "4", + "--", + "/bin/sh", + "-c", + "printf observed; printf rejected >&2; exit 4", + ]); + assert!(output.status.success(), "{output:?}"); + assert!(output.stderr.is_empty()); + let value: Value = serde_json::from_slice(&output.stdout).unwrap(); + assert_eq!(value, repo.receipt()); + assert_eq!(value["matched"], true); + assert_eq!(value["outcome"]["code"], 4); + assert_eq!(value["outcome"]["stdout"], "observed"); + assert_eq!(value["outcome"]["stderr"], "rejected"); + assert_eq!(value["executable_before"], value["executable_after"]); + for stream in value["streams"].as_array().unwrap() { + let bytes = fs::read(stream["path"].as_str().unwrap()).unwrap(); + assert_eq!(stream["sha256"], allstack::infrastructure::digest(&bytes)); + } + assert_no_delivery(&repo); +} + +#[test] +fn unexpected_exit_retains_failed_receipt_and_names_it() { + let repo = Repository::new(true); + let output = repo.check(&["--", "/bin/sh", "-c", "exit 4"]); + assert!(!output.status.success()); + let receipt = repo.receipt(); + assert_eq!(receipt["matched"], false); + assert_eq!(receipt["outcome"]["code"], 4); + let error = String::from_utf8(output.stderr).unwrap(); + assert!(error.contains(receipt["receipt"].as_str().unwrap())); + assert!(error.contains("observed exit Some(4)")); +} + +#[test] +fn deadline_terminates_child_and_cannot_match_expected_zero() { + let repo = Repository::new(true); + let output = repo.check(&["--seconds", "2", "--", "/bin/sleep", "10"]); + assert!(!output.status.success()); + let receipt = repo.receipt(); + assert_eq!(receipt["outcome"]["timed_out"], true); + assert_eq!(receipt["matched"], false); +} + +#[test] +fn spawn_failure_retains_supervisor_error() { + use std::os::unix::fs::PermissionsExt; + let repo = Repository::new(true); + let path = repo.0.path().join("not-executable"); + fs::write(&path, "not executable").unwrap(); + fs::set_permissions(&path, fs::Permissions::from_mode(0o600)).unwrap(); + let output = repo.check(&["--", path.to_str().unwrap()]); + assert!(!output.status.success()); + let receipt = repo.receipt(); + assert_eq!(receipt["matched"], false); + assert!(receipt["outcome"].is_null()); + assert!( + receipt["streams"] + .as_array() + .unwrap() + .iter() + .any(|s| s["channel"] == "supervisor-stderr" && s["bytes"].as_u64().unwrap() > 0) + ); +} + +#[test] +fn missing_executable_retains_failure_without_claiming_an_exit() { + let repo = Repository::new(true); + let missing = repo.0.path().join("missing"); + assert!( + !repo + .check(&["--", missing.to_str().unwrap()]) + .status + .success() + ); + let value = repo.receipt(); + assert_eq!(value["matched"], false); + assert!(value["outcome"].is_null()); + assert!(value["error"].is_string()); +} + +#[test] +fn input_digest_binds_the_bytes_delivered_to_stdin() { + let repo = Repository::new(true); + fs::write(repo.0.path().join("prompt.txt"), "native prompt\n").unwrap(); + let output = repo.check(&["--input", "prompt.txt", "--", "/bin/cat"]); + assert!(output.status.success(), "{output:?}"); + let value = repo.receipt(); + assert_eq!( + value["input"]["sha256"], + allstack::infrastructure::digest(b"native prompt\n") + ); + assert_eq!(value["outcome"]["stdout"], "native prompt\n"); + assert_no_delivery(&repo); +} + +#[test] +fn unignored_evidence_is_refused_before_creating_private_files() { + let repo = Repository::new(false); + let output = repo.check(&["--", "/usr/bin/true"]); + assert!(!output.status.success()); + assert!(!repo.0.path().join(".allstack").exists()); + assert!( + String::from_utf8(output.stderr) + .unwrap() + .contains("ignore .allstack/") + ); +} + +#[test] +fn relative_program_and_excessive_deadline_are_rejected() { + let repo = Repository::new(true); + assert!(!repo.check(&["--", "true"]).status.success()); + assert!( + !repo + .check(&["--seconds", "271", "--", "/usr/bin/true"]) + .status + .success() + ); + assert!(!repo.0.path().join(".allstack").exists()); +} + +#[test] +fn signal_cannot_match_a_numeric_expected_exit() { + let repo = Repository::new(true); + assert!( + !repo + .check(&["--", "/bin/sh", "-c", "kill -TERM $$"]) + .status + .success() + ); + let value = repo.receipt(); + assert_eq!(value["matched"], false); + assert!(value["outcome"]["code"].is_null()); +} + +#[test] +fn changed_executable_invalidates_matching_exit() { + use std::os::unix::fs::PermissionsExt; + let repo = Repository::new(true); + let program = repo.0.path().join("mutable-program"); + fs::write( + &program, + "#!/bin/sh\nprintf '\\n# changed\\n' >> \"$0\"\nexit 0\n", + ) + .unwrap(); + fs::set_permissions(&program, fs::Permissions::from_mode(0o700)).unwrap(); + let output = repo.check(&["--", program.to_str().unwrap()]); + assert!(!output.status.success()); + let value = repo.receipt(); + assert_eq!(value["outcome"]["code"], 0); + assert_eq!(value["matched"], false); + assert_ne!(value["executable_before"], value["executable_after"]); +} + +#[test] +fn source_identity_records_the_actual_checkout() { + let repo = Repository::new(true); + let output = repo.check(&["--", "/usr/bin/true"]); + assert!(output.status.success(), "{output:?}"); + let receipt = repo.receipt(); + assert!( + receipt["source"]["head"].is_null(), + "fixture has an unborn HEAD" + ); + assert!( + receipt["source"]["status"] + .as_str() + .unwrap() + .contains(".gitignore") + ); + assert_eq!( + Path::new(receipt["repository"].as_str().unwrap()), + repo.0.path().canonicalize().unwrap() + ); +} + +#[test] +fn non_utf8_output_preserves_raw_bytes_beside_decoded_text() { + let repo = Repository::new(true); + let output = repo.check(&["--", "/usr/bin/printf", "\\377"]); + assert!(output.status.success(), "{output:?}"); + let value = repo.receipt(); + assert_eq!(value["outcome"]["stdout"], "\u{fffd}"); + let stdout = value["streams"] + .as_array() + .unwrap() + .iter() + .find(|v| v["channel"] == "stdout") + .unwrap(); + assert_eq!(fs::read(stdout["path"].as_str().unwrap()).unwrap(), [255]); + assert_eq!(stdout["sha256"], allstack::infrastructure::digest(&[255])); +} + +#[test] +fn oversized_input_fails_before_starting_the_command() { + let repo = Repository::new(true); + fs::write(repo.0.path().join("large.txt"), vec![b'x'; 128 * 1024 + 1]).unwrap(); + let output = repo.check(&["--input", "large.txt", "--", "/usr/bin/true"]); + assert!(!output.status.success()); + let value = repo.receipt(); + assert_eq!(value["matched"], false); + assert!(value["outcome"].is_null()); + assert_eq!(value["streams"], serde_json::json!([])); +} + +#[test] +fn symlink_invocation_keeps_argv_zero_while_recording_resolved_identity() { + use std::os::unix::fs::{PermissionsExt, symlink}; + let repo = Repository::new(true); + let real = repo.0.path().join("real-program"); + let alias = repo.0.path().join("program-alias"); + fs::write(&real, "#!/bin/sh\nprintf '%s' \"$0\"\n").unwrap(); + fs::set_permissions(&real, fs::Permissions::from_mode(0o700)).unwrap(); + symlink(&real, &alias).unwrap(); + let output = repo.check(&["--", alias.to_str().unwrap()]); + assert!(output.status.success(), "{output:?}"); + let value = repo.receipt(); + assert_eq!(value["outcome"]["stdout"], alias.to_str().unwrap()); + assert_eq!( + value["executable_before"]["path"], + real.canonicalize().unwrap().to_str().unwrap() + ); +} diff --git a/tests/workspace.rs b/tests/workspace.rs new file mode 100644 index 0000000..c16ce60 --- /dev/null +++ b/tests/workspace.rs @@ -0,0 +1,268 @@ +use allstack::infrastructure::{Artifacts, process, workspace}; +use std::{collections::BTreeMap, fs, path::PathBuf}; + +struct Fixture { + _temp: tempfile::TempDir, + repo: PathBuf, + state: PathBuf, + artifacts: Artifacts, +} + +impl Fixture { + fn new() -> Self { + let temp = tempfile::tempdir().unwrap(); + let repo = temp.path().join("repository"); + let state = temp.path().join("state"); + fs::create_dir(&repo).unwrap(); + fs::create_dir(&state).unwrap(); + let f = Self { + artifacts: Artifacts(state.join("objects")), + _temp: temp, + repo, + state, + }; + f.git(&["init", "--quiet"]); + fs::write(f.repo.join("source.txt"), "before\n").unwrap(); + fs::write(f.repo.join(".gitignore"), "*.skill\n").unwrap(); + fs::write( + f.repo.join(".env.production"), + "GUARDED_FIXTURE_CONTENT=local\n", + ) + .unwrap(); + f.git(&[ + "add", + "-f", + "--", + "source.txt", + ".env.production", + ".gitignore", + ]); + f.commit(); + f + } + + fn git(&self, args: &[&str]) { + let outcome = process::run(process::Spec { + argv: ["git"] + .into_iter() + .chain(args.iter().copied()) + .map(String::from) + .collect(), + cwd: self.repo.clone(), + seconds: 10, + environment: BTreeMap::new(), + input: None, + directory: self.state.join("process"), + }) + .unwrap(); + assert!(outcome.success(), "{}", outcome.stderr); + } + + fn commit(&self) { + self.git(&[ + "-c", + "user.name=fixture", + "-c", + "user.email=fixture@example.test", + "-c", + "commit.gpgsign=false", + "commit", + "--quiet", + "-m", + "test fixture", + ]); + } + + fn capture(&self) -> anyhow::Result { + workspace::capture(&self.state, &self.repo, &self.artifacts) + } +} + +fn unchanged_guarded_base_files_restore_without_copying_contents_to_artifacts() { + let f = Fixture::new(); + fs::write(f.repo.join("source.txt"), "candidate\n").unwrap(); + fs::write(f.repo.join("method.skill"), "native method\n").unwrap(); + f.git(&["add", "-f", "--", "method.skill"]); + let key = f.capture().unwrap(); + let snapshot = workspace::read_snapshot(&f.artifacts, &key).unwrap(); + assert!(!snapshot.files.contains_key(".env.production")); + let restored = workspace::restore(&f.state, &f.repo, &f.artifacts, &key, "guarded", 1).unwrap(); + assert_eq!( + fs::read_to_string(restored.join("source.txt")).unwrap(), + "candidate\n" + ); + assert_eq!( + fs::read_to_string(restored.join("method.skill")).unwrap(), + "native method\n" + ); + assert_eq!( + fs::read_to_string(restored.join(".env.production")).unwrap(), + "GUARDED_FIXTURE_CONTENT=local\n" + ); + for entry in fs::read_dir(&f.artifacts.0).unwrap() { + let bytes = fs::read(entry.unwrap().path()).unwrap(); + assert!( + !bytes + .windows(b"GUARDED_FIXTURE_CONTENT".len()) + .any(|w| w == b"GUARDED_FIXTURE_CONTENT") + ); + } + assert_eq!( + workspace::restore(&f.state, &f.repo, &f.artifacts, &key, "guarded", 1).unwrap(), + restored + ); +} + +fn changed_added_or_deleted_guarded_files_still_fail_capture() { + for mutation in ["unstaged", "staged", "assumed", "new", "deleted"] { + let f = Fixture::new(); + match mutation { + "unstaged" | "staged" | "assumed" => { + if mutation == "assumed" { + f.git(&["update-index", "--assume-unchanged", ".env.production"]); + } + fs::write(f.repo.join(".env.production"), "changed\n").unwrap(); + if mutation == "staged" { + f.git(&["add", "-f", ".env.production"]); + } + } + "new" => { + fs::write(f.repo.join(".env.local"), "new\n").unwrap(); + f.git(&["add", "-f", ".env.local"]); + } + "deleted" => fs::remove_file(f.repo.join(".env.production")).unwrap(), + _ => unreachable!(), + } + assert!( + f.capture() + .unwrap_err() + .to_string() + .contains("sensitive path"), + "{mutation}" + ); + } +} + +fn preserved_base_changes_invalidate_content_and_refuse_materialization() { + let f = Fixture::new(); + let original = f.capture().unwrap(); + fs::write(f.repo.join(".env.production"), "changed base\n").unwrap(); + f.git(&["add", "-f", ".env.production"]); + f.commit(); + let changed = f.capture().unwrap(); + assert_ne!( + workspace::content_digest(&f.artifacts, &original).unwrap(), + workspace::content_digest(&f.artifacts, &changed).unwrap() + ); + fs::write(f.repo.join("source.txt"), "outside change\n").unwrap(); + assert!(workspace::materialize(&f.state, &f.repo, &f.artifacts, &original).is_err()); + assert_eq!( + fs::read_to_string(f.repo.join("source.txt")).unwrap(), + "outside change\n" + ); +} + +fn guarded_contents_in_artifacts_cannot_overwrite_base_files() { + let f = Fixture::new(); + let key = f.capture().unwrap(); + let mut snapshot = workspace::read_snapshot(&f.artifacts, &key).unwrap(); + snapshot.preserved.clear(); + snapshot.files.insert( + ".env.production".into(), + allstack::domain::FileEntry { + digest: f.artifacts.put(b"forged fixture contents\n").unwrap(), + executable: false, + }, + ); + let forged = f + .artifacts + .put(&serde_json::to_vec(&snapshot).unwrap()) + .unwrap(); + assert!( + workspace::materialize(&f.state, &f.repo, &f.artifacts, &forged) + .unwrap_err() + .to_string() + .contains("guarded contents") + ); + assert_eq!( + fs::read_to_string(f.repo.join(".env.production")).unwrap(), + "GUARDED_FIXTURE_CONTENT=local\n" + ); +} + +fn main() -> anyhow::Result<()> { + let cases: [(&str, fn()); 4] = [ + ( + "restore-without-copy", + unchanged_guarded_base_files_restore_without_copying_contents_to_artifacts, + ), + ( + "reject-guarded-changes", + changed_added_or_deleted_guarded_files_still_fail_capture, + ), + ( + "reject-base-changes", + preserved_base_changes_invalidate_content_and_refuse_materialization, + ), + ( + "reject-guarded-artifacts", + guarded_contents_in_artifacts_cannot_overwrite_base_files, + ), + ]; + let args: Vec = std::env::args().collect(); + if args.iter().any(|arg| arg == "--list") { + if !args.iter().any(|arg| arg == "--ignored") { + for (name, _) in cases { + println!("{name}: test"); + } + } + return Ok(()); + } + if let Some((_, case)) = cases + .iter() + .find(|(name, _)| args.iter().any(|arg| arg == name)) + { + case(); + return Ok(()); + } + anyhow::ensure!( + !args.iter().any(|arg| arg == "--exact"), + "unknown workspace proof case" + ); + match args.get(1).map(String::as_str) { + Some("__supervise") => return process::supervise(), + Some("__case") => { + let (_, case) = cases + .iter() + .find(|(name, _)| Some(*name) == args.get(2).map(String::as_str)) + .ok_or_else(|| anyhow::anyhow!("unknown workspace proof case"))?; + case(); + return Ok(()); + } + _ => {} + } + let state = tempfile::tempdir()?; + for (name, _) in cases { + let result = process::run(process::Spec { + argv: vec![ + std::env::current_exe()?.to_string_lossy().into_owned(), + "__case".into(), + name.into(), + ], + cwd: state.path().into(), + seconds: 45, + environment: BTreeMap::new(), + input: None, + directory: state.path().join(name), + })?; + anyhow::ensure!( + result.success(), + "workspace case {name} failed: {}\n{}", + result.stdout, + result.stderr + ); + println!("workspace case {name}: passed"); + } + println!("{} workspace cases passed", cases.len()); + Ok(()) +}