diff --git a/deno.json b/deno.json index 8e6223b..3fa3a56 100644 --- a/deno.json +++ b/deno.json @@ -1,5 +1,5 @@ { - "version": "0.2.2", + "version": "0.3.0", "tasks": { "dev:root": "deno run --allow-read --allow-write --allow-env --allow-net src/main.ts", "bump:version": "deno run --allow-read --allow-write scripts/bump-version.ts", @@ -30,6 +30,7 @@ "jsonld": "npm:jsonld@9.0.0", "n3": "npm:n3@2.0.3", "rdf-canonize": "npm:rdf-canonize@5.0.0", + "sparqljs": "npm:sparqljs@3.7.4", "shiki": "npm:shiki@4.0.2" }, "exclude": [ diff --git a/deno.lock b/deno.lock index 468a06e..2b76d55 100644 --- a/deno.lock +++ b/deno.lock @@ -18,11 +18,13 @@ "jsr:@std/path@1": "1.1.4", "jsr:@std/path@^1.1.4": "1.1.4", "jsr:@std/text@^1.0.17": "1.0.17", + "npm:@types/sparqljs@3.1.12": "3.1.12", "npm:jsonld@9.0.0": "9.0.0", "npm:n3@*": "2.0.3", "npm:n3@2.0.3": "2.0.3", "npm:rdf-canonize@5.0.0": "5.0.0", - "npm:shiki@4.0.2": "4.0.2" + "npm:shiki@4.0.2": "4.0.2", + "npm:sparqljs@3.7.4": "3.7.4" }, "jsr": { "@cliffy/ansi@1.0.0": { @@ -176,6 +178,12 @@ "undici" ] }, + "@rdfjs/types@1.1.2": { + "integrity": "sha512-wqpOJK1QCbmsGNtyzYnojPU8gRDPid2JO0Q0kMtb4j65xhCK880cnKAfEOwC+dX85VJcCByQx5zOwyyfCjDJsg==", + "dependencies": [ + "@types/node" + ] + }, "@shikijs/core@4.0.2": { "integrity": "sha512-hxT0YF4ExEqB8G/qFdtJvpmHXBYJ2lWW7qTHDarVkIudPFE6iCIrqdgWxGn5s+ppkGXI0aEGlibI0PAyzP3zlw==", "dependencies": [ @@ -243,6 +251,18 @@ "@types/unist" ] }, + "@types/node@26.1.0": { + "integrity": "sha512-O0A1G3xPGy4w7AgQdAQYUlQ+BKk2Oovw8eRpofyp5KdBZULnbe+WqaOVNrm705SHphCiG4XHsACrSmPu1f+Kgw==", + "dependencies": [ + "undici-types" + ] + }, + "@types/sparqljs@3.1.12": { + "integrity": "sha512-zg/sdKKtYI0845wKPSuSgunyU1o/+7tRzMw85lHsf4p/0UbA6+65MXAyEtv1nkaqSqrq/bXm7+bqXas+Xo5dpQ==", + "dependencies": [ + "@rdfjs/types" + ] + }, "@types/unist@3.0.3": { "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==" }, @@ -411,6 +431,12 @@ "setimmediate" ] }, + "rdf-data-factory@1.1.3": { + "integrity": "sha512-ny6CI7m2bq4lfQQmDYvcb2l1F9KtGwz9chipX4oWu2aAtVoXjb7k3d8J1EsgAsEbMXnBipB/iuRen5H2fwRWWQ==", + "dependencies": [ + "@rdfjs/types" + ] + }, "readable-stream@4.7.0": { "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", "dependencies": [ @@ -458,6 +484,14 @@ "space-separated-tokens@2.0.2": { "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==" }, + "sparqljs@3.7.4": { + "integrity": "sha512-hb4C84gf7KM7vz+iG595mPwvqxOvBJCm9L3dCxtV2zZDht6ZMmMG0tHeeqFeqwb9875yU9U4lhYe4LYRvWj0BQ==", + "dependencies": [ + "rdf-data-factory" + ], + "deprecated": true, + "bin": true + }, "string_decoder@1.3.0": { "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", "dependencies": [ @@ -474,6 +508,9 @@ "trim-lines@3.0.1": { "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==" }, + "undici-types@8.3.0": { + "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==" + }, "undici@6.24.1": { "integrity": "sha512-sC+b0tB1whOCzbtlx20fx3WgCXwkW627p4EA9uM+/tNNPkSS+eSEld6pAs9nDv7WbY1UUljBMYPtu9BCOrCWKA==" }, @@ -540,7 +577,8 @@ "npm:jsonld@9.0.0", "npm:n3@2.0.3", "npm:rdf-canonize@5.0.0", - "npm:shiki@4.0.2" + "npm:shiki@4.0.2", + "npm:sparqljs@3.7.4" ] } } diff --git a/documentation/notes/release-notes.v0.3.0.md b/documentation/notes/release-notes.v0.3.0.md new file mode 100644 index 0000000..f704782 --- /dev/null +++ b/documentation/notes/release-notes.v0.3.0.md @@ -0,0 +1,68 @@ +--- +id: f520462880584382b658d0952082620b +title: 'release notes v0.3.0' +desc: '' +updated: 1783310543118 +created: 1783310543118 +--- + +## Summary + +`v0.3.0` is a planner-generalization release driven by Stagecraft, Weave's first application-shaped consumer. Later-payload advancement is now derived from current RDF facts instead of carried fixture shapes, so application meshes can advance `_s0003` and later payload states with current-only support artifacts. The release adds deterministic multi-target payload batches with pre-write input snapshot verification, a `--generated-at` flag for pinned ResourcePage timestamps, and service-integration documentation for applications that drive Weave programmatically. + +The changes were verified against the real Stagecraft temporal-vocabulary fixture ladder: the previously failing three-designator rung now weaves cleanly, with all prior historical state files checksum-verified byte-identical and the result accepted by Accord transition checks. + +## Highlights + +- Later-payload weave planning now derives current history, latest state, next state ordinal, support-artifact policy, and KnopInventory progression from current RDF facts through a dedicated read model, so any coherent later ordinal can advance — not only the carried second-payload fixture shape. Later-ordinal payloads with intentionally current-only support artifacts classify without hints. +- "The current local weave slice only supports the settled second payload weave shape..." failures are gone from the payload path. Planner diagnostics now name the specific missing or conflicting fact, such as a missing `sflo:latestHistoricalState` or conflicting `sflo:nextStateOrdinal` values. The unused second-payload shape assertion was removed and the generalized symbols renamed to later-payload terminology. +- Repeated exact `--target` flags on `weave` and `weave version` now plan multiple payload advancements as one deterministic batch: canonical designator-path order, one merged support-artifact progression for shared support files (a shared MeshInventory advances once per batch), whole-plan refusal before any write when a requested target is malformed, and no-op convergence when re-running an already-applied batch. +- Explicit payload batches get input snapshot verification: Weave hashes the requested targets' current working payload files before batch content capture and verifies them after capture, refusing the whole batch before writing anything if a working payload changed mid-capture. Changes after capture are ignored by design; the batch derives from captured content. +- `--generated-at ` on full `weave` and `weave generate` pins the single timestamp used in generated ResourcePage footers, canonicalized to UTC `toISOString()` form. Supplying the flag converges existing pages to the requested timestamp; omitting it keeps the sample-once-now default and timestamp-only skip behavior. Services can stamp pages with event time, and page regeneration becomes byte-reproducible. +- The `weave` CLI reference now includes service-integration guidance: the application owns coherent serialization and retry orchestration, Weave owes fail-closed whole-plan validation and deterministic output, and refusal plus rerun-no-op semantics make retry loops safe. +- The fixture-ladder conformance flow, CLI reference, and repository options documentation were expanded, and CLI subprocess tests now share a Deno cache for faster, more reliable CI runs. + +## Breaking Or Changed Behavior + +- This is still pre-1.0 software, and no compatibility shims are promised for stale pre-v1 fixture or inventory shapes. +- Planner error text changed on the payload path: tooling that matched "settled second payload weave shape" (or the related settled-shape phrasing) must move to the new condition-specific diagnostics, which name the invalid state instead of the implementation slice. +- Multiple exact payload `--target` flags on `weave` and `weave version` are now planned together as a batch in canonical designator-path order instead of being limited to one payload candidate per invocation. Recursive and mixed-slice target sets keep the existing deterministic sequential planner. +- When batch members share support artifacts, the batch writes one merged progression (for example, one new MeshInventory state for the whole batch) rather than one progression per target. Sequential single-target invocations remain available where per-target progression is intended. +- Explicit payload batches can now fail pre-write with an input snapshot diagnostic if a requested working payload changes during batch capture. Nothing is written in that case; re-invoking after re-serialization is the intended recovery. +- Re-running an already-applied exact payload batch no-ops already-current payload targets instead of erroring or minting duplicate identical states. A caller that wants a new state must change the payload or request a new explicit state segment. +- Generalized later-payload inventory rendering preserves established state/manifestation block ordering and trims excess blank lines; existing single-payload fixture outputs remain byte-identical except where that normalization applies on the generalized path. +- When `--generated-at` is supplied, footer-only page differences are written so existing pages converge to the requested timestamp; the timestamp-only skip optimization applies only when the flag is omitted. + +## Artifacts + +- Git tag: `v0.3.0` +- GitHub Release: `v0.3.0` +- Native binary archives for Linux x64, Windows x64, macOS x64, and macOS arm64 +- Matching `.sha256` checksum files +- npm packages for `@semantic-flow/weave` and supported platform packages + +## Validation + +- Source quality gate for release preparation: `deno task ci`, covering fmt check, lint, type check, coverage test run, and LCOV generation. The latest full local gate passed with 664 tests. +- Real-fixture replay evidence: the Stagecraft `test-inn-ambush` temporal-vocabulary rung (`a.11-temporal-vocabulary-records` to `a.12-temporal-vocabulary-woven`) was replayed with sequential single-target and batch invocations. 72 pre-existing `_history*/_s*` payload state files were verified byte-identical by `sha256sum -c`, the staged replay produced the same 21 path changes as the woven branch, and batch runs are byte-identical to equivalent sequential runs for the rung. +- Accord acceptance: `accord validate` reports the rung's focused scenario index conformant, and `accord check-scenario` passes with one step wrapping 33 passing checks. +- Batch coverage includes shared-inventory merging, whole-plan refusal with nothing written, capture-window mutation refusal, post-capture mutation tolerance, rerun no-op convergence, and byte-identical determinism with a pinned `--generated-at`. +- Release workflow rehearsal should use the `Release Manual` workflow with `npm_publish_mode: dry-run` and `github_release_mode: draft` before publishing npm packages or the GitHub Release. +- Native binary smoke path: each built executable should run `weave --version`, and npm smoke install should verify the wrapper package resolves the matching platform package. + +## Known Limitations + +- Multi-target batching covers explicit exact payload targets whose target-scoped planning policies are consistent. Recursive targets, mixed-slice target sets, and untargeted multi-candidate planning keep the existing sequential behavior. +- Input snapshot verification hashes the requested targets' working payload files only. Hashing every plan-read support/config input is deferred until candidate loading exposes a clean pre-capture read set. +- Several non-payload planner paths still carry fixture-shaped "only supports the settled ... shape" gates, including extracted-knop, page-definition, and reference-catalog assertions. The remaining first-payload blockers are tracked separately. +- The full Stagecraft scenario index is not yet green; Accord acceptance coverage for this release is per-rung. Unrelated later-rung conformance expectations remain follow-up work. +- Append-onlyish inventory behavior remains partial: batches use the append/no-op/conflict planner portion needed for merged rendering and reruns, but broad inventory-writer migration is still open. +- A shared batch timestamp is a human-readable signal only. There is no grouped-provenance or batch-citation vocabulary in the data; if the game+session workload proves that need, it becomes its own model task. + +## Next + +- Sweep the remaining fixture-shaped planner gates: list every "only supports the settled ... shape" assertion, classify each as a true invariant, a malformed-state diagnostic, or implementation debt, and replace the debt with fact-driven read models. +- Extend input snapshot verification to all plan-read inputs once the loader can enumerate its read set before capture. +- Integrate the new Accord v0.1.0 capabilities into Weave's fixture-ladder workflow: run `accord validate` over live Semantic Flow manifests in CI, convert the full-corpus rerun to a scenario index driven by `accord check-scenario`, use `accord draft-manifest` when regenerating ladder rungs, and pin the CI accord checkout to the `v0.1.0` tag. +- Dogfood the game+session history mesh as the next application workload, including service-driven batch weaves with `--generated-at`, and revisit grouped provenance only if that workload proves the need. +- Continue append-onlyish inventory migration toward append/no-op/fail-closed semantics for the remaining inventory writers. diff --git a/documentation/notes/wd.codebase-overview.md b/documentation/notes/wd.codebase-overview.md index 0b39f1d..939ea37 100644 --- a/documentation/notes/wd.codebase-overview.md +++ b/documentation/notes/wd.codebase-overview.md @@ -33,7 +33,8 @@ Core owns portable Semantic Flow behavior: request/result types, RDF/Turtle help - `src/core/mesh`, `src/core/knop`, `src/core/integrate`, `src/core/payload`, and `src/core/extract` own operation-specific pure planning. - `src/core/weave` owns shared weave/version/generate contracts and planner helpers. - `src/core/weave/weave.ts` is still the major pressure point. It remains the public façade for core weave imports, but it is being decomposed under [[wa.completed.2026.2026-05-21_0849_careful-extraction-refactor]]. -- Already extracted core weave helpers include `errors.ts`, `version_plan.ts`, `mesh_support_pages.ts`, `requests.ts`, `source_models.ts`, `candidates.ts`, `planning_models.ts`, `progression_models.ts`, `progression_resolvers.ts`, `slices.ts`, `rdf_helpers.ts`, `turtle_blocks.ts`, `artifact_history_queries.ts`, `artifact_manifestation_paths.ts`, `slice_classification.ts`, `payload_version_layout.ts`, `payload_overwrite.ts`, `payload_renderers.ts`, `mesh_inventory_renderers.ts`, `knop_inventory_renderers.ts`, `legacy_page_renderers.ts`, `extraction_source_blocks.ts`, `knop_support_renderers.ts`, `shape_assertions.ts`, `source_locator_assertions.ts`, `source_locator_renderers.ts`, `support_history_renderers.ts`, `working_file_paths.ts`, `reference_catalog_links.ts`, `resource_page_builders.ts`, `resource_page_models.ts`, `resource_page_template_contract.ts`, `resource_page_history_groups.ts`, `resource_page_policy.ts`, `resource_page_reference_links.ts`, `naming_policy.ts`, and `support_history_policy.ts`. +- Exact multi-target payload advancement is handled as one deterministic payload batch: canonical designator-path order, one merged support-artifact progression for shared support files, and no-op reruns for already-current payload targets. Recursive and mixed-slice target sets still use the runtime's sequential overlay planner. +- Already extracted core weave helpers include `errors.ts`, `version_plan.ts`, `mesh_support_pages.ts`, `requests.ts`, `source_models.ts`, `candidates.ts`, `planning_models.ts`, `progression_models.ts`, `progression_resolvers.ts`, `slices.ts`, `rdf_helpers.ts`, `turtle_blocks.ts`, `artifact_history_queries.ts`, `artifact_manifestation_paths.ts`, `slice_classification.ts`, `payload_version_layout.ts`, `payload_weave_read_model.ts`, `payload_overwrite.ts`, `payload_renderers.ts`, `mesh_inventory_renderers.ts`, `knop_inventory_renderers.ts`, `legacy_page_renderers.ts`, `extraction_source_blocks.ts`, `knop_support_renderers.ts`, `shape_assertions.ts`, `source_locator_assertions.ts`, `source_locator_renderers.ts`, `support_history_renderers.ts`, `working_file_paths.ts`, `reference_catalog_links.ts`, `resource_page_builders.ts`, `resource_page_models.ts`, `resource_page_template_contract.ts`, `resource_page_history_groups.ts`, `resource_page_policy.ts`, `resource_page_reference_links.ts`, `naming_policy.ts`, and `support_history_policy.ts`. ## Runtime Layer @@ -46,6 +47,7 @@ Runtime owns local execution against a workspace: filesystem reads/writes, git-a - `src/runtime/weave/weave.ts` is the public runtime façade for validate/version/generate/weave. - `src/runtime/weave/prepared_execution.ts`, `candidate_loader.ts`, `planning_context.ts`, `artifact_loaders.ts`, `version_execution.ts`, and `request_normalization.ts` hold non-page-generation runtime weave execution pieces. - `src/runtime/weave/page_generation.ts`, `page_model_assembly.ts`, `page_contexts.ts`, `raw_source_panels.ts`, and `pages.ts` hold ResourcePage generation, model assembly, context loading, raw-source panels, and HTML rendering. +- `version_execution.ts` verifies current working payload file hashes around explicit payload-batch capture, while `page_generation.ts` owns generated-page timestamp sampling and timestamp-only write-skip behavior. - `WEAVE_TIMING=1` reports runtime phase timings; keep timing phase names stable during move-only refactors unless a task explicitly changes them. ## CLI, Daemon, Web diff --git a/documentation/notes/wd.decision-log.md b/documentation/notes/wd.decision-log.md index db3cdd6..599d9b7 100644 --- a/documentation/notes/wd.decision-log.md +++ b/documentation/notes/wd.decision-log.md @@ -20,6 +20,36 @@ created: 1773630801215 ## Decisions +### 2026-07-06: Application Owns Payload Batch Atomicity + +- Decision: Multi-target payload advancement is a fail-closed deterministic Weave plan, not a transactional filesystem boundary; the requesting application owns coherent serialization, locking, rollback, and retry orchestration. +- References: [[wa.task.2026.2026-07-05-multi-target-payload-advancement]], [[wa.task.2026.2026-07-03_1332-stagecraft-weave-planner-generalization]], [[wd.todo]] +- Why: + - The application is the coherent writer for game/session state and can re-request the weave after a process or filesystem failure. + - Weave's useful guarantee is that a requested batch validates as a whole before writing, renders shared support artifacts once, and safely no-ops already-current payloads on rerun. + - Adding locks, journals, rollback, or grouped-provenance vocabulary would solve a different problem than this planner slice. + +### 2026-07-06: Payload Batch Snapshot Hash Scope + +- Decision: Explicit multi-target payload batch snapshot verification hashes the requested targets' current working payload files before capture and verifies them after capture; hashing every plan-read support/config input is deferred until candidate loading and effective-config discovery expose a clean pre-capture read set. +- References: [[wa.task.2026.2026-07-05-multi-target-payload-advancement]], [[wd.todo]] +- Why: + - The immediate torn-read risk is one requested payload file being captured before another requested payload file changes. + - Current inventories and support artifacts are discovered while candidate/config loading runs, so full plan-read hashing would become a loader-read-set refactor rather than the small pre-write guard in this slice. + - The guard still fails closed before Weave writes when a scoped input changes during batch capture. + +### 2026-07-06: Later Payload Weave Uses RDF Fact Progression + +- Decision: Generalize the later-payload weave path so single-target payload advancement derives current history, latest state, next state ordinal, and current-only support-artifact behavior from current RDF facts instead of requiring the settled second-payload fixture shape. +- References: [[wa.task.2026.2026-07-03_1332-stagecraft-weave-planner-generalization]], [[wd.todo]] +- Why: + - Application meshes can already have `_history001/_s0003` or later payload states while intentionally keeping Knop support artifacts current-only. + - `weave generate` must remain render-only; full `weave` is the operation that should advance coherent payload histories. + - Condition-specific diagnostics for missing or conflicting RDF facts are more useful than exposing the old fixture-shaped implementation boundary. +- Follow-Up Tasks: + - [x] Add multi-target later-payload advancement as the next planner slice. + - [x] Add scenario-runner-backed Accord acceptance coverage for the Stagecraft-shaped transition. + ### 2026-05-17: Keep KnopInventory Current-Only Default Explicit - Decision: Keep the explicit `sfcfg:artifactRole_knopInventory` `currentOnly` default in `defaults/application.ttl` even though it currently matches the global default. diff --git a/documentation/notes/wd.testing.fixture-ladder-regeneration.md b/documentation/notes/wd.testing.fixture-ladder-regeneration.md index 40fcbc8..1208793 100644 --- a/documentation/notes/wd.testing.fixture-ladder-regeneration.md +++ b/documentation/notes/wd.testing.fixture-ladder-regeneration.md @@ -28,6 +28,8 @@ The transition manifests and example specs live in the Semantic Flow Framework c - `dependencies/github.com/semantic-flow/semantic-flow-framework` +Keep conformance harness files off generated fixture rung refs unless a task has a concrete reason to make a self-contained fixture repository. In the current Weave fixture setup, update conformance manifests and `scenario-index.jsonld` in the Semantic Flow Framework checkout, not on the generated rung branches. If a future fixture repo does carry `conformance/`, cover those files explicitly or ignore them with `conformance/**`; do not let harness churn become the transition being tested by accident. + The ladder runner also uses Accord from: - `dependencies/github.com/spectacular-voyage/accord` @@ -55,6 +57,34 @@ Useful options: The script intentionally does not push fixture branches. Its successful output says which branch was updated and reminds you to push that branch separately. +## Weave And ResourcePages + +`weave generate` renders ResourcePages from an already-settled mesh state. It may legitimately create no files when the needed histories or page-bearing resources do not exist yet. + +The bare `weave` command runs the composed local flow: + +```text +validate +version +generate ResourcePages +``` + +When the expected checkpoint is a fully woven fixture rung with payload histories, support RDF, and `index.html` ResourcePages, run full `weave`, not only `weave generate`. A `*-woven` rung should normally include both the generated ResourcePages and the RDF links that claim them, such as `sflo:hasResourcePage`. + +## Accord Checks + +Accord is the transition checker. It does not execute Weave; use Weave or the fixture ladder runner to produce the target checkout first. + +Use Accord to answer: + +- did this transition add, update, or remove the expected paths? +- do representative RDF facts exist in the target ref? +- did generated support files land where the contract expects? + +For added RDF files, pair path expectations with semantic assertions for the facts that matter. Do not expect Accord to prove exact bytes for newly added files unless the manifest contract explicitly asks for byte-level comparison. + +Consumer fixture checks validate the checkout a downstream tool reads. They are useful for proving that the accepted fixture state is still parseable and meaningful to that tool, but they do not replace Accord checks for the ladder transitions that produced the state. + ## Rung Dependency Model A fixture ladder is an ordered chain of branch refs. Most transitions read the previous rung's `toRef` as their next `fromRef`, so regeneration is usually a whole-ladder operation: an early ontology, inventory, ResourcePage, source-registry, or publication behavior change needs to be carried through every later rung. @@ -310,14 +340,38 @@ weave: document fixture ladder regeneration - describe review and push steps for Alice, sidecar, and branch-published fixtures - record CI failure patterns for missing fixture refs and stale remote fixture branches ``` -## Finally, merge to main -After non-branch ladders have been regenerated, move main to those final states, so that main always reflects the latest rung, something like: +## Update Main + +After non-branch ladders have been regenerated, update `main` by merging the accepted woven rung. Do not re-apply rung content to `main` as fresh linear commits, and do not reset `main` directly to the rung unless the task is explicitly a history-repair operation. Merging makes the accepted rung an ancestor of `main`, so provenance remains visible in history and the "accepted state plus harness" invariant is checked at merge time instead of reconstructed by hand. + +Example: +```sh +git -C dependencies/github.com/semantic-flow/mesh-alice-bio switch main +git -C dependencies/github.com/semantic-flow/mesh-alice-bio merge a.25-root-page-customized-woven +``` + +Do the same for other non-branch fixture ladders whose `main` branch should mirror the accepted current state: + +```sh +git -C dependencies/github.com/semantic-flow/mesh-sidecar-fantasy-rules switch main +git -C dependencies/github.com/semantic-flow/mesh-sidecar-fantasy-rules merge a.17-all-remaining-terms-woven +``` + +Before pushing, verify that `main` differs from the accepted woven rung only under harness-only paths such as `conformance/`. In the current Weave fixture setup, conformance manifests live in the Semantic Flow Framework checkout rather than the fixture repos, so this diff should usually be empty: + +```sh git -C dependencies/github.com/semantic-flow/mesh-alice-bio switch main -git -C dependencies/github.com/semantic-flow/mesh-alice-bio reset --hard a.25-root-page-customized-woven -git -C dependencies/github.com/semantic-flow/mesh-alice-bio push --force-with-lease origin main +git -C dependencies/github.com/semantic-flow/mesh-alice-bio diff a.25-root-page-customized-woven main --name-only | grep -v '^conformance/' && echo "DRIFT: main diverged from the accepted rung" || echo "ok: only harness paths differ" git -C dependencies/github.com/semantic-flow/mesh-sidecar-fantasy-rules switch main -git -C dependencies/github.com/semantic-flow/mesh-sidecar-fantasy-rules reset --hard a.17-all-remaining-terms-woven -git -C dependencies/github.com/semantic-flow/mesh-sidecar-fantasy-rules push --force-with-lease origin main +git -C dependencies/github.com/semantic-flow/mesh-sidecar-fantasy-rules diff a.17-all-remaining-terms-woven main --name-only | grep -v '^conformance/' && echo "DRIFT: main diverged from the accepted rung" || echo "ok: only harness paths differ" +``` + +After the invariant check passes, push `main` normally: + +```sh +git -C dependencies/github.com/semantic-flow/mesh-alice-bio push origin main +git -C dependencies/github.com/semantic-flow/mesh-sidecar-fantasy-rules push origin main +``` diff --git a/documentation/notes/wd.testing.md b/documentation/notes/wd.testing.md index e2a96f8..0f360ba 100644 --- a/documentation/notes/wd.testing.md +++ b/documentation/notes/wd.testing.md @@ -88,6 +88,10 @@ Practical guidance: - treat Accord as the acceptance oracle for behavior-level Semantic Flow conformance - prefer manifest-backed comparisons over ad hoc snapshot assertions when a reusable transition is available - keep Accord checks black-box and implementation-agnostic +- remember that Accord checks transitions, not execution: run Weave or the fixture runner to produce the target checkout, then use Accord to ask whether expected paths, representative RDF facts, and generated support files landed +- for newly added RDF files, pair path expectations with semantic assertions for the facts that matter; do not rely on byte identity unless the manifest contract explicitly asserts bytes +- keep conformance harness files out of fixture rung refs by default; if a fixture repo must carry `conformance/`, either test those paths deliberately or ignore them with `conformance/**` +- use consumer fixture checks to validate a consumer's interpretation of the accepted checkout; use Accord to validate the ladder transitions that produced that checkout - use Accord alongside narrower unit and integration tests rather than instead of them - when a new externally visible Semantic Flow operation stabilizes, consider whether it should gain an `sf.spec.*` note and an Accord-backed acceptance path @@ -102,6 +106,8 @@ Preferred placement: Temp workspaces created with `createTestTmpDir()` are registered with the test harness and cleaned after each test when tests are run through `deno task test` or `deno task test:coverage`. Set `WEAVE_KEEP_TEST_TMP=1` when running those tasks to preserve temp workspaces for debugging a failing test. +The test harness also sets `DENO_DIR` to `/tmp/semantic-flow-deno-test-cache` while each test runs. This keeps child `deno run` CLI subprocesses from rebuilding their dependency cache inside the per-test `XDG_CACHE_HOME`. + Curated Git fixture refs should be materialized through the shared fixture snapshot cache when possible. The cache stores immutable per-repo/per-commit snapshots under `/tmp/semantic-flow-fixture-snapshots`, keyed by resolved commit SHA, then copies from that snapshot into each test's private temp workspace. This keeps tests isolated while avoiding repeated per-file `git show` extraction. Live Semantic Flow fixture ladder regeneration is a separate cross-repo workflow; see [[wd.testing.fixture-ladder-regeneration]]. diff --git a/documentation/notes/wd.todo.md b/documentation/notes/wd.todo.md index a3c648d..873f80d 100644 --- a/documentation/notes/wd.todo.md +++ b/documentation/notes/wd.todo.md @@ -9,13 +9,16 @@ created: 1774046031081 ## Backlog -Groomed on 2026-05-27 from all `wa.task.*` notes in the Weave archive. Updated after per-target effective config and `ResourcePageSource` exact/fallback resolution landed. +Groomed on 2026-05-27 from all `wa.task.*` notes in the Weave archive. Updated after per-target effective config and `ResourcePageSource` exact/fallback resolution landed. Lightly refreshed on 2026-06-30 after the paper sprint was parked and Stagecraft became an active downstream consumer for persisted roleplaying data. ### Current Work And Next Pick - [x] Finish in-progress [[wa.completed.2026.2026-05-27_2215-resourcepage-source-resolution-semantics]]: exact `targetHistoricalState`, one-level fallback, no fallback for malformed/unsafe/unsupported specs, and focused resolver/page-definition tests. - [x] Finish the current-only `ResourcePageDefinition` slice from [[wa.task.2026.2026-05-05-optional-history-and-slim-support-artifacts-by-default]]: `_knop/_page` can remain unversioned, custom page generation reads `_knop/_page/page.ttl` when `_history001` is absent, and omitted page-definition histories are not linked. -- [ ] Take [[wa.task.2026.2026-05-17-append-onlyish-inventory]] as the next larger inventory-correctness task now that the current history-policy slice is bounded. +- [d] Park the June 2026 paper sprint as concept scratch, not current delivery work. The FOMI/FOIS drafts remain useful for terminology and future documentation, with KGSWC 2026 now tracked as the next possible submission target in [[wa.task.2026.2026-06-30_1159-kgswc-2026-paper-target]], but engineering should not chase paper-specific implementation. +- [ ] Use [[wa.task.2026.2026-06-30_1108-stagecraft-driven-semantic-flow-requirements]] to collect concrete Stagecraft persistence requirements before reshaping Weave or SFLO vocabulary around imagined roleplaying-data needs. +- [ ] Use [[wa.task.2026.2026-07-03_1332-stagecraft-weave-planner-generalization]] as the concrete Stagecraft-driven Weave blocker epic: reproduce the settled second-payload weave failure, generalize the planner from RDF facts, and replace fixture-shaped diagnostics. +- [ ] Take [[wa.task.2026.2026-05-17-append-onlyish-inventory]] as the next larger inventory-correctness task now that the current history-policy slice is bounded, unless a sharply scoped Stagecraft blocker proves more urgent. ### P0: Current Config And Resolution Follow-Ups @@ -27,6 +30,9 @@ Groomed on 2026-05-27 from all `wa.task.*` notes in the Weave archive. Updated a ### P1: Publication, History, And Runtime Correctness +- [ ] Keep Stagecraft's persisted roleplaying-data use case in view when prioritizing runtime work: prefer slices that improve stable identifiers, exact state citation, append-onlyish histories, source provenance, and generated inspection pages for ordinary application data. +- [x] Land the single-target Stagecraft-triggered later-payload planner slice from [[wa.task.2026.2026-07-03_1332-stagecraft-weave-planner-generalization]]: full `weave` can advance a coherent later payload history instead of rejecting non-fixture-shaped states with "settled second payload weave shape" errors. +- [x] Follow up on [[wa.task.2026.2026-07-03_1332-stagecraft-weave-planner-generalization]] with multi-target later-payload advancement. - [ ] Remove the remaining first-payload planner blockers from [[wa.task.2026.2026-05-04-refactor-planFirstPayloadWeave]]: support multi-pending first-payload weave in one transaction, support current-mode extracted-term weave, and replace fixture-shaped errors with condition-specific diagnostics. The SFLO and URPX docs still warn about the current failure mode, so this is real backlog, not just cleanup. - [ ] Implement append-onlyish inventory writes from [[wa.task.2026.2026-05-17-append-onlyish-inventory]]: normal inventory operations append new settled facts, no-op existing facts, and fail closed on conflicts; current/latest/next progression belongs in metadata or explicit repair/regeneration/retraction modes. - [d] Finish broader slim/current-only support-artifact behavior from [[wa.task.2026.2026-05-05-optional-history-and-slim-support-artifacts-by-default]]: the current-only `ResourcePageDefinition` slice has landed; remaining inventory/meta split and durable history-toggle coherence stay with [[wa.task.2026.2026-05-17-append-onlyish-inventory]] and follow-up history-policy work. @@ -38,6 +44,7 @@ Groomed on 2026-05-27 from all `wa.task.*` notes in the Weave archive. Updated a ### P2: Fixtures, Renderer, And Developer Experience +- [ ] Integrate Accord v0.1.0 capabilities into the fixture-ladder workflow: pin the CI accord checkout to the `v0.1.0` tag, run `accord validate` over the live Semantic Flow manifests as a fast CI authoring gate before slow ladder checks, convert the mesh-alice-bio smoke / full-corpus rerun to a scenario index driven by `accord check-scenario` for per-rung evidence, use `accord draft-manifest` as the starting point when ladder rungs change intentionally, and consider JSON assertions for generated-page contract checks. The accord-side features are complete (see `ac.completed.2026.2026-07-04-*` notes in the accord repo); this item is Weave-side adoption only. - [ ] Add the manifest completeness check from [[wa.task.2026.2026-05-16_1625-manifest-completeness-check]] so fixture branch diffs cannot drift beyond Accord manifest expectations or deliberate `ignorePaths`. - [ ] Add the latest-state conformance fixture from [[wa.task.2026.2026-05-19_1536-latest-state-conformance]] once Accord can express the needed rendered-text assertions. - [ ] Revisit custom ResourcePage follow-up work in [[wa.task.2026.2026-05-23-2157-resourcepage-followup]] after Knop config inheritance settles: the next useful slice is probably a narrow custom page fixture that keeps the shared Semantic Site presentation while opting into authored Markdown plus selected generated panels. @@ -76,6 +83,7 @@ Groomed on 2026-05-27 from all `wa.task.*` notes in the Weave archive. Updated a ### Active +- [[wa.task.2026.2026-07-03_1332-stagecraft-weave-planner-generalization]]: active Stagecraft-triggered epic for replacing settled second-payload and adjacent fixture-shaped weave planner gates with fact-driven progression and condition-specific diagnostics. - [[wa.task.2026.2026-05-04-refactor-planFirstPayloadWeave]]: active; docs still warn about the "settled first-payload-weave mesh inventory shape" failure, so the multi-pending first-payload and current-mode extracted-term fixes remain real. - [[wa.task.2026.2026-05-17-append-onlyish-inventory]]: active; important correctness cleanup for reruns and release workflows. - [[wa.task.2026.2026-05-05-optional-history-and-slim-support-artifacts-by-default]]: partially superseded by config synthesis, but the current-only `ResourcePageDefinition` and history-policy runtime behavior remain active. diff --git a/documentation/notes/wd.vision.md b/documentation/notes/wd.vision.md index ed1117e..46003c2 100644 --- a/documentation/notes/wd.vision.md +++ b/documentation/notes/wd.vision.md @@ -2,12 +2,16 @@ id: aqnk3ferc6mewpj9qpd5ola title: Weave Vision desc: '' -updated: 1779954261344 +updated: 1779954453544 created: 1779954054498 --- -Weave will be an versatile implementation of the Semantic Flow Framework with a RDF-native core and three prongs: +Weave will be a versatile, filesystem-oriented implementation of the Semantic Flow Framework, built around an RDF-native core, a host-aware runtime, and three coordinated surfaces: -- an agile CLI for local use -- an API -- a web application that consumes the API \ No newline at end of file +- an agile CLI for local authoring, publishing, repair, and automation +- a stable API for applications, agents, and integrations +- a web application that consumes the API for browsing, editing, and managing meshes + +Its job is to make semantic meshes practical: easy to create locally, safe to version, simple to publish through static hosting, and pleasant to inspect through generated pages or interactive tools. + +Near-term product pressure may come from real downstream applications as much as from ontology-publication fixtures. Stagecraft is now an active consumer candidate: Semantic Flow is expected to provide persisted roleplaying data for ordinary application workflows, not only public RDF/ontology publication. That should bias Weave toward boring but durable substrate first: stable identifier minting, current-vs-historical state policy, append-onlyish support facts, source/provenance records, local inspection pages, and safe filesystem/runtime behavior. diff --git a/documentation/notes/wu.cli-reference.generate.md b/documentation/notes/wu.cli-reference.generate.md index 63a5286..64732e6 100644 --- a/documentation/notes/wu.cli-reference.generate.md +++ b/documentation/notes/wu.cli-reference.generate.md @@ -15,7 +15,7 @@ See [[wu.resource-pages]] for what ResourcePages are for and how default and cus ## Usage ```sh -weave generate [--mesh-root ] [--target ]... +weave generate [--mesh-root ] [--target ]... [--generated-at ] weave generate [--include-semantic-flow-metadata] [--history-tracking-policy ] ``` @@ -27,6 +27,7 @@ Targets use [[wu.cli-reference.target-syntax]]. Use `/` for the root designator weave generate weave generate --target 'designatorPath=alice/data' weave generate --target 'designatorPath=alice,recursive=true' +weave generate --generated-at 2026-07-06T12:34:56Z weave generate --include-semantic-flow-metadata ``` @@ -40,9 +41,10 @@ Mesh-local `_mesh/_config/config.ttl` can select durable ResourcePage generation `--history-tracking-policy` is an advanced command-scoped override used while resolving page-generation inputs. It applies only to the current command. +`--generated-at` pins the single timestamp used in every generated ResourcePage footer for this invocation. The value must be an ISO 8601 instant with an explicit UTC offset, and Weave writes it canonically as UTC `toISOString()` output. Supplying the flag also writes footer-only differences so existing pages converge to the requested timestamp; omitting it keeps the normal sample-once-now default and timestamp-only skip behavior. + ## Environment - [[wu.environment-variables#weave_log_dir]] controls where command audit logs are written. - [[wu.environment-variables#weave_timing]] enables aggregate timing lines. -- [[wu.environment-variables#weave_generated_at]] sets the rendered generation timestamp. - [[wu.environment-variables#home-and-userprofile]] can affect workspace/local-access policy resolution. diff --git a/documentation/notes/wu.cli-reference.integrate.md b/documentation/notes/wu.cli-reference.integrate.md index 4b3b4fd..f509a2d 100644 --- a/documentation/notes/wu.cli-reference.integrate.md +++ b/documentation/notes/wu.cli-reference.integrate.md @@ -59,6 +59,10 @@ This records `sflo:hasRepositorySourceFloatingLocator` with `sflo:sourceReposito If `--source-repository-url` is omitted, Weave resolves the URL from the source checkout's git remote. Use `--source-repository-remote ` with `--source-repository-current` to choose a remote other than `origin`. +`--source-repository-url` describes the repository that owns the source file being integrated, not the repository that contains the mesh root. In a branch-published or application-managed data mesh, those may be different checkouts. For example, if the source file lives in an application repository and the mesh root lives in a separate publication/data repository, the source repository URL should identify the application repository. + +The URL is recorded as provenance text; Weave does not require a `.git` suffix. Use the canonical clone-style URL you want future readers and automation to recognize. Existing examples use GitHub HTTPS URLs with `.git` because they are unambiguous clone URLs, but `https://github.com/org/repo` and SSH-style remotes are also accepted as strings. + ## Pinned Repository Provenance Use repository metadata without `--source-repository-current` when the source observation should be tied to a specific ref/path and optional commit evidence. diff --git a/documentation/notes/wu.cli-reference.md b/documentation/notes/wu.cli-reference.md index 31bbc51..b78731e 100644 --- a/documentation/notes/wu.cli-reference.md +++ b/documentation/notes/wu.cli-reference.md @@ -273,6 +273,7 @@ Integrates a local source file into a designator path as a payload artifact, inc weave integrate ./alice-data.ttl alice/data weave integrate ./alice-data.ttl --designator-path alice/data weave integrate ./ontology/fantasy-rules-ontology.ttl ontology --mesh-root docs --grant-source-directory ontology +weave integrate "$SOURCE_REPO/ontology/example.ttl" ontology --mesh-root "$MESH_ROOT" --grant-source-directory "$SOURCE_REPO" --source-repository-current --source-repository-url https://github.com/example/source.git weave integrate ./ontology/fantasy-rules-ontology.ttl ontology --mesh-root docs --grant-source-directory ontology --source-repository-url https://github.com/example/source.git --source-repository-ref main --source-repository-path ontology/fantasy-rules-ontology.ttl weave integrate ./root.ttl --designator-path / ``` @@ -286,6 +287,9 @@ Constraints: - `--grant-source-directory ` adds an operational `workingLocalRelativePath` grant for that source directory before resolving the source; workspace-contained sources may be recorded in mesh config, while separate checkout sources are recorded in the current user's mesh-scoped settings access profile - when the approved source path is outside the mesh root, `integrate` creates a Knop source registry automatically with the internal `payload-source` `IntegrationSource` binding id, `targetLocalRelativePath`, and `artifactResolutionMode_working` - automatically created floating working-source bindings do not record repository ref, commit, path, digest evidence, or `expectsContentDigest` +- `--source-repository-current` records a floating repository source locator for the source file's current checkout; use this for sidecar, branch-published, or application-managed meshes where later operations should follow the current source checkout rather than a host-local absolute path +- with `--source-repository-current`, `--source-repository-url` identifies the repository that owns the source file, not necessarily the repository containing the mesh root; if omitted, Weave reads the URL from the source checkout's git remote, defaulting to `origin` +- `--source-repository-remote ` chooses a non-`origin` remote for `--source-repository-current` when `--source-repository-url` is omitted - `--source-repository-url`, `--source-repository-ref`, and `--source-repository-path` record repository-backed source provenance in the Knop source registry without fetching or copying source bytes - `--source-repository-commit` records immutable commit evidence when known - `--source-digest ` records caller-provided byte evidence; if omitted when repository source metadata is supplied, Weave records a computed `sha256:` digest for the local source bytes it observed and links that evidence from the `IntegrationSource` with an `ArtifactResolutionObservation` diff --git a/documentation/notes/wu.cli-reference.target-syntax.md b/documentation/notes/wu.cli-reference.target-syntax.md index 65cacc4..9cce4fb 100644 --- a/documentation/notes/wu.cli-reference.target-syntax.md +++ b/documentation/notes/wu.cli-reference.target-syntax.md @@ -24,6 +24,7 @@ The aliases `payloadHistorySegment`, `payloadStateSegment`, and `payloadManifest ```sh weave --target 'designatorPath=alice/data' +weave --target 'designatorPath=game/state' --target 'designatorPath=game/session' weave generate --target 'designatorPath=alice,recursive=true' weave validate --target 'designatorPath=alice/data' --target 'designatorPath=bob' weave version --target 'designatorPath=ontology,historySegment=releases,stateSegment=v0.1.0,manifestationSegment=ttl' @@ -35,6 +36,8 @@ weave version --target 'designatorPath=ontology,historySegment=releases,stateSeg Version-oriented fields are rejected by [[wu.cli-reference.validate]] and [[wu.cli-reference.generate]] because those commands do not create historical states. For recursive version targets, version-oriented fields act as defaults for matched payload artifacts; a more specific target can override them. +When [[wu.cli-reference.weave]] or [[wu.cli-reference.version]] receives multiple exact payload targets, Weave plans those payload advancements together in canonical designator-path order. Recursive targets still expand deterministically through the existing recursive planner. + ## Environment Target syntax itself does not read environment variables. The command using the target determines the relevant variables; see [[wu.environment-variables]]. diff --git a/documentation/notes/wu.cli-reference.version.md b/documentation/notes/wu.cli-reference.version.md index 81b78da..146a005 100644 --- a/documentation/notes/wu.cli-reference.version.md +++ b/documentation/notes/wu.cli-reference.version.md @@ -25,6 +25,7 @@ Targets use [[wu.cli-reference.target-syntax]]. Use `/` for the root designator ```sh weave version weave version --target 'designatorPath=alice/data' +weave version --target 'designatorPath=game/state' --target 'designatorPath=game/session' weave version \ --target 'designatorPath=alice/data' \ --payload-history-segment releases \ @@ -39,6 +40,12 @@ weave version \ Payload version naming can be provided as general `--payload-*` defaults or as fields on individual version targets. Per-target fields override the general defaults. +Repeated exact payload targets are planned together in canonical designator-path order. A coherent batch writes one merged support-artifact progression for shared support files; if any requested payload target is malformed, the entire version plan is refused before files are written. Re-running an already-applied payload batch no-ops already-current payloads. + +For explicit multi-target payload batches, Weave hashes the requested targets' current working payload files before batch content capture and verifies those hashes after capture. A changed working payload refuses the whole batch before Weave writes version output, with a diagnostic naming the changed file. Changes after capture are ignored by design. + +For service-owned invocation and retry guidance, see [[wu.cli-reference.weave#service-integration]]. The same single-writer, fail-closed validation, snapshot-verification, and retry-safety rules apply to `weave version`; only page generation and `--generated-at` are specific to full `weave` or [[wu.cli-reference.generate]]. + `weave version` reads `_mesh/_config/config.ttl` when present. Mesh-local config can set durable history tracking and naming defaults. `--history-tracking-policy` remains a command-scoped override for the current run. `weave set history` and `weave set next-state` can persist payload-only intent for a later version operation; see [[wu.cli-reference.set.history]] and [[wu.cli-reference.set.next-state]]. diff --git a/documentation/notes/wu.cli-reference.weave.md b/documentation/notes/wu.cli-reference.weave.md index 73e230e..db071ba 100644 --- a/documentation/notes/wu.cli-reference.weave.md +++ b/documentation/notes/wu.cli-reference.weave.md @@ -13,7 +13,7 @@ created: 1779377000000 ## Usage ```sh -weave [--mesh-root ] [--target ]... +weave [--mesh-root ] [--target ]... [--generated-at ] weave [--payload-history-segment ] [--payload-state-segment ] [--payload-manifestation-segment ] weave [--history-tracking-policy ] [--silent] [--validate-before] [--validate-after] ``` @@ -27,6 +27,8 @@ weave weave --mesh-root docs weave --target 'designatorPath=alice/data' weave --target 'designatorPath=alice,recursive=true' +weave --target 'designatorPath=game/state' --target 'designatorPath=game/session' +weave --generated-at 2026-07-06T12:34:56Z weave --validate-before --validate-after ``` @@ -58,11 +60,26 @@ Existing-mesh commands load `_mesh/_config/config.ttl` when it exists. Mesh-loca `--history-tracking-policy` is an advanced command-scoped override for artifact history behavior. Supported values are `versioned`, `currentOnly`, `required`, `slimHistory`, `checkpointOnly`, and `metadataOnly`. It applies only to the current command and wins over mesh-local config for that run. +`--generated-at` pins the single timestamp used in generated ResourcePage footers for this invocation. The value must be an ISO 8601 instant with an explicit UTC offset, such as `2026-07-06T12:34:56Z` or `2026-07-06T08:34:56-04:00`; Weave canonicalizes it to UTC `toISOString()` form in output. When this flag is supplied, footer-only page differences are written so existing pages converge to the requested timestamp. When omitted, Weave samples the current time once and keeps the normal timestamp-only skip behavior. + Payload naming applies only to payload artifacts selected for versioning. Support artifacts keep system-controlled history paths. If a payload's current history uses a named state such as `v0.0.1`, a later version request must provide the next `stateSegment` or explicitly request an ordinal fallback segment. +Repeated exact `--target` flags can advance multiple payload artifacts in one invocation. Explicit payload batches are planned in canonical designator-path order, only include the requested targets, and merge shared support-artifact updates into one deterministic plan. Re-running the same completed payload batch no-ops already-current payloads rather than creating duplicate historical states. + +For explicit multi-target payload batches, Weave hashes the requested targets' current working payload files before batch content capture and verifies those hashes after capture. If a working payload changes during capture, the whole batch is refused before Weave writes anything, and the diagnostic names the changed file. Changes after capture are ignored by design; the batch is derived from the captured content. + +## Service Integration + +For service-owned state, treat the service as the single writer. Serialize the coherent game/session/application state to the working payload files, then invoke `weave` with explicit payload targets. Do not modify those working payload files while `weave` is running. Weave provides fail-closed validation and deterministic output; it does not provide locking, rollback, journaling, or filesystem transactions. + +For explicit payload batches, snapshot verification and whole-plan validation failures exit with code `1` and write a stderr diagnostic naming the changed file, missing fact, conflicting fact, or invalid target. These failures happen before Weave writes version output. The correct service response is to re-serialize a coherent state snapshot and invoke `weave` again. + +Retries are safe by design. If a previous invocation completed or partially completed, re-running the same explicit payload batch no-ops targets that are already current and advances only the remaining requested targets. + +Services that want page footers to show event time rather than weave-execution time should pass `--generated-at `. Weave canonicalizes the instant to UTC `toISOString()` form and uses it as the single generated-page timestamp for the invocation. + ## Environment - [[wu.environment-variables#weave_log_dir]] controls where runtime logs are written. - [[wu.environment-variables#weave_timing]] enables timing lines for the composed flow and its phases. -- [[wu.environment-variables#weave_generated_at]] sets the timestamp used by the generate phase when rendering pages. - [[wu.environment-variables#home-and-userprofile]] can affect workspace/local-access policy resolution and publication validation. diff --git a/documentation/notes/wu.repository-options.md b/documentation/notes/wu.repository-options.md index 9dac857..39deabf 100644 --- a/documentation/notes/wu.repository-options.md +++ b/documentation/notes/wu.repository-options.md @@ -44,3 +44,13 @@ Branch-published meshes should record durable source provenance, such as reposit Choose this option when generated mesh state would be too noisy for the source branch, when review of generated publication output can happen on the publication branch, and when the project can tolerate a slightly more explicit deploy workflow. The branch boundary should not create a different Semantic Flow model. The same underlying operations should apply across whole-repo, sidecar, and branch-published meshes: create a mesh root, integrate or materialize source bytes according to an explicit locator/materialization policy, weave/version/generate the mesh, apply any selected publication-host preset, validate the publication output, and optionally commit changes when the mesh root is a git worktree. A branch-published mesh mainly changes the default source/output boundary and publication safety checks; it should not require a separate semantic command family unless branch-specific behavior is genuinely needed. + +## Application-managed local semantic mesh + +Use this when an application owns the mesh as its local persisted data store, and public static publication is optional or deferred. + +This is a good fit for local-first applications that need stable identifiers, inspectable history, provenance, generated pages, or RDF-native export without making every working file part of a public website. The application can keep the mesh under an app data directory, workspace folder, or project-specific data root, while using Semantic Flow identifiers and support artifacts to make saved state reviewable. + +An application-managed mesh may later be projected into one of the publication topologies above. For example, selected artifacts or snapshots can be exported to a sidecar or branch-published mesh when the user wants to publish them. Until then, the mesh should be treated as application data: local privacy and access policy matter more than static-host convenience. + +Choose this option when the primary workflow is persistence, inspection, and revision inside an application rather than publication from a source repository. Do not assume that application-managed data is safe to publish just because it has ResourcePages or stable identifiers. diff --git a/src/cli/generated_at.ts b/src/cli/generated_at.ts new file mode 100644 index 0000000..a89f9e4 --- /dev/null +++ b/src/cli/generated_at.ts @@ -0,0 +1,16 @@ +const ISO_8601_INSTANT_PATTERN = + /^\d{4}-\d{2}-\d{2}T(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d{1,3})?(?:Z|[+-](?:[01]\d|2[0-3]):[0-5]\d)$/; + +export function parseIso8601Instant(value: string): Date | undefined { + if (!ISO_8601_INSTANT_PATTERN.test(value)) { + return undefined; + } + + try { + const instant = Temporal.Instant.from(value); + const date = new Date(instant.epochMilliseconds); + return Number.isNaN(date.getTime()) ? undefined : date; + } catch { + return undefined; + } +} diff --git a/src/cli/generated_at_test.ts b/src/cli/generated_at_test.ts new file mode 100644 index 0000000..8b509a7 --- /dev/null +++ b/src/cli/generated_at_test.ts @@ -0,0 +1,70 @@ +import { assertEquals } from "@std/assert"; +import { parseIso8601Instant } from "./generated_at.ts"; + +Deno.test("parseIso8601Instant validates leap-year calendar dates", () => { + assertEquals( + parseIso8601Instant("2024-02-29T00:00:00Z")?.toISOString(), + "2024-02-29T00:00:00.000Z", + ); + assertEquals(parseIso8601Instant("2026-02-29T00:00:00Z"), undefined); + assertEquals( + parseIso8601Instant("2000-02-29T00:00:00Z")?.toISOString(), + "2000-02-29T00:00:00.000Z", + ); + assertEquals(parseIso8601Instant("1900-02-29T00:00:00Z"), undefined); +}); + +Deno.test("parseIso8601Instant applies positive and negative offsets", () => { + assertEquals( + parseIso8601Instant("2026-07-06T15:04:05+02:30")?.toISOString(), + "2026-07-06T12:34:05.000Z", + ); + assertEquals( + parseIso8601Instant("2026-07-06T08:34:56-04:00")?.toISOString(), + "2026-07-06T12:34:56.000Z", + ); + assertEquals( + parseIso8601Instant("2026-07-06T23:59:59+23:59")?.toISOString(), + "2026-07-06T00:00:59.000Z", + ); + assertEquals( + parseIso8601Instant("2026-07-06T00:00:00-23:59")?.toISOString(), + "2026-07-06T23:59:00.000Z", + ); +}); + +Deno.test("parseIso8601Instant enforces boundary hour minute and second values", () => { + assertEquals( + parseIso8601Instant("2026-07-06T00:00:00Z")?.toISOString(), + "2026-07-06T00:00:00.000Z", + ); + assertEquals( + parseIso8601Instant("2026-07-06T23:59:59Z")?.toISOString(), + "2026-07-06T23:59:59.000Z", + ); + assertEquals(parseIso8601Instant("2026-07-06T24:00:00Z"), undefined); + assertEquals(parseIso8601Instant("2026-07-06T23:60:00Z"), undefined); + assertEquals(parseIso8601Instant("2026-07-06T23:59:60Z"), undefined); + assertEquals(parseIso8601Instant("2026-07-06T23:59:59+24:00"), undefined); + assertEquals(parseIso8601Instant("2026-07-06T23:59:59+23:60"), undefined); +}); + +Deno.test("parseIso8601Instant parses millisecond fractions deterministically", () => { + assertEquals( + parseIso8601Instant("2026-07-06T12:34:56.1Z")?.toISOString(), + "2026-07-06T12:34:56.100Z", + ); + assertEquals( + parseIso8601Instant("2026-07-06T12:34:56.12Z")?.toISOString(), + "2026-07-06T12:34:56.120Z", + ); + assertEquals( + parseIso8601Instant("2026-07-06T12:34:56.123Z")?.toISOString(), + "2026-07-06T12:34:56.123Z", + ); + assertEquals(parseIso8601Instant("2026-07-06T12:34:56.1234Z"), undefined); +}); + +Deno.test("parseIso8601Instant requires an explicit UTC offset", () => { + assertEquals(parseIso8601Instant("2026-07-06T12:34:56"), undefined); +}); diff --git a/src/cli/run.ts b/src/cli/run.ts index 16b405e..9416a51 100644 --- a/src/cli/run.ts +++ b/src/cli/run.ts @@ -87,6 +87,7 @@ import { import { resolveUserSettingsPaths } from "../runtime/settings/user_settings.ts"; import type { HistoryTrackingPolicy } from "../runtime/config/effective_config.ts"; import { WEAVE_VERSION } from "../version.ts"; +import { parseIso8601Instant } from "./generated_at.ts"; const TARGET_OPTION_DESCRIPTION = "Target spec as comma-separated key=value fields. Supported keys: designatorPath, recursive. Versioning commands also accept historySegment, stateSegment, and manifestationSegment."; @@ -143,6 +144,10 @@ export async function runWeaveCli(args: string[]): Promise { "--history-tracking-policy ", "Override the history tracking policy for all artifact roles during this command.", ) + .option( + "--generated-at ", + "Use this ISO 8601 instant as the single generated-page timestamp.", + ) .option( "--silent", "Suppress progress updates for long-running weave operations.", @@ -164,11 +169,16 @@ export async function runWeaveCli(args: string[]): Promise { payloadManifestationSegment?: string; overwriteExistingState?: boolean; historyTrackingPolicy?: string; + generatedAt?: string; silent?: boolean; validateBefore?: boolean; validateAfter?: boolean; }, ) => { + const generatedAt = resolveGeneratedAtOption( + options.generatedAt, + "weave", + ); const meshRoot = resolve(options.meshRoot); const { workspaceRoot, logDir } = await resolveCliMeshContext(meshRoot); const targets = resolveVersionTargetSpecs(options, "weave"); @@ -185,6 +195,7 @@ export async function runWeaveCli(args: string[]): Promise { targets, overwriteExistingState: options.overwriteExistingState === true, historyTrackingPolicyOverride, + generatedAt: generatedAt?.iso, validateBefore: options.validateBefore === true, validateAfter: options.validateAfter === true, localMode: true, @@ -202,10 +213,12 @@ export async function runWeaveCli(args: string[]): Promise { : undefined, operationalLogger, auditLogger, + now: generatedAt?.now, historyTrackingPolicyOverride, onProgress: options.silent ? undefined : printWeaveProgress, validateBefore: options.validateBefore === true, validateAfter: options.validateAfter === true, + updateTimestampOnlyPages: generatedAt !== undefined, }); console.log(describeWeaveResult(result)); printTimestampOnlyGenerateSkipInfo(result.skippedTimestampOnlyPaths); @@ -385,14 +398,23 @@ export async function runWeaveCli(args: string[]): Promise { "--history-tracking-policy ", "Override the history tracking policy for all artifact roles during this command.", ) + .option( + "--generated-at ", + "Use this ISO 8601 instant as the single generated-page timestamp.", + ) .action(async ( options: { meshRoot: string; target?: string[]; includeSemanticFlowMetadata?: boolean; historyTrackingPolicy?: string; + generatedAt?: string; }, ) => { + const generatedAt = resolveGeneratedAtOption( + options.generatedAt, + "generate", + ); const meshRoot = resolve(options.meshRoot); const { workspaceRoot, logDir } = await resolveCliMeshContext( meshRoot, @@ -411,6 +433,7 @@ export async function runWeaveCli(args: string[]): Promise { includeSemanticFlowMetadata: options.includeSemanticFlowMetadata === true, historyTrackingPolicyOverride, + generatedAt: generatedAt?.iso, localMode: true, }); @@ -418,9 +441,11 @@ export async function runWeaveCli(args: string[]): Promise { meshRoot, request: targets.length > 0 ? { targets } : undefined, operationalLogger, + now: generatedAt?.now, includeSemanticFlowMetadata: options.includeSemanticFlowMetadata === true, historyTrackingPolicyOverride, + updateTimestampOnlyPages: generatedAt !== undefined, }); console.log(describeGenerateResult(result)); printTimestampOnlyGenerateSkipInfo(result.skippedTimestampOnlyPaths); @@ -1914,6 +1939,31 @@ function printTimestampOnlyGenerateSkipInfo(paths: readonly string[]): void { ); } +function resolveGeneratedAtOption( + value: string | undefined, + commandName: string, +): { iso: string; now: () => Date } | undefined { + if (value === undefined) { + return undefined; + } + const instant = resolveRequiredOptionValue( + value, + `${commandName} --generated-at requires an ISO 8601 instant`, + (message) => new WeaveInputError(message), + ); + const parsed = parseIso8601Instant(instant); + if (parsed === undefined) { + throw new WeaveInputError( + `${commandName} --generated-at must be an ISO 8601 instant with an explicit UTC offset, such as 2026-07-06T12:34:56Z or 2026-07-06T08:34:56-04:00`, + ); + } + const iso = parsed.toISOString(); + return { + iso, + now: () => new Date(iso), + }; +} + function getCliErrorMessage(error: unknown): string { if ( error instanceof ExtractInputError || diff --git a/src/core/import/import.ts b/src/core/import/import.ts index fb29784..7380f5a 100644 --- a/src/core/import/import.ts +++ b/src/core/import/import.ts @@ -13,6 +13,8 @@ import { } from "../rdf/namespaces.ts"; import { escapeTurtleString } from "../rdf/turtle.ts"; import { normalizeXsdDateTimeLiteral } from "../rdf/xsd_literals.ts"; +import { WeaveInputError } from "../weave/errors.ts"; +import { planInventoryAppend } from "../weave/inventory_append_planner.ts"; const RDF_TYPE_IRI = "http://www.w3.org/1999/02/22-rdf-syntax-ns#type"; const XSD_ANY_URI_IRI = "http://www.w3.org/2001/XMLSchema#anyURI"; @@ -37,6 +39,10 @@ const SFLO_RDF_DOCUMENT_IRI = `${SFLO_NAMESPACE}RdfDocument`; const SFLO_SEMANTIC_MESH_IRI = `${SFLO_NAMESPACE}SemanticMesh`; const SFLO_WORKING_LOCAL_RELATIVE_PATH_IRI = `${SFLO_NAMESPACE}workingLocalRelativePath`; +const KNOP_SOURCE_REGISTRY_APPEND_SINGLE_VALUED_PREDICATES = [ + SFLO_HAS_KNOP_SOURCE_REGISTRY_IRI, + SFLO_HAS_WORKING_LOCATED_FILE_IRI, +]; export interface ImportSourceBinding { bindingId?: string; @@ -669,28 +675,44 @@ function renderKnopInventoryWithSourceRegistry( designatorPath: string, sourceRegistryPath: string, ): string { - const current = currentKnopInventoryTurtle.trimEnd(); - const quads = parseQuads( + const sourceRegistryFactsTurtle = renderKnopSourceRegistryInventoryFacts( + designatorPath, + sourceRegistryPath, + ); + const requestedFactsTurtle = `@base <${meshBase}> . +${SFLO_TURTLE_PREFIX_DECLARATION} + +${sourceRegistryFactsTurtle}`; + const plan = planImportInventoryAppend({ meshBase, currentKnopInventoryTurtle, - `current Knop inventory has an unsupported carried shape for import: ${designatorPath}`, - ); - const knopIri = new URL(toKnopPath(designatorPath), meshBase).href; - const sourceRegistryIri = new URL(sourceRegistryPath, meshBase).href; - if ( - hasNamedNodeFact( - quads, - knopIri, - SFLO_HAS_KNOP_SOURCE_REGISTRY_IRI, - sourceRegistryIri, - ) - ) { - return currentKnopInventoryTurtle; + requestedFactsTurtle, + designatorPath, + }); + + if (plan.kind === "conflict") { + throw new ImportInputError( + `Could not append Knop source registry facts for ${designatorPath}: ${ + plan.conflicts.map((conflict) => conflict.message).join(" ") + }`, + ); + } + + if (plan.kind === "append" && plan.alreadyPresent.length === 0) { + return appendTurtleBlock( + currentKnopInventoryTurtle, + sourceRegistryFactsTurtle, + ); } - return `${current} + return plan.outputTurtle; +} -<${ +function renderKnopSourceRegistryInventoryFacts( + designatorPath: string, + sourceRegistryPath: string, +): string { + return `<${ toKnopPath(designatorPath) }> sflo:hasKnopSourceRegistry <${sourceRegistryPath}> . @@ -701,6 +723,42 @@ function renderKnopInventoryWithSourceRegistry( `; } +function appendTurtleBlock(currentTurtle: string, blockTurtle: string): string { + if (currentTurtle.length === 0) { + return blockTurtle; + } + + return `${currentTurtle.trimEnd()} + +${blockTurtle}`; +} + +function planImportInventoryAppend(input: { + meshBase: string; + currentKnopInventoryTurtle: string; + requestedFactsTurtle: string; + designatorPath: string; +}): ReturnType { + try { + return planInventoryAppend({ + baseIri: input.meshBase, + currentInventoryTurtle: input.currentKnopInventoryTurtle, + requestedSettledFactsTurtle: input.requestedFactsTurtle, + singleValuedSettledPredicates: + KNOP_SOURCE_REGISTRY_APPEND_SINGLE_VALUED_PREDICATES, + currentInventoryLabel: + `current Knop inventory for ${input.designatorPath}`, + requestedFactsLabel: + `requested Knop source registry facts for ${input.designatorPath}`, + }); + } catch (error) { + if (error instanceof WeaveInputError) { + throw new ImportInputError(error.message); + } + throw error; + } +} + function resolveExistingPayload( meshBase: string, designatorPath: string, diff --git a/src/core/import/import_test.ts b/src/core/import/import_test.ts index 80af739..1a8289d 100644 --- a/src/core/import/import_test.ts +++ b/src/core/import/import_test.ts @@ -195,6 +195,81 @@ Deno.test("planImport replaces source registry provenance for an existing import ); }); +Deno.test("planImport appends missing source registry inventory facts without rewriting carried facts", () => { + const currentKnopInventoryTurtle = legacyImportedPayloadKnopInventory( + ' "keep me" .\n', + ); + + const plan = planImport({ + designatorPath: "bob/page-main", + workingLocalRelativePath: "bob-page-main.md", + importedBytes: encode("# Bob v2\n"), + meshBase: "https://semantic-flow.github.io/mesh-alice-bio/", + currentMeshInventoryTurtle: "", + currentKnopInventoryTurtle, + replaceWorking: true, + sourceBinding: { + targetAccessUrl: "https://example.com/bob-v2.md", + observation: { + observedContentDigest: "sha256:second", + }, + }, + }); + + assertEquals( + plan.createdFiles.map((file) => file.path), + ["bob/page-main/_knop/_sources/sources.ttl"], + ); + assertEquals( + plan.updatedFiles.map((file) => file.path), + ["bob/page-main/_knop/_inventory/inventory.ttl"], + ); + + const inventory = plan.updatedFiles[0]?.contents ?? ""; + assertEquals(inventory.startsWith(currentKnopInventoryTurtle), true); + assertStringIncludes( + inventory, + ' "keep me" .', + ); + assertStringIncludes( + inventory, + " sflo:hasKnopSourceRegistry .", + ); + assertStringIncludes( + inventory, + "sflo:hasWorkingLocatedFile .", + ); +}); + +Deno.test("planImport fails closed when appending source registry facts would conflict", () => { + const currentKnopInventoryTurtle = legacyImportedPayloadKnopInventory( + ` a sflo:KnopSourceRegistry, sflo:DigitalArtifact, sflo:RdfDocument ; + sflo:hasWorkingLocatedFile . +`, + ); + + assertThrows( + () => + planImport({ + designatorPath: "bob/page-main", + workingLocalRelativePath: "bob-page-main.md", + importedBytes: encode("# Bob v2\n"), + meshBase: "https://semantic-flow.github.io/mesh-alice-bio/", + currentMeshInventoryTurtle: "", + currentKnopInventoryTurtle, + replaceWorking: true, + sourceBinding: { + targetAccessUrl: "https://example.com/bob-v2.md", + observation: { + observedContentDigest: "sha256:second", + }, + }, + }), + ImportInputError, + "conflicts with existing fact", + ); +}); + Deno.test("planImport rejects replacement without replaceWorking", () => { const initial = planImport({ designatorPath: "bob/page-main", @@ -247,6 +322,23 @@ Deno.test("planImport rejects replacement without replaceWorking", () => { ); }); +function legacyImportedPayloadKnopInventory(extraTurtle: string): string { + return `@base . +@prefix sflo: . + + a sflo:Knop ; + sflo:hasWorkingKnopInventoryFile ; + sflo:hasPayloadArtifact . + + a sflo:PayloadArtifact, sflo:DigitalArtifact ; + sflo:hasWorkingLocatedFile . + + a sflo:LocatedFile, sflo:RdfDocument . + + a sflo:LocatedFile . +${extraTurtle}`; +} + function encode(value: string): Uint8Array { return new TextEncoder().encode(value); } diff --git a/src/core/weave/artifact_history_queries.ts b/src/core/weave/artifact_history_queries.ts index 3bfc55d..9c441d4 100644 --- a/src/core/weave/artifact_history_queries.ts +++ b/src/core/weave/artifact_history_queries.ts @@ -3,7 +3,8 @@ import { SFLO_NAMESPACE } from "../rdf/namespaces.ts"; import { WeaveInputError } from "./errors.ts"; import { hasNamedNodeFact, - resolveOptionalNamedNodePath, + toAbsoluteIri, + toMeshRelativePath, } from "./rdf_helpers.ts"; const RDF_TYPE_IRI = "http://www.w3.org/1999/02/22-rdf-syntax-ns#type"; @@ -31,17 +32,30 @@ export function requirePayloadCurrentStatePathFromInventory( designatorPath: string, historyPath: string, errorMessage: string, + diagnostics?: { + missingMessage?: string; + conflictMessage?: string; + }, ): string { - const currentStatePath = resolveOptionalNamedNodePath( - quads, - meshBase, - historyPath, - SFLO_LATEST_HISTORICAL_STATE_IRI, - errorMessage, - ); - if (!currentStatePath) { - throw new WeaveInputError(errorMessage); + const historyIri = toAbsoluteIri(meshBase, historyPath); + const values = new Set(); + for (const quad of quads) { + if ( + quad.subject.termType === "NamedNode" && + quad.subject.value === historyIri && + quad.predicate.value === SFLO_LATEST_HISTORICAL_STATE_IRI && + quad.object.termType === "NamedNode" + ) { + values.add(toMeshRelativePath(meshBase, quad.object.value, errorMessage)); + } + } + if (values.size === 0) { + throw new WeaveInputError(diagnostics?.missingMessage ?? errorMessage); + } + if (values.size > 1) { + throw new WeaveInputError(diagnostics?.conflictMessage ?? errorMessage); } + const currentStatePath = values.values().next().value!; if (!currentStatePath.startsWith(`${historyPath}/`)) { throw new WeaveInputError( `Current payload historical state for ${designatorPath} was outside the current payload history: ${currentStatePath}`, diff --git a/src/core/weave/mesh_inventory_renderers.ts b/src/core/weave/mesh_inventory_renderers.ts index 537f133..8f99307 100644 --- a/src/core/weave/mesh_inventory_renderers.ts +++ b/src/core/weave/mesh_inventory_renderers.ts @@ -329,6 +329,40 @@ export function renderFirstPayloadWovenMeshInventoryTurtle( return `${blocks.join("\n\n")}\n`; } +export interface BatchedFirstPayloadMeshInventoryTarget { + designatorPath: string; + workingLocalRelativePath: string; + repositorySourceFloatingLocator?: RepositorySourceFloatingLocator; + payloadIsRdfDocument?: boolean; +} + +export function renderBatchedFirstPayloadWovenMeshInventoryTurtle( + currentMeshInventoryTurtle: string, + meshBase: string, + targets: readonly BatchedFirstPayloadMeshInventoryTarget[], + meshInventoryProgression: MeshInventoryProgression | undefined, +): string { + return targets.reduce( + (turtle, target) => + meshInventoryProgression === undefined + ? renderFirstPayloadWovenCurrentOnlyMeshInventoryTurtle( + turtle, + meshBase, + target.designatorPath, + ) + : renderFirstPayloadWovenMeshInventoryTurtle( + turtle, + meshBase, + target.designatorPath, + target.workingLocalRelativePath, + meshInventoryProgression, + target.repositorySourceFloatingLocator, + target.payloadIsRdfDocument ?? true, + ), + currentMeshInventoryTurtle, + ); +} + export function renderFirstPayloadWovenCurrentOnlyMeshInventoryTurtle( currentMeshInventoryTurtle: string, meshBase: string, diff --git a/src/core/weave/payload_renderers.ts b/src/core/weave/payload_renderers.ts index 9cf9919..38b5f7e 100644 --- a/src/core/weave/payload_renderers.ts +++ b/src/core/weave/payload_renderers.ts @@ -351,12 +351,10 @@ function renderMultiHistoryPayloadWovenKnopInventoryTurtle( const payloadHistoryBlocks = payloadHistories .map(renderRenderedArtifactHistoryBlock) .join("\n\n"); - const payloadStateBlocks = payloadHistories.flatMap((history) => - history.states.map(renderRenderedHistoricalStateBlock) - ).join("\n\n"); - const payloadManifestationBlocks = payloadHistories.flatMap((history) => - history.states.map(renderRenderedManifestationBlock) - ).join("\n\n"); + const payloadStateAndManifestationBlocks = payloadHistories.flatMap(( + history, + ) => history.states.map(renderRenderedHistoricalStateWithManifestationBlock)) + .join("\n\n"); const payloadLocatedFileBlocks = payloadHistories.flatMap((history) => history.states.map((state) => `<${state.locatedFilePath}> a sflo:LocatedFile, sflo:RdfDocument .` @@ -369,12 +367,12 @@ function renderMultiHistoryPayloadWovenKnopInventoryTurtle( const knopInventoryHistoryBlocks = knopInventoryHistories .map(renderRenderedArtifactHistoryBlock) .join("\n\n"); - const knopInventoryStateBlocks = knopInventoryHistories.flatMap((history) => - history.states.map(renderRenderedHistoricalStateBlock) - ).join("\n\n"); - const knopInventoryManifestationBlocks = knopInventoryHistories.flatMap(( - history, - ) => history.states.map(renderRenderedManifestationBlock)).join("\n\n"); + const knopInventoryStateAndManifestationBlocks = knopInventoryHistories + .flatMap(( + history, + ) => + history.states.map(renderRenderedHistoricalStateWithManifestationBlock) + ).join("\n\n"); const knopInventoryLocatedFileBlocks = knopInventoryHistories.flatMap(( history, ) => @@ -403,7 +401,7 @@ function renderMultiHistoryPayloadWovenKnopInventoryTurtle( ) : undefined; - return `@base <${meshBase}> . + return collapseExcessBlankLines(`@base <${meshBase}> . ${SFLO_TURTLE_PREFIX_DECLARATION} @prefix xsd: . @@ -431,9 +429,7 @@ ${ ${payloadHistoryBlocks} -${payloadStateBlocks} - -${payloadManifestationBlocks} +${payloadStateAndManifestationBlocks} <${knopPath}/_meta> a sflo:KnopMetadata, sflo:DigitalArtifact, sflo:RdfDocument ; sflo:hasArtifactHistory <${knopPath}/_meta/_history001> ; @@ -477,9 +473,7 @@ ${ ${knopInventoryHistoryBlocks} -${knopInventoryStateBlocks} - -${knopInventoryManifestationBlocks} +${knopInventoryStateAndManifestationBlocks} <${knopPath}/_meta/meta.ttl> a sflo:LocatedFile, sflo:RdfDocument . @@ -510,10 +504,10 @@ ${payloadResourcePageBlocks} <${knopPath}/_inventory/index.html> a sflo:ResourcePage, sflo:LocatedFile . ${knopInventoryResourcePageBlocks} -`; +`); } -export function renderSecondPayloadWovenKnopInventoryTurtle( +export function renderLaterPayloadWovenKnopInventoryTurtle( meshBase: string, designatorPath: string, payloadLayout: PayloadVersionLayout, @@ -553,7 +547,8 @@ export function renderSecondPayloadWovenKnopInventoryTurtle( if ( payloadLayout.isNewHistory || payloadHistoryPaths.length > 1 || - hasSingleNamedHistoryPath(payloadHistoryPaths) + hasSingleNamedHistoryPath(payloadHistoryPaths) || + payloadLayout.nextStateOrdinal !== 2 ) { return applySupportHistoryPolicies( renderMultiHistoryPayloadWovenKnopInventoryTurtle( @@ -1014,6 +1009,18 @@ function renderRenderedHistoricalStateBlock( ); } +function renderRenderedHistoricalStateWithManifestationBlock( + state: RenderedHistoricalStateModel, +): string { + return `${renderRenderedHistoricalStateBlock(state)} + +${renderRenderedManifestationBlock(state)}`; +} + +function collapseExcessBlankLines(turtle: string): string { + return turtle.replace(/\n{3,}/g, "\n\n").replace(/\n{2,}$/, "\n"); +} + function renderRenderedManifestationBlock( state: RenderedHistoricalStateModel, ): string { diff --git a/src/core/weave/payload_version_layout.ts b/src/core/weave/payload_version_layout.ts index aab7108..83f058b 100644 --- a/src/core/weave/payload_version_layout.ts +++ b/src/core/weave/payload_version_layout.ts @@ -18,6 +18,7 @@ import { hasSubject, parseWeaveShapeQuads, requireSingleNonNegativeIntegerLiteral, + requireSingleNonNegativeIntegerLiteralWithDiagnostics, resolveOptionalNamedNodePath, resolveOptionalSegmentHint, toAbsoluteIri, @@ -96,7 +97,7 @@ export function resolveFirstPayloadVersionLayout( }; } -export function resolveSecondPayloadVersionLayout( +export function resolveLaterPayloadVersionLayout( meshBase: string, designatorPath: string, payloadArtifact: PayloadWorkingArtifact, @@ -166,6 +167,12 @@ export function resolveSecondPayloadVersionLayout( designatorPath, historyPath, `Could not resolve the current payload historical state for ${designatorPath} in ${historyPath}.`, + { + missingMessage: + `Payload history ${historyPath} for ${designatorPath} is missing sflo:latestHistoricalState.`, + conflictMessage: + `Payload history ${historyPath} for ${designatorPath} has conflicting sflo:latestHistoricalState facts.`, + }, ); const currentManifestationPath = resolveCurrentPayloadManifestationPathFromInventory( @@ -202,6 +209,14 @@ export function resolveSecondPayloadVersionLayout( meshBase, historyPath, `Could not resolve the next payload historical state for ${designatorPath} in ${historyPath}.`, + { + missingMessage: + `Payload history ${historyPath} for ${designatorPath} is missing sflo:nextStateOrdinal.`, + conflictMessage: + `Payload history ${historyPath} for ${designatorPath} has conflicting sflo:nextStateOrdinal facts.`, + invalidMessage: + `Payload history ${historyPath} for ${designatorPath} has invalid sflo:nextStateOrdinal.`, + }, ); if (nextStatePath === currentStatePath) { throw new WeaveInputError( @@ -394,15 +409,27 @@ function resolveNextOrdinalStatePathFromHistory( meshBase: string, historyPath: string, errorMessage: string, + diagnostics?: { + missingMessage?: string; + conflictMessage?: string; + invalidMessage?: string; + }, ): string { - const nextStateOrdinal = requireSingleNonNegativeIntegerLiteral( - quads, - toAbsoluteIri(meshBase, historyPath), - SFLO_NEXT_STATE_ORDINAL_IRI, - errorMessage, - ); + const nextStateOrdinal = diagnostics === undefined + ? requireSingleNonNegativeIntegerLiteral( + quads, + toAbsoluteIri(meshBase, historyPath), + SFLO_NEXT_STATE_ORDINAL_IRI, + errorMessage, + ) + : requireSingleNonNegativeIntegerLiteralWithDiagnostics( + quads, + toAbsoluteIri(meshBase, historyPath), + SFLO_NEXT_STATE_ORDINAL_IRI, + diagnostics, + ); if (nextStateOrdinal < 1) { - throw new WeaveInputError(errorMessage); + throw new WeaveInputError(diagnostics?.invalidMessage ?? errorMessage); } return `${historyPath}/${toStateSegment(nextStateOrdinal)}`; diff --git a/src/core/weave/payload_weave_read_model.ts b/src/core/weave/payload_weave_read_model.ts new file mode 100644 index 0000000..9407e2a --- /dev/null +++ b/src/core/weave/payload_weave_read_model.ts @@ -0,0 +1,553 @@ +import type { Quad } from "n3"; +import { toKnopPath } from "../designator_segments.ts"; +import { SFLO_NAMESPACE } from "../rdf/namespaces.ts"; +import type { PayloadWorkingArtifact } from "./candidates.ts"; +import { WeaveInputError } from "./errors.ts"; +import type { PayloadVersionLayout } from "./payload_version_layout.ts"; +import type { MeshInventoryProgression } from "./progression_models.ts"; +import { + hasNamedNodeFact, + hasSubject, + hasSubjectPredicateFact, + parseWeaveShapeQuads, + requireSingleNonNegativeIntegerLiteralWithDiagnostics, + toAbsoluteIri, + toMeshRelativePath, +} from "./rdf_helpers.ts"; +import { + assertHasCurrentPayloadSourceLocator, + assertHasCurrentWorkingFileLocator, +} from "./source_locator_assertions.ts"; +import { + shouldMaterializeSupportHistory, + type SupportArtifactHistoryPolicy, +} from "./support_history_policy.ts"; + +const RDF_TYPE_IRI = "http://www.w3.org/1999/02/22-rdf-syntax-ns#type"; +const SFLO_ARTIFACT_HISTORY_IRI = `${SFLO_NAMESPACE}ArtifactHistory`; +const SFLO_CURRENT_ARTIFACT_HISTORY_IRI = + `${SFLO_NAMESPACE}currentArtifactHistory`; +const SFLO_DIGITAL_ARTIFACT_IRI = `${SFLO_NAMESPACE}DigitalArtifact`; +const SFLO_HAS_ARTIFACT_HISTORY_IRI = `${SFLO_NAMESPACE}hasArtifactHistory`; +const SFLO_HAS_HISTORICAL_STATE_IRI = `${SFLO_NAMESPACE}hasHistoricalState`; +const SFLO_HAS_PAYLOAD_ARTIFACT_IRI = `${SFLO_NAMESPACE}hasPayloadArtifact`; +const SFLO_KNOP_INVENTORY_IRI = `${SFLO_NAMESPACE}KnopInventory`; +const SFLO_LATEST_HISTORICAL_STATE_IRI = + `${SFLO_NAMESPACE}latestHistoricalState`; +const SFLO_NEXT_STATE_ORDINAL_IRI = `${SFLO_NAMESPACE}nextStateOrdinal`; +const SFLO_PAYLOAD_ARTIFACT_IRI = `${SFLO_NAMESPACE}PayloadArtifact`; +const SFLO_RDF_DOCUMENT_IRI = `${SFLO_NAMESPACE}RdfDocument`; + +export interface LaterPayloadWeaveReadModel { + knopMetadataHistoryPolicy: SupportArtifactHistoryPolicy; + knopInventoryHistoryPolicy: SupportArtifactHistoryPolicy; + knopInventoryProgression?: MeshInventoryProgression; +} + +export function resolveLaterPayloadWeaveReadModel( + meshBase: string, + currentKnopInventoryTurtle: string, + designatorPath: string, + payloadArtifact: PayloadWorkingArtifact, + payloadLayout: PayloadVersionLayout, + options?: { + knopMetadataHistoryPolicy?: SupportArtifactHistoryPolicy; + knopInventoryHistoryPolicy?: SupportArtifactHistoryPolicy; + }, +): LaterPayloadWeaveReadModel { + const parseErrorMessage = + `Could not parse the current KnopInventory while resolving later payload weave facts for ${designatorPath}.`; + const quads = parseWeaveShapeQuads( + meshBase, + currentKnopInventoryTurtle, + parseErrorMessage, + ); + const knopPath = toKnopPath(designatorPath); + const payloadHistoryPath = requirePayloadCurrentHistoryFact( + quads, + meshBase, + designatorPath, + ); + + assertRequiredNamedNodeFact( + quads, + meshBase, + knopPath, + SFLO_HAS_PAYLOAD_ARTIFACT_IRI, + designatorPath, + `Knop ${knopPath} is missing sflo:hasPayloadArtifact <${designatorPath}>.`, + ); + assertRequiredNamedNodeFact( + quads, + meshBase, + designatorPath, + RDF_TYPE_IRI, + SFLO_PAYLOAD_ARTIFACT_IRI, + `Payload artifact ${designatorPath} is missing rdf:type sflo:PayloadArtifact.`, + ); + assertRequiredNamedNodeFact( + quads, + meshBase, + designatorPath, + RDF_TYPE_IRI, + SFLO_DIGITAL_ARTIFACT_IRI, + `Payload artifact ${designatorPath} is missing rdf:type sflo:DigitalArtifact.`, + ); + assertHasCurrentPayloadSourceLocator( + quads, + meshBase, + `Payload artifact ${designatorPath} is missing or conflicts on its current working file locator.`, + designatorPath, + payloadArtifact, + ); + + validateCurrentPayloadHistory( + quads, + meshBase, + designatorPath, + payloadHistoryPath, + payloadArtifact, + payloadLayout, + ); + + const effectiveKnopMetadataHistoryPolicy = + resolveEffectiveKnopMetadataHistoryPolicy( + quads, + meshBase, + knopPath, + options?.knopMetadataHistoryPolicy, + ); + const effectiveKnopInventoryHistoryPolicy = + resolveEffectiveKnopInventoryHistoryPolicy( + quads, + meshBase, + designatorPath, + knopPath, + options?.knopInventoryHistoryPolicy, + ); + const knopInventoryProgression = shouldMaterializeSupportHistory( + effectiveKnopInventoryHistoryPolicy, + ) + ? resolveKnopInventoryProgression( + quads, + meshBase, + designatorPath, + knopPath, + ) + : undefined; + + return { + knopMetadataHistoryPolicy: effectiveKnopMetadataHistoryPolicy, + knopInventoryHistoryPolicy: effectiveKnopInventoryHistoryPolicy, + ...(knopInventoryProgression ? { knopInventoryProgression } : {}), + }; +} + +function validateCurrentPayloadHistory( + quads: readonly Quad[], + meshBase: string, + designatorPath: string, + payloadHistoryPath: string, + payloadArtifact: PayloadWorkingArtifact, + payloadLayout: PayloadVersionLayout, +): void { + if (payloadArtifact.currentArtifactHistoryPath !== payloadHistoryPath) { + throw new WeaveInputError( + `Payload artifact ${designatorPath} current history conflicts with the loaded candidate: inventory has ${payloadHistoryPath}, candidate has ${ + payloadArtifact.currentArtifactHistoryPath ?? "" + }.`, + ); + } + if (!isArtifactHistory(quads, meshBase, payloadHistoryPath)) { + throw new WeaveInputError( + `Current payload history ${payloadHistoryPath} for ${designatorPath} is missing rdf:type sflo:ArtifactHistory.`, + ); + } + + const latestStatePath = requireSingleNamedNodePathFact(quads, meshBase, { + subjectPath: payloadHistoryPath, + predicateIri: SFLO_LATEST_HISTORICAL_STATE_IRI, + missingMessage: + `Payload history ${payloadHistoryPath} for ${designatorPath} is missing sflo:latestHistoricalState.`, + conflictMessage: + `Payload history ${payloadHistoryPath} for ${designatorPath} has conflicting sflo:latestHistoricalState facts.`, + }); + if (!latestStatePath.startsWith(`${payloadHistoryPath}/`)) { + throw new WeaveInputError( + `Payload history ${payloadHistoryPath} for ${designatorPath} points sflo:latestHistoricalState outside that history: ${latestStatePath}.`, + ); + } + + if (payloadLayout.isNewHistory) { + if (hasSubject(quads, meshBase, payloadLayout.nextStatePath)) { + throw new WeaveInputError( + `Requested payload state ${payloadLayout.nextStatePath} already exists for ${designatorPath}.`, + ); + } + return; + } + + if (payloadLayout.historyPath !== payloadHistoryPath) { + throw new WeaveInputError( + `Invalid target selection for ${designatorPath}: requested current payload history ${payloadLayout.historyPath}, but current inventory selects ${payloadHistoryPath}.`, + ); + } + if (payloadLayout.currentStatePath !== latestStatePath) { + throw new WeaveInputError( + `Payload history ${payloadHistoryPath} for ${designatorPath} conflicts on sflo:latestHistoricalState: layout resolved ${ + payloadLayout.currentStatePath ?? "" + }, inventory has ${latestStatePath}.`, + ); + } + + const nextStateOrdinal = requireSingleNonNegativeIntegerFact( + quads, + meshBase, + { + subjectPath: payloadHistoryPath, + predicateIri: SFLO_NEXT_STATE_ORDINAL_IRI, + missingMessage: + `Payload history ${payloadHistoryPath} for ${designatorPath} is missing sflo:nextStateOrdinal.`, + conflictMessage: + `Payload history ${payloadHistoryPath} for ${designatorPath} has conflicting sflo:nextStateOrdinal facts.`, + invalidMessage: + `Payload history ${payloadHistoryPath} for ${designatorPath} has invalid sflo:nextStateOrdinal.`, + }, + ); + if ( + payloadLayout.nextStateOrdinal !== undefined && + payloadLayout.nextStateOrdinal !== nextStateOrdinal + ) { + throw new WeaveInputError( + `Payload history ${payloadHistoryPath} for ${designatorPath} conflicts on sflo:nextStateOrdinal: layout resolved ${payloadLayout.nextStateOrdinal}, inventory has ${nextStateOrdinal}.`, + ); + } + + const latestStateOrdinal = parseStateOrdinalFromPath(latestStatePath); + if ( + latestStateOrdinal !== undefined && + nextStateOrdinal !== latestStateOrdinal + 1 + ) { + throw new WeaveInputError( + `Payload history ${payloadHistoryPath} for ${designatorPath} has impossible state progression: latest state ${latestStatePath} and sflo:nextStateOrdinal "${nextStateOrdinal}".`, + ); + } + if ( + hasNamedNodeFact( + quads, + meshBase, + payloadHistoryPath, + SFLO_HAS_HISTORICAL_STATE_IRI, + payloadLayout.nextStatePath, + ) || + hasSubject(quads, meshBase, payloadLayout.nextStatePath) + ) { + throw new WeaveInputError( + `Payload artifact ${designatorPath} already has historical state ${payloadLayout.nextStatePath}.`, + ); + } +} + +function resolveKnopInventoryProgression( + quads: readonly Quad[], + meshBase: string, + designatorPath: string, + knopPath: string, +): MeshInventoryProgression { + const inventoryPath = `${knopPath}/_inventory`; + assertRequiredNamedNodeFact( + quads, + meshBase, + inventoryPath, + RDF_TYPE_IRI, + SFLO_KNOP_INVENTORY_IRI, + `KnopInventory ${inventoryPath} for ${designatorPath} is missing rdf:type sflo:KnopInventory.`, + ); + assertRequiredNamedNodeFact( + quads, + meshBase, + inventoryPath, + RDF_TYPE_IRI, + SFLO_DIGITAL_ARTIFACT_IRI, + `KnopInventory ${inventoryPath} for ${designatorPath} is missing rdf:type sflo:DigitalArtifact.`, + ); + assertRequiredNamedNodeFact( + quads, + meshBase, + inventoryPath, + RDF_TYPE_IRI, + SFLO_RDF_DOCUMENT_IRI, + `KnopInventory ${inventoryPath} for ${designatorPath} is missing rdf:type sflo:RdfDocument.`, + ); + assertHasCurrentWorkingFileLocator( + quads, + meshBase, + `KnopInventory ${inventoryPath} for ${designatorPath} is missing or conflicts on its current working file locator.`, + inventoryPath, + `${inventoryPath}/inventory.ttl`, + ); + + const historyPath = requireSingleNamedNodePathFact(quads, meshBase, { + subjectPath: inventoryPath, + predicateIri: SFLO_CURRENT_ARTIFACT_HISTORY_IRI, + missingMessage: + `KnopInventory ${inventoryPath} for ${designatorPath} is missing sflo:currentArtifactHistory.`, + conflictMessage: + `KnopInventory ${inventoryPath} for ${designatorPath} has conflicting sflo:currentArtifactHistory facts.`, + }); + if (!isArtifactHistory(quads, meshBase, historyPath)) { + throw new WeaveInputError( + `Current KnopInventory history ${historyPath} for ${designatorPath} is missing rdf:type sflo:ArtifactHistory.`, + ); + } + const latestStatePath = requireSingleNamedNodePathFact(quads, meshBase, { + subjectPath: historyPath, + predicateIri: SFLO_LATEST_HISTORICAL_STATE_IRI, + missingMessage: + `KnopInventory history ${historyPath} for ${designatorPath} is missing sflo:latestHistoricalState.`, + conflictMessage: + `KnopInventory history ${historyPath} for ${designatorPath} has conflicting sflo:latestHistoricalState facts.`, + }); + if (!latestStatePath.startsWith(`${historyPath}/`)) { + throw new WeaveInputError( + `KnopInventory history ${historyPath} for ${designatorPath} points sflo:latestHistoricalState outside that history: ${latestStatePath}.`, + ); + } + const latestStateOrdinal = parseStateOrdinalFromPath(latestStatePath); + if (latestStateOrdinal === undefined) { + throw new WeaveInputError( + `KnopInventory history ${historyPath} for ${designatorPath} uses a non-ordinal latest state and cannot be auto-advanced: ${latestStatePath}.`, + ); + } + const nextStateOrdinal = requireSingleNonNegativeIntegerFact( + quads, + meshBase, + { + subjectPath: historyPath, + predicateIri: SFLO_NEXT_STATE_ORDINAL_IRI, + missingMessage: + `KnopInventory history ${historyPath} for ${designatorPath} is missing sflo:nextStateOrdinal.`, + conflictMessage: + `KnopInventory history ${historyPath} for ${designatorPath} has conflicting sflo:nextStateOrdinal facts.`, + invalidMessage: + `KnopInventory history ${historyPath} for ${designatorPath} has invalid sflo:nextStateOrdinal.`, + }, + ); + if (nextStateOrdinal !== latestStateOrdinal + 1) { + throw new WeaveInputError( + `KnopInventory history ${historyPath} for ${designatorPath} has impossible inventory progression: latest state ${latestStatePath} and sflo:nextStateOrdinal "${nextStateOrdinal}".`, + ); + } + + const nextStatePath = `${historyPath}/${toStateSegment(nextStateOrdinal)}`; + if ( + hasNamedNodeFact( + quads, + meshBase, + historyPath, + SFLO_HAS_HISTORICAL_STATE_IRI, + nextStatePath, + ) || + hasSubject(quads, meshBase, nextStatePath) + ) { + throw new WeaveInputError( + `KnopInventory history ${historyPath} for ${designatorPath} already has historical state ${nextStatePath}.`, + ); + } + + return { + historyPath, + latestStatePath, + latestStateOrdinal, + latestManifestationPath: `${latestStatePath}/ttl`, + nextStatePath, + nextStateOrdinal, + }; +} + +function resolveEffectiveKnopMetadataHistoryPolicy( + quads: readonly Quad[], + meshBase: string, + knopPath: string, + requestedPolicy: SupportArtifactHistoryPolicy | undefined, +): SupportArtifactHistoryPolicy { + if ( + requestedPolicy !== undefined && + requestedPolicy !== "versioned" && + requestedPolicy !== "currentOnly" + ) { + return requestedPolicy; + } + if (requestedPolicy === "currentOnly") { + return requestedPolicy; + } + + const metadataPath = `${knopPath}/_meta`; + if ( + hasSubject(quads, meshBase, metadataPath) && + !hasSubjectPredicateFact( + quads, + meshBase, + metadataPath, + SFLO_HAS_ARTIFACT_HISTORY_IRI, + ) + ) { + return "currentOnly"; + } + + return "versioned"; +} + +function resolveEffectiveKnopInventoryHistoryPolicy( + quads: readonly Quad[], + meshBase: string, + designatorPath: string, + knopPath: string, + requestedPolicy: SupportArtifactHistoryPolicy | undefined, +): SupportArtifactHistoryPolicy { + if (requestedPolicy === "currentOnly") { + return requestedPolicy; + } + + const inventoryPath = `${knopPath}/_inventory`; + const hasCurrentHistory = hasSubjectPredicateFact( + quads, + meshBase, + inventoryPath, + SFLO_CURRENT_ARTIFACT_HISTORY_IRI, + ); + if ( + !hasCurrentHistory && + requestedPolicy !== undefined && + requestedPolicy !== "versioned" + ) { + throw new WeaveInputError( + `Unsupported KnopInventory history policy ${requestedPolicy} for ${designatorPath}: ${inventoryPath} has no sflo:currentArtifactHistory to advance.`, + ); + } + if (!hasCurrentHistory) { + return "currentOnly"; + } + + return requestedPolicy ?? "versioned"; +} + +function requirePayloadCurrentHistoryFact( + quads: readonly Quad[], + meshBase: string, + designatorPath: string, +): string { + return requireSingleNamedNodePathFact(quads, meshBase, { + subjectPath: designatorPath, + predicateIri: SFLO_CURRENT_ARTIFACT_HISTORY_IRI, + missingMessage: + `Payload artifact ${designatorPath} is missing sflo:currentArtifactHistory.`, + conflictMessage: + `Payload artifact ${designatorPath} has conflicting sflo:currentArtifactHistory facts.`, + }); +} + +function assertRequiredNamedNodeFact( + quads: readonly Quad[], + meshBase: string, + subjectPath: string, + predicateIri: string, + objectPathOrIri: string, + missingMessage: string, +): void { + if ( + !hasNamedNodeFact( + quads, + meshBase, + subjectPath, + predicateIri, + objectPathOrIri, + ) + ) { + throw new WeaveInputError(missingMessage); + } +} + +function requireSingleNamedNodePathFact( + quads: readonly Quad[], + meshBase: string, + options: { + subjectPath: string; + predicateIri: string; + missingMessage: string; + conflictMessage: string; + }, +): string { + const subjectIri = toAbsoluteIri(meshBase, options.subjectPath); + const values = new Set(); + for (const quad of quads) { + if ( + quad.subject.termType === "NamedNode" && + quad.subject.value === subjectIri && + quad.predicate.value === options.predicateIri && + quad.object.termType === "NamedNode" + ) { + values.add(toMeshRelativePath( + meshBase, + quad.object.value, + options + .conflictMessage, + )); + } + } + + if (values.size === 0) { + throw new WeaveInputError(options.missingMessage); + } + if (values.size > 1) { + throw new WeaveInputError(options.conflictMessage); + } + + return values.values().next().value!; +} + +function requireSingleNonNegativeIntegerFact( + quads: readonly Quad[], + meshBase: string, + options: { + subjectPath: string; + predicateIri: string; + missingMessage: string; + conflictMessage: string; + invalidMessage: string; + }, +): number { + const subjectIri = toAbsoluteIri(meshBase, options.subjectPath); + return requireSingleNonNegativeIntegerLiteralWithDiagnostics( + quads, + subjectIri, + options.predicateIri, + options, + ); +} + +function isArtifactHistory( + quads: readonly Quad[], + meshBase: string, + historyPath: string, +): boolean { + return hasNamedNodeFact( + quads, + meshBase, + historyPath, + RDF_TYPE_IRI, + SFLO_ARTIFACT_HISTORY_IRI, + ); +} + +function parseStateOrdinalFromPath(statePath: string): number | undefined { + const stateSegment = statePath.slice(statePath.lastIndexOf("/") + 1); + const match = stateSegment.match(/^_s(\d+)$/); + const parsed = match ? Number(match[1]) : Number.NaN; + + return Number.isInteger(parsed) && parsed >= 1 ? parsed : undefined; +} + +function toStateSegment(stateOrdinal: number): string { + return `_s${String(stateOrdinal).padStart(4, "0")}`; +} diff --git a/src/core/weave/rdf_helpers.ts b/src/core/weave/rdf_helpers.ts index 8ed14f6..49ace17 100644 --- a/src/core/weave/rdf_helpers.ts +++ b/src/core/weave/rdf_helpers.ts @@ -5,6 +5,12 @@ import { WeaveInputError } from "./errors.ts"; const XSD_NON_NEGATIVE_INTEGER_IRI = "http://www.w3.org/2001/XMLSchema#nonNegativeInteger"; +export interface NonNegativeIntegerLiteralDiagnostics { + missingMessage?: string; + conflictMessage?: string; + invalidMessage?: string; +} + export function parseWeaveShapeQuads( meshBase: string, turtle: string, @@ -45,23 +51,47 @@ export function requireSingleNonNegativeIntegerLiteral( predicateIri: string, errorMessage: string, ): number { - const values = quads.flatMap((quad) => - quad.subject.termType === "NamedNode" && - quad.subject.value === subjectIri && - quad.predicate.value === predicateIri && - quad.object.termType === "Literal" && - quad.object.datatype.value === XSD_NON_NEGATIVE_INTEGER_IRI - ? [quad.object.value] - : [] + return requireSingleNonNegativeIntegerLiteralWithDiagnostics( + quads, + subjectIri, + predicateIri, + { + missingMessage: errorMessage, + conflictMessage: errorMessage, + invalidMessage: errorMessage, + }, ); +} - if (values.length !== 1) { - throw new WeaveInputError(errorMessage); +export function requireSingleNonNegativeIntegerLiteralWithDiagnostics( + quads: readonly Quad[], + subjectIri: string, + predicateIri: string, + diagnostics: NonNegativeIntegerLiteralDiagnostics = {}, +): number { + const values = collectNonNegativeIntegerLiteralValues( + quads, + subjectIri, + predicateIri, + ); + + if (values.size === 0) { + throw new WeaveInputError( + diagnostics.missingMessage ?? "Missing non-negative integer literal.", + ); + } + if (values.size > 1) { + throw new WeaveInputError( + diagnostics.conflictMessage ?? + "Conflicting non-negative integer literal facts.", + ); } - const parsed = Number(values[0]); + const parsed = Number(values.values().next().value); if (!Number.isInteger(parsed) || parsed < 0) { - throw new WeaveInputError(errorMessage); + throw new WeaveInputError( + diagnostics.invalidMessage ?? "Invalid non-negative integer literal.", + ); } return parsed; @@ -98,6 +128,26 @@ export function resolveOptionalNonNegativeIntegerLiteral( return parsed; } +function collectNonNegativeIntegerLiteralValues( + quads: readonly Quad[], + subjectIri: string, + predicateIri: string, +): Set { + const values = new Set(); + for (const quad of quads) { + if ( + quad.subject.termType === "NamedNode" && + quad.subject.value === subjectIri && + quad.predicate.value === predicateIri && + quad.object.termType === "Literal" && + quad.object.datatype.value === XSD_NON_NEGATIVE_INTEGER_IRI + ) { + values.add(quad.object.value); + } + } + return values; +} + export function requireOptionalNamedNodeObject( quads: readonly Quad[], subjectIri: string, diff --git a/src/core/weave/resource_page_builders.ts b/src/core/weave/resource_page_builders.ts index 6af2c2c..a1e3fc2 100644 --- a/src/core/weave/resource_page_builders.ts +++ b/src/core/weave/resource_page_builders.ts @@ -371,36 +371,40 @@ export function buildCurrentOnlyPageDefinitionWeavePages( ]; } -export function buildSecondPayloadWeavePages( +export function buildLaterPayloadWeavePages( designatorPath: string, payloadLayout: PayloadVersionLayout, - options?: { knopInventoryHistoryPolicy?: SupportArtifactHistoryPolicy }, + options?: { knopInventoryProgression?: MeshInventoryProgression }, ): readonly ResourcePageModel[] { - const knopPath = toKnopPath(designatorPath); const displayDesignatorPath = formatDesignatorPathForDisplay(designatorPath); + const payloadStateOrdinalLabel = payloadLayout.nextStateOrdinal === undefined + ? "requested" + : toOrdinalLabel(payloadLayout.nextStateOrdinal); + const knopInventoryProgression = options?.knopInventoryProgression; + const knopInventoryStateOrdinalLabel = knopInventoryProgression === undefined + ? undefined + : toOrdinalLabel(knopInventoryProgression.nextStateOrdinal); const pages: readonly ResourcePageModel[] = [ simplePage( `${payloadLayout.nextStatePath}/index.html`, - `Resource page for the second historical state of the ${displayDesignatorPath} payload artifact.`, + `Resource page for the ${payloadStateOrdinalLabel} historical state of the ${displayDesignatorPath} payload artifact.`, ), simplePage( `${payloadLayout.nextManifestationPath}/index.html`, - `Resource page for the Turtle manifestation of the second ${displayDesignatorPath} payload historical state.`, - ), - simplePage( - `${knopPath}/_inventory/_history001/_s0002/index.html`, - `Resource page for the second historical state of the ${displayDesignatorPath} KnopInventory artifact.`, - ), - simplePage( - `${knopPath}/_inventory/_history001/_s0002/ttl/index.html`, - `Resource page for the Turtle manifestation of the second ${displayDesignatorPath} KnopInventory historical state.`, + `Resource page for the Turtle manifestation of the ${payloadStateOrdinalLabel} ${displayDesignatorPath} payload historical state.`, ), + ...(knopInventoryProgression === undefined ? [] : [ + simplePage( + `${knopInventoryProgression.nextStatePath}/index.html`, + `Resource page for the ${knopInventoryStateOrdinalLabel} historical state of the ${displayDesignatorPath} KnopInventory artifact.`, + ), + simplePage( + `${knopInventoryProgression.nextStatePath}/ttl/index.html`, + `Resource page for the Turtle manifestation of the ${knopInventoryStateOrdinalLabel} ${displayDesignatorPath} KnopInventory historical state.`, + ), + ]), ]; - return shouldMaterializeSupportHistory( - options?.knopInventoryHistoryPolicy ?? "versioned", - ) - ? pages - : omitSecondPayloadKnopInventoryHistoryPages(pages, knopPath); + return pages; } function buildMeshInventoryProgressionPages( @@ -443,17 +447,6 @@ function omitInitialKnopInventoryHistoryPages( ); } -function omitSecondPayloadKnopInventoryHistoryPages( - pages: readonly ResourcePageModel[], - knopPath: string, -): readonly ResourcePageModel[] { - const inventoryStatePagePrefix = `${knopPath}/_inventory/_history001/_s0002`; - return pages.filter((page) => - page.path !== `${inventoryStatePagePrefix}/index.html` && - !page.path.startsWith(`${inventoryStatePagePrefix}/`) - ); -} - function identifierPage( path: string, designatorPath: string, diff --git a/src/core/weave/shape_assertions.ts b/src/core/weave/shape_assertions.ts index 619bec1..872332b 100644 --- a/src/core/weave/shape_assertions.ts +++ b/src/core/weave/shape_assertions.ts @@ -11,10 +11,6 @@ import type { ResourcePageDefinitionWorkingArtifact, } from "./candidates.ts"; import { WeaveInputError } from "./errors.ts"; -import { - requirePayloadCurrentStatePath, - requirePayloadHistoryPath, -} from "./payload_version_layout.ts"; import type { MeshInventoryProgression, PageDefinitionWeaveProgression, @@ -39,10 +35,6 @@ import { assertHasCurrentSourceLocator, assertHasCurrentWorkingFileLocator, } from "./source_locator_assertions.ts"; -import { - shouldMaterializeSupportHistory, - type SupportArtifactHistoryPolicy, -} from "./support_history_policy.ts"; const RDF_TYPE_IRI = "http://www.w3.org/1999/02/22-rdf-syntax-ns#type"; const XSD_NON_NEGATIVE_INTEGER_IRI = @@ -74,7 +66,6 @@ const SFLO_HAS_KNOP_INVENTORY_IRI = `${SFLO_NAMESPACE}hasKnopInventory`; const SFLO_HAS_KNOP_METADATA_IRI = `${SFLO_NAMESPACE}hasKnopMetadata`; const SFLO_HAS_KNOP_SOURCE_REGISTRY_IRI = `${SFLO_NAMESPACE}hasKnopSourceRegistry`; -const SFLO_HAS_PAYLOAD_ARTIFACT_IRI = `${SFLO_NAMESPACE}hasPayloadArtifact`; const SFLO_HAS_REFERENCE_CATALOG_IRI = `${SFLO_NAMESPACE}hasReferenceCatalog`; const SFLO_HAS_RESOURCE_PAGE_IRI = `${SFLO_NAMESPACE}hasResourcePage`; const SFLO_HAS_SOURCE_BINDING_IRI = `${SFLO_NAMESPACE}hasSourceBinding`; @@ -944,122 +935,6 @@ export function assertCurrentKnopInventoryShapeForFirstReferenceCatalogWeave( } } -export function assertCurrentKnopInventoryShapeForSecondPayloadWeave( - meshBase: string, - currentKnopInventoryTurtle: string, - designatorPath: string, - payloadArtifact: PayloadWorkingArtifact, - nextPayloadStatePath: string, - options?: { knopInventoryHistoryPolicy?: SupportArtifactHistoryPolicy }, -): void { - const knopPath = toKnopPath(designatorPath); - const payloadHistoryPath = requirePayloadHistoryPath( - designatorPath, - payloadArtifact, - ); - const currentPayloadStatePath = requirePayloadCurrentStatePath( - designatorPath, - payloadArtifact, - payloadHistoryPath, - ); - const errorMessage = - `The current local weave slice only supports the settled second payload weave shape for ${designatorPath}.`; - const quads = parseWeaveShapeQuads( - meshBase, - currentKnopInventoryTurtle, - errorMessage, - ); - const versionKnopInventory = shouldMaterializeSupportHistory( - options?.knopInventoryHistoryPolicy ?? "versioned", - ); - - const expectedFacts: NamedNodeFact[] = [ - [knopPath, RDF_TYPE_IRI, SFLO_KNOP_IRI], - [knopPath, SFLO_HAS_PAYLOAD_ARTIFACT_IRI, designatorPath], - [designatorPath, RDF_TYPE_IRI, SFLO_PAYLOAD_ARTIFACT_IRI], - [designatorPath, RDF_TYPE_IRI, SFLO_DIGITAL_ARTIFACT_IRI], - [designatorPath, RDF_TYPE_IRI, SFLO_RDF_DOCUMENT_IRI], - [ - designatorPath, - SFLO_CURRENT_ARTIFACT_HISTORY_IRI, - payloadHistoryPath, - ], - [ - payloadHistoryPath, - SFLO_LATEST_HISTORICAL_STATE_IRI, - currentPayloadStatePath, - ], - [`${knopPath}/_inventory`, RDF_TYPE_IRI, SFLO_KNOP_INVENTORY_IRI], - [`${knopPath}/_inventory`, RDF_TYPE_IRI, SFLO_DIGITAL_ARTIFACT_IRI], - [`${knopPath}/_inventory`, RDF_TYPE_IRI, SFLO_RDF_DOCUMENT_IRI], - ]; - if (versionKnopInventory) { - expectedFacts.push( - [ - `${knopPath}/_inventory`, - SFLO_CURRENT_ARTIFACT_HISTORY_IRI, - `${knopPath}/_inventory/_history001`, - ], - [ - `${knopPath}/_inventory/_history001`, - SFLO_LATEST_HISTORICAL_STATE_IRI, - `${knopPath}/_inventory/_history001/_s0001`, - ], - ); - } - assertHasNamedNodeFacts(quads, meshBase, errorMessage, expectedFacts); - assertHasCurrentPayloadSourceLocator( - quads, - meshBase, - errorMessage, - designatorPath, - payloadArtifact, - ); - assertHasCurrentWorkingFileLocator( - quads, - meshBase, - errorMessage, - `${knopPath}/_inventory`, - `${knopPath}/_inventory/inventory.ttl`, - ); - assertHasLiteralFacts(quads, meshBase, errorMessage, [ - [ - payloadHistoryPath, - SFLO_NEXT_STATE_ORDINAL_IRI, - "2", - XSD_NON_NEGATIVE_INTEGER_IRI, - ], - ]); - - if ( - hasNamedNodeFact( - quads, - meshBase, - payloadHistoryPath, - SFLO_HAS_HISTORICAL_STATE_IRI, - nextPayloadStatePath, - ) - ) { - throw new WeaveInputError( - `Payload artifact already has a second explicit historical state for ${designatorPath}.`, - ); - } - if ( - versionKnopInventory && - hasNamedNodeFact( - quads, - meshBase, - `${knopPath}/_inventory/_history001`, - SFLO_HAS_HISTORICAL_STATE_IRI, - `${knopPath}/_inventory/_history001/_s0002`, - ) - ) { - throw new WeaveInputError( - `KnopInventory already has a second explicit historical state for ${designatorPath}.`, - ); - } -} - export function assertHasNamedNodeFacts( quads: readonly Quad[], meshBase: string, diff --git a/src/core/weave/slice_classification.ts b/src/core/weave/slice_classification.ts index b51ab6a..c4cc1ed 100644 --- a/src/core/weave/slice_classification.ts +++ b/src/core/weave/slice_classification.ts @@ -5,7 +5,6 @@ import type { NormalizedVersionTargetSpec } from "../targeting.ts"; import type { WeaveableKnopCandidate } from "./candidates.ts"; import { WeaveInputError } from "./errors.ts"; import { - hasLiteralFact, hasNamedNodeFact, parseWeaveShapeQuads, resolveNamedNodeObjectPaths, @@ -13,10 +12,7 @@ import { toAbsoluteIri, } from "./rdf_helpers.ts"; import type { WeaveSlice } from "./slices.ts"; -import { - isDeclaredArtifactHistory, - requirePayloadCurrentStatePathFromInventory, -} from "./artifact_history_queries.ts"; +import { isDeclaredArtifactHistory } from "./artifact_history_queries.ts"; const XSD_NON_NEGATIVE_INTEGER_IRI = "http://www.w3.org/2001/XMLSchema#nonNegativeInteger"; @@ -80,7 +76,7 @@ export function classifyWeaveSlice( ); } - if (slice === "secondPayloadWeave" && !candidate.payloadArtifact) { + if (slice === "laterPayloadWeave" && !candidate.payloadArtifact) { throw new WeaveInputError( `Payload weave candidate ${candidate.designatorPath} is missing working payload state.`, ); @@ -248,7 +244,7 @@ export function detectPendingWeaveSlice( !isDeclaredArtifactHistory(quads, meshBase, payloadHistoryPath) && payloadHasDeclaredArtifactHistory ) { - return "secondPayloadWeave"; + return "laterPayloadWeave"; } if ( @@ -257,33 +253,14 @@ export function detectPendingWeaveSlice( isDeclaredArtifactHistory(quads, meshBase, payloadHistoryPath) && hasNextStateSegmentHint(quads, meshBase, payloadHistoryPath) ) { - return "secondPayloadWeave"; + return "laterPayloadWeave"; } if ( payloadRelationship && payloadHistoryPath && - hasNamedNodeFact( - quads, - meshBase, - payloadHistoryPath, - SFLO_LATEST_HISTORICAL_STATE_IRI, - requirePayloadCurrentStatePathFromInventory( - quads, - meshBase, - designatorPath, - payloadHistoryPath, - errorMessage, - ), - ) && - hasLiteralFact( - quads, - meshBase, - payloadHistoryPath, - SFLO_NEXT_STATE_ORDINAL_IRI, - "2", - XSD_NON_NEGATIVE_INTEGER_IRI, - ) && + isDeclaredArtifactHistory(quads, meshBase, payloadHistoryPath) && + payloadHistoryHasPendingOrdinalState(quads, meshBase, payloadHistoryPath) && (!knopInventoryHasHistory || (hasNamedNodeFact( quads, @@ -300,7 +277,7 @@ export function detectPendingWeaveSlice( `${knopPath}/_inventory/_history001/_s0002`, ))) ) { - return "secondPayloadWeave"; + return "laterPayloadWeave"; } if ( @@ -309,7 +286,7 @@ export function detectPendingWeaveSlice( knopInventoryHasHistory && hasPayloadVersionNamingTarget(target) ) { - return "secondPayloadWeave"; + return "laterPayloadWeave"; } if ( @@ -354,3 +331,30 @@ function hasPayloadVersionNamingTarget( target.stateSegment !== undefined || target.manifestationSegment !== undefined); } + +function payloadHistoryHasPendingOrdinalState( + quads: readonly Quad[], + meshBase: string, + historyPath: string, +): boolean { + const historyIri = toAbsoluteIri(meshBase, historyPath); + const values = new Set(); + for (const quad of quads) { + if ( + quad.subject.termType === "NamedNode" && + quad.subject.value === historyIri && + quad.predicate.value === SFLO_NEXT_STATE_ORDINAL_IRI && + quad.object.termType === "Literal" && + quad.object.datatype.value === XSD_NON_NEGATIVE_INTEGER_IRI + ) { + values.add(quad.object.value); + } + } + + if (values.size !== 1) { + return values.size > 1; + } + + const parsed = Number(values.values().next().value); + return Number.isInteger(parsed) && parsed > 1; +} diff --git a/src/core/weave/slices.ts b/src/core/weave/slices.ts index 0ed2a6c..7ec7d99 100644 --- a/src/core/weave/slices.ts +++ b/src/core/weave/slices.ts @@ -4,4 +4,4 @@ export type WeaveSlice = | "firstExtractedKnopWeave" | "firstReferenceCatalogWeave" | "pageDefinitionWeave" - | "secondPayloadWeave"; + | "laterPayloadWeave"; diff --git a/src/core/weave/weave.ts b/src/core/weave/weave.ts index e386d7e..1a6a3ac 100644 --- a/src/core/weave/weave.ts +++ b/src/core/weave/weave.ts @@ -3,6 +3,7 @@ import { toDesignatorResourcePagePath, toKnopPath, } from "../designator_segments.ts"; +import type { PlannedBinaryFile, PlannedFile } from "../planned_file.ts"; import { type NormalizedVersionTargetSpec, normalizeVersionTargetSpecs, @@ -33,9 +34,10 @@ import { } from "./payload_overwrite.ts"; import { renderFirstPayloadWovenKnopInventoryTurtle, - renderSecondPayloadWovenKnopInventoryTurtle, + renderLaterPayloadWovenKnopInventoryTurtle, } from "./payload_renderers.ts"; import { + renderBatchedFirstPayloadWovenMeshInventoryTurtle, renderFirstKnopWovenMeshInventoryTurtle, renderFirstPayloadWovenCurrentOnlyMeshInventoryTurtle, renderFirstPayloadWovenMeshInventoryTurtle, @@ -52,8 +54,9 @@ import { } from "./knop_inventory_renderers.ts"; import { resolveFirstPayloadVersionLayout, - resolveSecondPayloadVersionLayout, + resolveLaterPayloadVersionLayout, } from "./payload_version_layout.ts"; +import { resolveLaterPayloadWeaveReadModel } from "./payload_weave_read_model.ts"; import { hasNamedNodeFact, parseWeaveShapeQuads } from "./rdf_helpers.ts"; import { classifyWeaveSlice } from "./slice_classification.ts"; import { extractCurrentReferenceCatalogLinks } from "./reference_catalog_links.ts"; @@ -74,9 +77,10 @@ import { buildFirstKnopWeavePages, buildFirstPayloadWeavePages, buildFirstReferenceCatalogWeavePages, - buildSecondPayloadWeavePages, + buildLaterPayloadWeavePages, buildSubsequentPageDefinitionWeavePages, } from "./resource_page_builders.ts"; +import type { ResourcePageModel } from "./resource_page_models.ts"; import { renderKnopInventoryWithPreservedSupportArtifacts } from "./knop_support_renderers.ts"; import { renderArtifactHistoryIndexPage, @@ -87,7 +91,6 @@ import { assertCurrentKnopInventoryShapeForFirstExtractedKnopWeave, assertCurrentKnopInventoryShapeForFirstPageDefinitionWeave, assertCurrentKnopInventoryShapeForFirstReferenceCatalogWeave, - assertCurrentKnopInventoryShapeForSecondPayloadWeave, assertCurrentKnopInventoryShapeForSubsequentPageDefinitionWeave, assertCurrentKnopInventoryWithoutHistory, assertCurrentKnopMetadataShape, @@ -220,6 +223,22 @@ export function planWeave(input: PlanWeaveInput): WeavePlan { throw new WeaveInputError("No weave candidates were found."); } if (weaveableKnops.length !== 1) { + if (requestedTargets.length > 1 && !overwriteExistingState) { + const plan = planExplicitPayloadBatchWeave( + meshBase, + input.currentMeshInventoryTurtle, + input.currentMeshMetadataTurtle, + weaveableKnops, + input.supportHistoryPolicies, + input.namingPolicies, + ); + + return applyResourcePageGenerationPolicies(plan, { + config: input.resourcePageGenerationConfig, + policies: input.resourcePageGenerationPolicies, + explicitRequest: requestedTargets.length > 0, + }); + } throw new WeaveInputError( `The current local weave slice supports exactly one weave candidate; found ${weaveableKnops.length}.`, ); @@ -296,8 +315,8 @@ export function planWeave(input: PlanWeaveInput): WeavePlan { candidate, input.supportHistoryPolicies, ); - case "secondPayloadWeave": - return planSecondPayloadWeave( + case "laterPayloadWeave": + return planLaterPayloadWeave( meshBase, candidate, target, @@ -483,7 +502,7 @@ function assertPayloadNamingSupportedForSlice( return; } - if (slice === "firstPayloadWeave" || slice === "secondPayloadWeave") { + if (slice === "firstPayloadWeave" || slice === "laterPayloadWeave") { return; } if (target.recursive) { @@ -497,6 +516,461 @@ function assertPayloadNamingSupportedForSlice( ); } +interface FirstPayloadBatchEntry { + selection: SelectedWeaveableKnopCandidate; + payloadLayout: ReturnType; + payloadIsRdfDocument: boolean; + meshInventoryProgression?: ReturnType< + typeof resolveCurrentMeshInventoryProgressionForFirstPayloadWeave + >; +} + +function planExplicitPayloadBatchWeave( + meshBase: string, + currentMeshInventoryTurtle: string, + currentMeshMetadataTurtle: string | undefined, + selectedWeaveableKnops: readonly SelectedWeaveableKnopCandidate[], + supportHistoryPolicies?: WeaveSupportHistoryPolicies, + namingPolicies?: WeaveNamingPolicies, +): WeavePlan { + const selections = [...selectedWeaveableKnops].sort((left, right) => + left.candidate.designatorPath.localeCompare(right.candidate.designatorPath) + ); + const plans: WeavePlan[] = []; + const firstPayloadEntries: FirstPayloadBatchEntry[] = []; + + for (const selection of selections) { + const candidate = selection.candidate; + const target = selection.target; + const designatorPath = candidate.designatorPath; + const knopPath = toKnopPath(designatorPath); + + withBatchTargetDiagnostic(designatorPath, () => { + if (target?.recursive) { + throw new WeaveInputError( + "Multi-target payload weave requires exact target designators; recursive target selection is not supported for a payload batch.", + ); + } + + assertCurrentKnopMetadataShape( + meshBase, + candidate.currentKnopMetadataTurtle, + designatorPath, + knopPath, + ); + assertCurrentKnopInventoryBaseShape( + meshBase, + candidate.currentKnopInventoryTurtle, + knopPath, + ); + + const detectedSlice = classifyWeaveSlice(meshBase, candidate, target); + const slice = detectedSlice ?? (target !== undefined && + candidate.payloadArtifact?.currentArtifactHistoryPath !== undefined + ? "laterPayloadWeave" + : undefined); + assertPayloadNamingSupportedForSlice(slice, designatorPath, target); + + if ( + slice === "laterPayloadWeave" && payloadTargetIsAlreadyCurrent( + candidate, + target, + ) + ) { + return; + } + + if (slice === "firstPayloadWeave") { + firstPayloadEntries.push(resolveFirstPayloadBatchEntry( + meshBase, + currentMeshInventoryTurtle, + currentMeshMetadataTurtle, + selection, + supportHistoryPolicies, + namingPolicies, + )); + plans.push(planFirstPayloadWeave( + meshBase, + currentMeshInventoryTurtle, + currentMeshMetadataTurtle, + candidate, + target, + supportHistoryPolicies, + namingPolicies, + )); + return; + } + + if (slice === "laterPayloadWeave") { + plans.push(planLaterPayloadWeave( + meshBase, + candidate, + target, + supportHistoryPolicies, + namingPolicies, + )); + return; + } + + throw new WeaveInputError( + `Multi-target weave supports explicit payload targets only; ${designatorPath} is currently ${ + slice ?? "not weaveable" + }.`, + ); + }); + } + + if (plans.length === 0) { + return { + meshBase, + wovenDesignatorPaths: [], + createdFiles: [], + updatedFiles: [], + createdPages: [], + }; + } + + return mergePayloadBatchPlans( + meshBase, + currentMeshInventoryTurtle, + currentMeshMetadataTurtle, + plans, + firstPayloadEntries, + ); +} + +function resolveFirstPayloadBatchEntry( + meshBase: string, + currentMeshInventoryTurtle: string, + currentMeshMetadataTurtle: string | undefined, + selection: SelectedWeaveableKnopCandidate, + supportHistoryPolicies?: WeaveSupportHistoryPolicies, + namingPolicies?: WeaveNamingPolicies, +): FirstPayloadBatchEntry { + const candidate = selection.candidate; + const target = selection.target; + const payloadArtifact = candidate.payloadArtifact!; + const designatorPath = candidate.designatorPath; + const meshInventoryHistoryPolicy = supportHistoryPolicies?.meshInventory ?? + "versioned"; + const versionMeshInventory = shouldMaterializeSupportHistory( + meshInventoryHistoryPolicy, + ); + const meshInventoryProgression = versionMeshInventory + ? resolveCurrentMeshInventoryProgressionForFirstPayloadWeave( + meshBase, + currentMeshInventoryTurtle, + currentMeshMetadataTurtle, + designatorPath, + ) + : undefined; + assertCurrentPayloadArtifactShape( + meshBase, + candidate.currentKnopInventoryTurtle, + designatorPath, + payloadArtifact, + ); + const payloadIsRdfDocument = payloadArtifact.payloadIsRdfDocument ?? + currentPayloadArtifactIsRdfDocument( + meshBase, + candidate.currentKnopInventoryTurtle, + designatorPath, + ); + const payloadLayout = resolveFirstPayloadVersionLayout( + meshBase, + designatorPath, + payloadArtifact.workingLocalRelativePath, + candidate.currentKnopInventoryTurtle, + payloadArtifact.currentArtifactHistoryPath, + target, + namingPolicies, + ); + + return { + selection, + payloadLayout, + payloadIsRdfDocument, + ...(meshInventoryProgression ? { meshInventoryProgression } : {}), + }; +} + +function mergePayloadBatchPlans( + meshBase: string, + currentMeshInventoryTurtle: string, + currentMeshMetadataTurtle: string | undefined, + plans: readonly WeavePlan[], + firstPayloadEntries: readonly FirstPayloadBatchEntry[], +): WeavePlan { + const createdFiles: PlannedFile[] = []; + const createdBinaryFiles: PlannedBinaryFile[] = []; + const updatedFiles: PlannedFile[] = []; + const createdPages: ResourcePageModel[] = []; + const createdPaths = new Set(); + const updatedPaths = new Set(); + const pagePaths = new Set(); + const firstPayloadProgression = resolveBatchMeshInventoryProgression( + firstPayloadEntries, + ); + const mergedMeshInventoryTurtle = firstPayloadEntries.length === 0 + ? undefined + : renderBatchedFirstPayloadWovenMeshInventoryTurtle( + currentMeshInventoryTurtle, + meshBase, + firstPayloadEntries.map((entry) => ({ + designatorPath: entry.selection.candidate.designatorPath, + workingLocalRelativePath: entry.selection.candidate.payloadArtifact! + .workingLocalRelativePath, + repositorySourceFloatingLocator: entry.selection.candidate + .payloadArtifact!.repositorySourceFloatingLocator, + payloadIsRdfDocument: entry.payloadIsRdfDocument, + })), + firstPayloadProgression, + ); + + if (firstPayloadProgression && mergedMeshInventoryTurtle) { + addCreatedFile(createdFiles, createdPaths, updatedPaths, { + path: `${firstPayloadProgression.nextStatePath}/ttl/inventory.ttl`, + contents: mergedMeshInventoryTurtle, + }); + } + if (mergedMeshInventoryTurtle) { + addUpdatedFile(updatedFiles, updatedPaths, createdPaths, { + path: "_mesh/_inventory/inventory.ttl", + contents: mergedMeshInventoryTurtle, + }); + } + + for (const plan of plans) { + for (const file of plan.createdFiles) { + if (isSharedMeshInventoryFile(file.path)) { + continue; + } + addCreatedFile(createdFiles, createdPaths, updatedPaths, file); + } + for (const file of plan.createdBinaryFiles ?? []) { + addCreatedBinaryFile( + createdBinaryFiles, + createdPaths, + updatedPaths, + file, + ); + } + for (const file of plan.updatedFiles) { + if (isSharedMeshInventoryFile(file.path)) { + continue; + } + addUpdatedFile(updatedFiles, updatedPaths, createdPaths, file); + } + for (const page of plan.createdPages) { + if (pagePaths.has(page.path)) { + continue; + } + createdPages.push(page); + pagePaths.add(page.path); + } + } + + if (firstPayloadProgression) { + addUpdatedFile(updatedFiles, updatedPaths, createdPaths, { + path: "_mesh/_meta/meta.ttl", + contents: renderMeshMetadataWithMeshInventoryProgression( + currentMeshMetadataTurtle, + firstPayloadProgression, + ), + }); + } + + return { + meshBase, + wovenDesignatorPaths: plans.flatMap((plan) => plan.wovenDesignatorPaths), + createdFiles, + ...(createdBinaryFiles.length > 0 ? { createdBinaryFiles } : {}), + updatedFiles, + createdPages, + }; +} + +function resolveBatchMeshInventoryProgression( + firstPayloadEntries: readonly FirstPayloadBatchEntry[], +): FirstPayloadBatchEntry["meshInventoryProgression"] { + const progressions = firstPayloadEntries + .map((entry) => entry.meshInventoryProgression) + .filter((progression) => progression !== undefined); + if (progressions.length === 0) { + return undefined; + } + if (progressions.length !== firstPayloadEntries.length) { + throw new WeaveInputError( + "Multi-target payload weave found conflicting MeshInventory history policies across requested targets.", + ); + } + + const [first, ...rest] = progressions; + for (const progression of rest) { + if (!meshInventoryProgressionsEqual(first!, progression)) { + throw new WeaveInputError( + "Multi-target payload weave found impossible MeshInventory progression across requested targets.", + ); + } + } + + return first; +} + +function meshInventoryProgressionsEqual( + left: NonNullable, + right: NonNullable, +): boolean { + return left.historyPath === right.historyPath && + left.latestStatePath === right.latestStatePath && + left.latestStateOrdinal === right.latestStateOrdinal && + left.latestManifestationPath === right.latestManifestationPath && + left.nextStatePath === right.nextStatePath && + left.nextStateOrdinal === right.nextStateOrdinal && + left.nextHistoryOrdinal === right.nextHistoryOrdinal; +} + +function isSharedMeshInventoryFile(path: string): boolean { + return path === "_mesh/_inventory/inventory.ttl" || + path === "_mesh/_meta/meta.ttl" || + path.startsWith("_mesh/_inventory/_history"); +} + +function addCreatedFile( + files: PlannedFile[], + createdPaths: Set, + updatedPaths: ReadonlySet, + file: PlannedFile, +): void { + if (createdPaths.has(file.path) || updatedPaths.has(file.path)) { + throw new WeaveInputError( + `Multi-target payload weave produced a conflicting created file: ${file.path}`, + ); + } + files.push(file); + createdPaths.add(file.path); +} + +function addCreatedBinaryFile( + files: PlannedBinaryFile[], + createdPaths: Set, + updatedPaths: ReadonlySet, + file: PlannedBinaryFile, +): void { + if (createdPaths.has(file.path) || updatedPaths.has(file.path)) { + throw new WeaveInputError( + `Multi-target payload weave produced a conflicting created file: ${file.path}`, + ); + } + files.push(file); + createdPaths.add(file.path); +} + +function addUpdatedFile( + files: PlannedFile[], + updatedPaths: Set, + createdPaths: ReadonlySet, + file: PlannedFile, +): void { + if (createdPaths.has(file.path)) { + throw new WeaveInputError( + `Multi-target payload weave attempted to update a newly created file: ${file.path}`, + ); + } + if (updatedPaths.has(file.path)) { + throw new WeaveInputError( + `Multi-target payload weave produced a conflicting updated file: ${file.path}`, + ); + } + files.push(file); + updatedPaths.add(file.path); +} + +function withBatchTargetDiagnostic( + designatorPath: string, + operation: () => T, +): T { + try { + return operation(); + } catch (error) { + if (error instanceof WeaveInputError) { + throw new WeaveInputError( + `Target ${ + formatDesignatorPathForDisplay(designatorPath) + }: ${error.message}`, + ); + } + throw error; + } +} + +function payloadTargetIsAlreadyCurrent( + candidate: WeaveableKnopCandidate, + target?: NormalizedVersionTargetSpec, +): boolean { + const payloadArtifact = candidate.payloadArtifact; + if (!payloadArtifact || !payloadContentsMatchLatest(payloadArtifact)) { + return false; + } + if ( + target?.historySegment !== undefined && + payloadArtifact.currentArtifactHistoryPath?.endsWith( + `/${target.historySegment}`, + ) !== true + ) { + return false; + } + if ( + target?.stateSegment !== undefined && + payloadArtifact.latestHistoricalStatePath?.endsWith( + `/${target.stateSegment}`, + ) !== true + ) { + return false; + } + if ( + target?.manifestationSegment !== undefined && + payloadArtifact.latestHistoricalSnapshotPath?.includes( + `/${target.manifestationSegment}/`, + ) !== true + ) { + return false; + } + + return true; +} + +function payloadContentsMatchLatest( + payloadArtifact: WeaveableKnopCandidate["payloadArtifact"], +): boolean { + if (!payloadArtifact) { + return false; + } + if (payloadArtifact.currentPayloadBytes !== undefined) { + return payloadArtifact.latestHistoricalSnapshotBytes !== undefined && + bytesEqual( + payloadArtifact.currentPayloadBytes, + payloadArtifact.latestHistoricalSnapshotBytes, + ); + } + + return payloadArtifact.latestHistoricalSnapshotTurtle !== undefined && + payloadArtifact.currentPayloadTurtle === + payloadArtifact.latestHistoricalSnapshotTurtle; +} + +function bytesEqual(left: Uint8Array, right: Uint8Array): boolean { + if (left.byteLength !== right.byteLength) { + return false; + } + for (let index = 0; index < left.byteLength; index += 1) { + if (left[index] !== right[index]) { + return false; + } + } + return true; +} + function planFirstKnopWeave( meshBase: string, currentMeshInventoryTurtle: string, @@ -1205,7 +1679,7 @@ function hasReferenceCatalogInKnopInventory( ); } -function planSecondPayloadWeave( +function planLaterPayloadWeave( meshBase: string, candidate: WeaveableKnopCandidate, target?: NormalizedVersionTargetSpec, @@ -1215,7 +1689,7 @@ function planSecondPayloadWeave( const payloadArtifact = candidate.payloadArtifact!; const designatorPath = candidate.designatorPath; const knopPath = toKnopPath(designatorPath); - const payloadLayout = resolveSecondPayloadVersionLayout( + const payloadLayout = resolveLaterPayloadVersionLayout( meshBase, designatorPath, payloadArtifact, @@ -1223,30 +1697,29 @@ function planSecondPayloadWeave( target, namingPolicies, ); - assertCurrentKnopInventoryShapeForSecondPayloadWeave( + const readModel = resolveLaterPayloadWeaveReadModel( meshBase, candidate.currentKnopInventoryTurtle, designatorPath, payloadArtifact, - payloadLayout.nextStatePath, - { knopInventoryHistoryPolicy: supportHistoryPolicies?.knopInventory }, + payloadLayout, + { + knopMetadataHistoryPolicy: supportHistoryPolicies?.knopMetadata, + knopInventoryHistoryPolicy: supportHistoryPolicies?.knopInventory, + }, ); const payloadSnapshotPath = `${payloadLayout.nextManifestationPath}/${ toFileName(payloadArtifact.workingLocalRelativePath) }`; - const knopMetadataHistoryPolicy = supportHistoryPolicies?.knopMetadata ?? - "versioned"; - const knopInventoryHistoryPolicy = supportHistoryPolicies?.knopInventory ?? - "versioned"; - const versionKnopInventory = shouldMaterializeSupportHistory( - knopInventoryHistoryPolicy, - ); + const knopMetadataHistoryPolicy = readModel.knopMetadataHistoryPolicy; + const knopInventoryHistoryPolicy = readModel.knopInventoryHistoryPolicy; + const knopInventoryProgression = readModel.knopInventoryProgression; const wovenKnopInventoryTurtle = renderKnopInventoryWithPreservedSupportArtifacts({ meshBase, currentKnopInventoryTurtle: candidate.currentKnopInventoryTurtle, - renderedKnopInventoryTurtle: renderSecondPayloadWovenKnopInventoryTurtle( + renderedKnopInventoryTurtle: renderLaterPayloadWovenKnopInventoryTurtle( meshBase, designatorPath, payloadLayout, @@ -1266,9 +1739,9 @@ function planSecondPayloadWeave( path: payloadSnapshotPath, contents: payloadArtifact.currentPayloadTurtle, }, - ...(versionKnopInventory + ...(knopInventoryProgression ? [{ - path: `${knopPath}/_inventory/_history001/_s0002/ttl/inventory.ttl`, + path: `${knopInventoryProgression.nextStatePath}/ttl/inventory.ttl`, contents: wovenKnopInventoryTurtle, }] : []), @@ -1279,10 +1752,10 @@ function planSecondPayloadWeave( contents: wovenKnopInventoryTurtle, }, ], - createdPages: buildSecondPayloadWeavePages( + createdPages: buildLaterPayloadWeavePages( designatorPath, payloadLayout, - { knopInventoryHistoryPolicy }, + { knopInventoryProgression }, ), }; } diff --git a/src/core/weave/weave_test.ts b/src/core/weave/weave_test.ts index 5b2e7e8..b6b5eb8 100644 --- a/src/core/weave/weave_test.ts +++ b/src/core/weave/weave_test.ts @@ -636,7 +636,7 @@ const firstReferenceCatalogWeaveReferenceCatalogTurtle = sflo:targetArtifact . `; -const secondPayloadWeaveKnopInventoryTurtle = +const laterPayloadWeaveKnopInventoryTurtle = `@base . @prefix sflo: . @prefix xsd: . @@ -687,6 +687,104 @@ const secondPayloadWeaveKnopInventoryTurtle = sflo:hasResourcePage . `; +const laterOrdinalCurrentOnlyPayloadKnopMetadataTurtle = + `@base . +@prefix sflo: . + + a sflo:Knop ; + sflo:designatorPath "projections/contracts/inn-ambush-contract-context" ; + sflo:hasWorkingKnopInventoryFile . +`; + +const laterOrdinalCurrentOnlyPayloadKnopInventoryTurtle = + `@base . +@prefix sflo: . +@prefix xsd: . + + a sflo:Knop ; + sflo:hasKnopMetadata ; + sflo:hasKnopInventory ; + sflo:hasWorkingKnopInventoryFile ; + sflo:hasPayloadArtifact ; + sflo:hasKnopSourceRegistry ; + sflo:hasReferenceCatalog ; + sflo:hasResourcePage . + + a sflo:PayloadArtifact, sflo:DigitalArtifact, sflo:RdfDocument ; + sflo:hasArtifactHistory ; + sflo:currentArtifactHistory ; + sflo:nextHistoryOrdinal "2"^^xsd:nonNegativeInteger ; + sflo:hasWorkingLocatedFile ; + sflo:hasResourcePage . + + a sflo:ArtifactHistory ; + sflo:historyOrdinal "1"^^xsd:nonNegativeInteger ; + sflo:hasHistoricalState ; + sflo:hasHistoricalState ; + sflo:hasHistoricalState ; + sflo:latestHistoricalState ; + sflo:nextStateOrdinal "4"^^xsd:nonNegativeInteger ; + sflo:hasResourcePage . + + a sflo:HistoricalState ; + sflo:stateOrdinal "1"^^xsd:nonNegativeInteger ; + sflo:hasManifestation ; + sflo:locatedFileForState ; + sflo:hasResourcePage . + + a sflo:ArtifactManifestation, sflo:RdfDocument ; + sflo:locatedFileForManifestation ; + sflo:hasResourcePage . + + a sflo:HistoricalState ; + sflo:stateOrdinal "2"^^xsd:nonNegativeInteger ; + sflo:previousHistoricalState ; + sflo:hasManifestation ; + sflo:locatedFileForState ; + sflo:hasResourcePage . + + a sflo:ArtifactManifestation, sflo:RdfDocument ; + sflo:locatedFileForManifestation ; + sflo:hasResourcePage . + + a sflo:HistoricalState ; + sflo:stateOrdinal "3"^^xsd:nonNegativeInteger ; + sflo:previousHistoricalState ; + sflo:hasManifestation ; + sflo:locatedFileForState ; + sflo:hasResourcePage . + + a sflo:ArtifactManifestation, sflo:RdfDocument ; + sflo:locatedFileForManifestation ; + sflo:hasResourcePage . + + a sflo:KnopMetadata, sflo:DigitalArtifact, sflo:RdfDocument ; + sflo:hasWorkingLocatedFile ; + sflo:hasResourcePage . + + a sflo:KnopInventory, sflo:DigitalArtifact, sflo:RdfDocument ; + sflo:hasWorkingLocatedFile ; + sflo:hasResourcePage . + + a sflo:KnopSourceRegistry, sflo:DigitalArtifact, sflo:RdfDocument ; + sflo:hasWorkingLocatedFile ; + sflo:hasResourcePage . + + a sflo:ReferenceCatalog, sflo:DigitalArtifact, sflo:RdfDocument ; + sflo:hasWorkingLocatedFile ; + sflo:hasResourcePage . + + a sflo:LocatedFile, sflo:RdfDocument . + + a sflo:LocatedFile, sflo:RdfDocument . + + a sflo:LocatedFile, sflo:RdfDocument . + + a sflo:LocatedFile, sflo:RdfDocument . + + a sflo:LocatedFile, sflo:RdfDocument . +`; + Deno.test("planWeave renders the first alice knop-created-woven slice", () => { const plan = planWeave({ request: {}, @@ -864,6 +962,184 @@ Deno.test("planWeave renders the first alice bio payload weave slice", () => { ); }); +Deno.test("planWeave batches explicit first payload targets with one merged MeshInventory progression", () => { + const meshBase = "https://semantic-flow.github.io/mesh-alice-bio/"; + const currentMeshInventoryTurtle = `@base <${meshBase}> . +@prefix sflo: . +@prefix xsd: . + +<_mesh> a sflo:SemanticMesh ; + sflo:meshBase "${meshBase}"^^xsd:anyURI ; + sflo:hasMeshMetadata <_mesh/_meta> ; + sflo:hasMeshInventory <_mesh/_inventory> ; + sflo:hasKnop ; + sflo:hasKnop ; + sflo:hasResourcePage <_mesh/index.html> . + +<_mesh/_inventory> a sflo:MeshInventory, sflo:DigitalArtifact, sflo:RdfDocument ; + sflo:hasArtifactHistory <_mesh/_inventory/_history001> ; + sflo:currentArtifactHistory <_mesh/_inventory/_history001> ; + sflo:nextHistoryOrdinal "2"^^xsd:nonNegativeInteger ; + sflo:hasWorkingLocatedFile <_mesh/_inventory/inventory.ttl> ; + sflo:hasResourcePage <_mesh/_inventory/index.html> . + +<_mesh/_inventory/_history001> a sflo:ArtifactHistory ; + sflo:historyOrdinal "1"^^xsd:nonNegativeInteger ; + sflo:hasHistoricalState <_mesh/_inventory/_history001/_s0001> ; + sflo:hasHistoricalState <_mesh/_inventory/_history001/_s0002> ; + sflo:latestHistoricalState <_mesh/_inventory/_history001/_s0002> ; + sflo:nextStateOrdinal "3"^^xsd:nonNegativeInteger ; + sflo:hasResourcePage <_mesh/_inventory/_history001/index.html> . + +<_mesh/_inventory/_history001/_s0002> a sflo:HistoricalState ; + sflo:stateOrdinal "2"^^xsd:nonNegativeInteger ; + sflo:hasManifestation <_mesh/_inventory/_history001/_s0002/ttl> ; + sflo:locatedFileForState <_mesh/_inventory/_history001/_s0002/ttl/inventory.ttl> ; + sflo:hasResourcePage <_mesh/_inventory/_history001/_s0002/index.html> . + +<_mesh/_inventory/_history001/_s0002/ttl> a sflo:ArtifactManifestation, sflo:RdfDocument ; + sflo:locatedFileForManifestation <_mesh/_inventory/_history001/_s0002/ttl/inventory.ttl> ; + sflo:hasResourcePage <_mesh/_inventory/_history001/_s0002/ttl/index.html> . + + a sflo:PayloadArtifact, sflo:DigitalArtifact, sflo:RdfDocument ; + sflo:hasWorkingLocatedFile . + + a sflo:Knop ; + sflo:hasWorkingKnopInventoryFile . + + a sflo:PayloadArtifact, sflo:DigitalArtifact, sflo:RdfDocument ; + sflo:hasWorkingLocatedFile . + + a sflo:Knop ; + sflo:hasWorkingKnopInventoryFile . + +<_mesh/index.html> a sflo:ResourcePage, sflo:LocatedFile . + +<_mesh/_inventory/_history001/_s0002/ttl/inventory.ttl> a sflo:LocatedFile, sflo:RdfDocument . + +<_mesh/_inventory/_history001/_s0002/ttl/index.html> a sflo:ResourcePage, sflo:LocatedFile . +`; + const currentMeshMetadataTurtle = meshMetadataProgressionTurtle( + "_mesh/_inventory/_history001/_s0002", + 3, + ); + const makeKnopMetadata = (designatorPath: string) => + `@base <${meshBase}> . +@prefix sflo: . + +<${designatorPath}/_knop> a sflo:Knop ; + sflo:designatorPath "${designatorPath}" ; + sflo:hasWorkingKnopInventoryFile <${designatorPath}/_knop/_inventory/inventory.ttl> . +`; + const makeKnopInventory = ( + designatorPath: string, + workingLocalRelativePath: string, + ) => + `@base <${meshBase}> . +@prefix sflo: . + +<${designatorPath}/_knop> a sflo:Knop ; + sflo:hasKnopMetadata <${designatorPath}/_knop/_meta> ; + sflo:hasKnopInventory <${designatorPath}/_knop/_inventory> ; + sflo:hasWorkingKnopInventoryFile <${designatorPath}/_knop/_inventory/inventory.ttl> ; + sflo:hasPayloadArtifact <${designatorPath}> . + +<${designatorPath}> a sflo:PayloadArtifact, sflo:DigitalArtifact, sflo:RdfDocument ; + sflo:hasWorkingLocatedFile <${workingLocalRelativePath}> . +`; + + const plan = planWeave({ + request: { + targets: [ + { designatorPath: "bob/data" }, + { designatorPath: "alice/data" }, + ], + }, + meshBase, + currentMeshInventoryTurtle, + currentMeshMetadataTurtle, + weaveableKnops: [ + { + designatorPath: "bob/data", + currentKnopMetadataTurtle: makeKnopMetadata("bob/data"), + currentKnopInventoryTurtle: makeKnopInventory( + "bob/data", + "bob-data.ttl", + ), + payloadArtifact: { + workingLocalRelativePath: "bob-data.ttl", + currentPayloadTurtle: `@base <${meshBase}> . +@prefix schema: . + + a schema:Person . +`, + }, + }, + { + designatorPath: "alice/data", + currentKnopMetadataTurtle: makeKnopMetadata("alice/data"), + currentKnopInventoryTurtle: makeKnopInventory( + "alice/data", + "alice-data.ttl", + ), + payloadArtifact: { + workingLocalRelativePath: "alice-data.ttl", + currentPayloadTurtle: `@base <${meshBase}> . +@prefix schema: . + + a schema:Person . +`, + }, + }, + ], + }); + + assertEquals(plan.wovenDesignatorPaths, ["alice/data", "bob/data"]); + assertEquals( + plan.createdFiles.filter((file) => + file.path === "_mesh/_inventory/_history001/_s0003/ttl/inventory.ttl" + ).length, + 1, + ); + assertEquals( + plan.updatedFiles.filter((file) => + file.path === "_mesh/_inventory/inventory.ttl" + ).length, + 1, + ); + const meshInventory = + plan.updatedFiles.find((file) => + file.path === "_mesh/_inventory/inventory.ttl" + )?.contents ?? ""; + assertStringIncludes( + meshInventory, + "sflo:hasKnop ;\n sflo:hasKnop ;", + ); + assertStringIncludes( + meshInventory, + " a sflo:PayloadArtifact, sflo:DigitalArtifact, sflo:RdfDocument ;", + ); + assertStringIncludes( + meshInventory, + " a sflo:PayloadArtifact, sflo:DigitalArtifact, sflo:RdfDocument ;", + ); + assertStringIncludes( + meshInventory, + "sflo:hasHistoricalState <_mesh/_inventory/_history001/_s0003> ;", + ); + const meshMetadata = + plan.updatedFiles.find((file) => file.path === "_mesh/_meta/meta.ttl") + ?.contents ?? ""; + assertStringIncludes( + meshMetadata, + "sflo:latestHistoricalState <_mesh/_inventory/_history001/_s0003> ;", + ); + assertStringIncludes( + meshMetadata, + 'sflo:nextStateOrdinal "4"^^xsd:nonNegativeInteger .', + ); +}); + Deno.test("planWeave preserves floating repository payload source locators", () => { const repositorySourceFloatingLocator = { repositoryUrl: "https://github.com/semantic-flow/sflo.git", @@ -2263,7 +2539,7 @@ Deno.test("planWeave renders the second alice bio payload weave slice", () => { weaveableKnops: [{ designatorPath: "alice/data", currentKnopMetadataTurtle: firstPayloadWeaveKnopMetadataTurtle, - currentKnopInventoryTurtle: secondPayloadWeaveKnopInventoryTurtle, + currentKnopInventoryTurtle: laterPayloadWeaveKnopInventoryTurtle, payloadArtifact: { workingLocalRelativePath: "alice-data.ttl", currentArtifactHistoryPath: "alice/data/_history001", @@ -2307,6 +2583,230 @@ Deno.test("planWeave renders the second alice bio payload weave slice", () => { ); }); +Deno.test("detectPendingWeaveSlice recognizes later ordinal payload advancement with current-only support artifacts", () => { + assertEquals( + detectPendingWeaveSlice( + "https://semantic-flow.github.io/stagecraft-test/", + "projections/contracts/inn-ambush-contract-context", + laterOrdinalCurrentOnlyPayloadKnopInventoryTurtle, + ), + "laterPayloadWeave", + ); +}); + +Deno.test("planWeave advances a later ordinal payload while preserving current-only support artifacts", () => { + const designatorPath = "projections/contracts/inn-ambush-contract-context"; + const plan = planWeave({ + request: { + targets: [{ designatorPath }], + }, + meshBase: "https://semantic-flow.github.io/stagecraft-test/", + currentMeshInventoryTurtle: firstReferenceCatalogWeaveMeshInventoryTurtle, + weaveableKnops: [{ + designatorPath, + currentKnopMetadataTurtle: + laterOrdinalCurrentOnlyPayloadKnopMetadataTurtle, + currentKnopInventoryTurtle: + laterOrdinalCurrentOnlyPayloadKnopInventoryTurtle, + payloadArtifact: { + workingLocalRelativePath: + "projections/contracts/inn-ambush-contract-context.ttl", + currentArtifactHistoryPath: + "projections/contracts/inn-ambush-contract-context/_history001", + currentPayloadTurtle: + `@base . +@prefix dcterms: . + + dcterms:title "Inn Ambush Contract Context v4" . +`, + latestHistoricalStatePath: + "projections/contracts/inn-ambush-contract-context/_history001/_s0003", + }, + }], + supportHistoryPolicies: { + knopMetadata: "currentOnly", + knopInventory: "currentOnly", + }, + }); + + assertEquals(plan.wovenDesignatorPaths, [designatorPath]); + assertEquals( + plan.createdFiles.map((file) => file.path), + [ + "projections/contracts/inn-ambush-contract-context/_history001/_s0004/ttl/inn-ambush-contract-context.ttl", + ], + ); + assertEquals(plan.updatedFiles.map((file) => file.path), [ + "projections/contracts/inn-ambush-contract-context/_knop/_inventory/inventory.ttl", + ]); + assertEquals(plan.createdPages.map((page) => page.path), [ + "projections/contracts/inn-ambush-contract-context/_history001/_s0004/index.html", + "projections/contracts/inn-ambush-contract-context/_history001/_s0004/ttl/index.html", + ]); + const updatedInventory = plan.updatedFiles[0]?.contents ?? ""; + assertStringIncludes( + updatedInventory, + "sflo:latestHistoricalState ;", + ); + assertStringIncludes( + updatedInventory, + 'sflo:nextStateOrdinal "5"^^xsd:nonNegativeInteger ;', + ); + assertStringIncludes( + updatedInventory, + "sflo:previousHistoricalState ;", + ); + assertStringIncludes( + updatedInventory, + " sflo:hasKnopSourceRegistry .", + ); + assertStringIncludes( + updatedInventory, + " sflo:hasReferenceCatalog .", + ); + assertFalse( + updatedInventory.includes( + "projections/contracts/inn-ambush-contract-context/_knop/_inventory/_history001", + ), + ); +}); + +Deno.test("planWeave advances only the requested later payload when unrelated candidates are present", () => { + const designatorPath = "projections/contracts/inn-ambush-contract-context"; + const plan = planWeave({ + request: { + targets: [{ designatorPath }], + }, + meshBase: "https://semantic-flow.github.io/stagecraft-test/", + currentMeshInventoryTurtle: firstReferenceCatalogWeaveMeshInventoryTurtle, + weaveableKnops: [{ + designatorPath, + currentKnopMetadataTurtle: + laterOrdinalCurrentOnlyPayloadKnopMetadataTurtle, + currentKnopInventoryTurtle: + laterOrdinalCurrentOnlyPayloadKnopInventoryTurtle, + payloadArtifact: { + workingLocalRelativePath: + "projections/contracts/inn-ambush-contract-context.ttl", + currentArtifactHistoryPath: + "projections/contracts/inn-ambush-contract-context/_history001", + currentPayloadTurtle: + `@base . +@prefix dcterms: . + + dcterms:title "Inn Ambush Contract Context v4" . +`, + latestHistoricalStatePath: + "projections/contracts/inn-ambush-contract-context/_history001/_s0003", + }, + }, { + designatorPath: "projections/contracts/unrelated-pending-payload", + currentKnopMetadataTurtle: "<> a .", + currentKnopInventoryTurtle: "<> a .", + }], + supportHistoryPolicies: { + knopMetadata: "currentOnly", + knopInventory: "currentOnly", + }, + }); + + assertEquals(plan.wovenDesignatorPaths, [designatorPath]); + assertEquals(plan.updatedFiles.map((file) => file.path), [ + "projections/contracts/inn-ambush-contract-context/_knop/_inventory/inventory.ttl", + ]); + assertFalse( + plan.createdFiles.some((file) => + file.path.includes("unrelated-pending-payload") + ), + ); +}); + +Deno.test("planWeave names a missing latest-state fact on later payload advancement", () => { + const designatorPath = "projections/contracts/inn-ambush-contract-context"; + const currentKnopInventoryTurtle = + laterOrdinalCurrentOnlyPayloadKnopInventoryTurtle.replace( + " sflo:latestHistoricalState ;\n", + "", + ); + + assertThrows( + () => + planWeave({ + request: { + targets: [{ designatorPath }], + }, + meshBase: "https://semantic-flow.github.io/stagecraft-test/", + currentMeshInventoryTurtle: + firstReferenceCatalogWeaveMeshInventoryTurtle, + weaveableKnops: [{ + designatorPath, + currentKnopMetadataTurtle: + laterOrdinalCurrentOnlyPayloadKnopMetadataTurtle, + currentKnopInventoryTurtle, + payloadArtifact: { + workingLocalRelativePath: + "projections/contracts/inn-ambush-contract-context.ttl", + currentArtifactHistoryPath: + "projections/contracts/inn-ambush-contract-context/_history001", + currentPayloadTurtle: "<> a .\n", + latestHistoricalStatePath: + "projections/contracts/inn-ambush-contract-context/_history001/_s0003", + }, + }], + supportHistoryPolicies: { + knopMetadata: "currentOnly", + knopInventory: "currentOnly", + }, + }), + WeaveInputError, + "missing sflo:latestHistoricalState", + ); +}); + +Deno.test("planWeave names conflicting next-state facts on later payload advancement", () => { + const designatorPath = "projections/contracts/inn-ambush-contract-context"; + const currentKnopInventoryTurtle = + laterOrdinalCurrentOnlyPayloadKnopInventoryTurtle.replace( + ' sflo:nextStateOrdinal "4"^^xsd:nonNegativeInteger ;\n', + ` sflo:nextStateOrdinal "4"^^xsd:nonNegativeInteger ; + sflo:nextStateOrdinal "5"^^xsd:nonNegativeInteger ; +`, + ); + + assertThrows( + () => + planWeave({ + request: { + targets: [{ designatorPath }], + }, + meshBase: "https://semantic-flow.github.io/stagecraft-test/", + currentMeshInventoryTurtle: + firstReferenceCatalogWeaveMeshInventoryTurtle, + weaveableKnops: [{ + designatorPath, + currentKnopMetadataTurtle: + laterOrdinalCurrentOnlyPayloadKnopMetadataTurtle, + currentKnopInventoryTurtle, + payloadArtifact: { + workingLocalRelativePath: + "projections/contracts/inn-ambush-contract-context.ttl", + currentArtifactHistoryPath: + "projections/contracts/inn-ambush-contract-context/_history001", + currentPayloadTurtle: "<> a .\n", + latestHistoricalStatePath: + "projections/contracts/inn-ambush-contract-context/_history001/_s0003", + }, + }], + supportHistoryPolicies: { + knopMetadata: "currentOnly", + knopInventory: "currentOnly", + }, + }), + WeaveInputError, + "conflicting sflo:nextStateOrdinal", + ); +}); + Deno.test("planWeave applies configured manifestation naming on the second payload weave slice", () => { const plan = planWeave({ request: { @@ -2320,7 +2820,7 @@ Deno.test("planWeave applies configured manifestation naming on the second paylo weaveableKnops: [{ designatorPath: "alice/data", currentKnopMetadataTurtle: firstPayloadWeaveKnopMetadataTurtle, - currentKnopInventoryTurtle: secondPayloadWeaveKnopInventoryTurtle, + currentKnopInventoryTurtle: laterPayloadWeaveKnopInventoryTurtle, payloadArtifact: { workingLocalRelativePath: "alice-data.ttl", currentArtifactHistoryPath: "alice/data/_history001", @@ -2366,7 +2866,7 @@ Deno.test("planWeave requires explicit state segments for non-ordinal state nami weaveableKnops: [{ designatorPath: "alice/data", currentKnopMetadataTurtle: firstPayloadWeaveKnopMetadataTurtle, - currentKnopInventoryTurtle: secondPayloadWeaveKnopInventoryTurtle, + currentKnopInventoryTurtle: laterPayloadWeaveKnopInventoryTurtle, payloadArtifact: { workingLocalRelativePath: "alice-data.ttl", currentArtifactHistoryPath: "alice/data/_history001", @@ -2384,7 +2884,7 @@ Deno.test("planWeave requires explicit state segments for non-ordinal state nami }); Deno.test("planWeave applies requested payload naming on the second payload weave slice", () => { - const currentKnopInventoryTurtle = secondPayloadWeaveKnopInventoryTurtle + const currentKnopInventoryTurtle = laterPayloadWeaveKnopInventoryTurtle .replaceAll( "alice/data/_history001/_s0001", "alice/data/releases/v0.0.1", @@ -2445,7 +2945,7 @@ Deno.test("planWeave applies requested payload naming on the second payload weav }); Deno.test("planWeave consumes next-state intent on the selected current history", () => { - const currentKnopInventoryTurtle = secondPayloadWeaveKnopInventoryTurtle + const currentKnopInventoryTurtle = laterPayloadWeaveKnopInventoryTurtle .replace( "@prefix sflo: .", `@prefix sflo: . @@ -2499,7 +2999,7 @@ Deno.test("planWeave consumes next-state intent on the selected current history" Deno.test("detectPendingWeaveSlice accepts semantically equivalent second payload weave Turtle", () => { const equivalentKnopInventoryTurtle = withRdfPrefix( - secondPayloadWeaveKnopInventoryTurtle, + laterPayloadWeaveKnopInventoryTurtle, ) .replace( " a sflo:Knop ;", @@ -2528,12 +3028,12 @@ Deno.test("detectPendingWeaveSlice accepts semantically equivalent second payloa "alice/data", equivalentKnopInventoryTurtle, ), - "secondPayloadWeave", + "laterPayloadWeave", ); }); Deno.test("detectPendingWeaveSlice supports custom payload history and state naming", () => { - const customNamedKnopInventoryTurtle = secondPayloadWeaveKnopInventoryTurtle + const customNamedKnopInventoryTurtle = laterPayloadWeaveKnopInventoryTurtle .replaceAll( "alice/data/_history001/_s0001", "alice/data/releases/v0.0.1", @@ -2546,7 +3046,7 @@ Deno.test("detectPendingWeaveSlice supports custom payload history and state nam "alice/data", customNamedKnopInventoryTurtle, ), - "secondPayloadWeave", + "laterPayloadWeave", ); }); @@ -2599,7 +3099,7 @@ Deno.test("detectPendingWeaveSlice ignores current-only settled Knops with Resou }); Deno.test("planWeave can start a requested payload history after another history exists", () => { - const currentKnopInventoryTurtle = secondPayloadWeaveKnopInventoryTurtle + const currentKnopInventoryTurtle = laterPayloadWeaveKnopInventoryTurtle .replaceAll( "alice/data/_history001/_s0001", "alice/data/releases/v0.0.1", @@ -2676,7 +3176,7 @@ Deno.test("planWeave can start a requested payload history after another history Deno.test("planWeave can start a requested payload history with working-only KnopInventory policy", () => { const historyPath = "alice/data/_knop/_inventory/_history001"; - const versionedKnopInventoryTurtle = secondPayloadWeaveKnopInventoryTurtle + const versionedKnopInventoryTurtle = laterPayloadWeaveKnopInventoryTurtle .replaceAll( "alice/data/_history001/_s0001", "alice/data/releases/v0.0.1", @@ -2760,7 +3260,7 @@ Deno.test("planWeave can start a requested payload history with working-only Kno }); Deno.test("planWeave rejects implicit ordinal advancement after a named payload state", () => { - const currentKnopInventoryTurtle = secondPayloadWeaveKnopInventoryTurtle + const currentKnopInventoryTurtle = laterPayloadWeaveKnopInventoryTurtle .replaceAll( "alice/data/_history001/_s0001", "alice/data/releases/v0.0.1", diff --git a/src/runtime/weave/candidate_loader.ts b/src/runtime/weave/candidate_loader.ts index b9fe13b..e3d5529 100644 --- a/src/runtime/weave/candidate_loader.ts +++ b/src/runtime/weave/candidate_loader.ts @@ -3,6 +3,7 @@ import { formatDesignatorPathForDisplay, toKnopPath, } from "../../core/designator_segments.ts"; +import { SFLO_NAMESPACE } from "../../core/rdf/namespaces.ts"; import { findUncoveredRequestedTargets, type NormalizedVersionTargetSpec, @@ -12,6 +13,10 @@ import { WeaveInputError, } from "../../core/weave/weave.ts"; import type { WeaveableKnopCandidate } from "../../core/weave/candidates.ts"; +import { + hasNamedNodeFact, + parseWeaveShapeQuads, +} from "../../core/weave/rdf_helpers.ts"; import type { WeaveSlice } from "../../core/weave/slices.ts"; import { listKnopDesignatorPaths } from "../mesh/inventory.ts"; import type { OperationalLocalPathPolicy } from "../operational/local_path_policy.ts"; @@ -28,6 +33,8 @@ import { } from "./planning_context.ts"; import { timeOptional, timeOptionalSync } from "./timing_helpers.ts"; +const SFLO_HAS_PAYLOAD_ARTIFACT_IRI = `${SFLO_NAMESPACE}hasPayloadArtifact`; + export function assertRequestedTargetsAreWeaveable( targets: readonly NormalizedVersionTargetSpec[], weaveableKnops: readonly WeaveableKnopCandidate[], @@ -57,6 +64,10 @@ export function assertRequestedTargetsAreWeaveable( ); } +export interface LoadWeaveableKnopCandidatesOptions { + includeSettledPayloadTargets?: boolean; +} + export async function loadWeaveableKnopCandidates( workspaceRoot: string, localPathPolicy: OperationalLocalPathPolicy, @@ -70,6 +81,7 @@ export async function loadWeaveableKnopCandidates( overlay?: ReadonlyMap, timing?: RuntimeTiming, phasePrefix = "loadCandidates", + options?: LoadWeaveableKnopCandidatesOptions, ): Promise { const phase = (name: string) => `${phasePrefix}.${name}`; const designatorPaths = timeOptionalSync( @@ -107,6 +119,7 @@ export async function loadWeaveableKnopCandidates( overlay, timing, phase("candidate"), + options, ), ) : loadWeaveableKnopCandidate( @@ -118,6 +131,7 @@ export async function loadWeaveableKnopCandidates( overlay, timing, phase("candidate"), + options, ), ); if (candidate === undefined) { @@ -141,6 +155,7 @@ async function loadWeaveableKnopCandidate( overlay?: ReadonlyMap, timing?: RuntimeTiming, phasePrefix = "candidate", + options?: LoadWeaveableKnopCandidatesOptions, ): Promise { const phase = (name: string) => `${phasePrefix}.${name}`; const knopPath = toKnopPath(designatorPath); @@ -175,7 +190,7 @@ async function loadWeaveableKnopCandidate( currentKnopMetadataTurtle, currentKnopInventoryTurtle, }; - const slice = timeOptionalSync( + let slice = timeOptionalSync( timing, phase("detectPendingSlice"), () => @@ -186,13 +201,25 @@ async function loadWeaveableKnopCandidate( target, ), ); + if ( + slice === undefined && + options?.includeSettledPayloadTargets === true && + target !== undefined && + hasExplicitPayloadCandidate( + meshBase, + designatorPath, + currentKnopInventoryTurtle, + ) + ) { + slice = "laterPayloadWeave"; + } if (!slice) { return undefined; } if ( - slice === "firstPayloadWeave" || slice === "secondPayloadWeave" || + slice === "firstPayloadWeave" || slice === "laterPayloadWeave" || slice === "firstExtractedKnopWeave" ) { candidate.payloadArtifact = await timeOptional( @@ -257,15 +284,35 @@ async function loadWeaveableKnopCandidate( ); } - return isWeaveableKnopCandidate(candidate, slice, target) + return isWeaveableKnopCandidate(candidate, slice, target, options) ? candidate : undefined; } +function hasExplicitPayloadCandidate( + meshBase: string, + designatorPath: string, + currentKnopInventoryTurtle: string, +): boolean { + const quads = parseWeaveShapeQuads( + meshBase, + currentKnopInventoryTurtle, + `Could not parse the current KnopInventory while loading explicit payload target ${designatorPath}.`, + ); + return hasNamedNodeFact( + quads, + meshBase, + toKnopPath(designatorPath), + SFLO_HAS_PAYLOAD_ARTIFACT_IRI, + designatorPath, + ); +} + function isWeaveableKnopCandidate( candidate: WeaveableKnopCandidate, slice: WeaveSlice, target?: NormalizedVersionTargetSpec, + options?: LoadWeaveableKnopCandidatesOptions, ): boolean { if (slice === "firstExtractedKnopWeave") { return candidate.referenceTargetSourcePayloadArtifact !== undefined; @@ -296,20 +343,28 @@ function isWeaveableKnopCandidate( return candidate.payloadArtifact !== undefined; } - if (slice === "secondPayloadWeave") { - return candidate.payloadArtifact !== undefined && - (hasPayloadVersionNamingTarget(target) || - (candidate.payloadArtifact.latestHistoricalSnapshotTurtle !== - undefined && - candidate.payloadArtifact.currentPayloadTurtle !== - candidate.payloadArtifact.latestHistoricalSnapshotTurtle) || - (candidate.payloadArtifact.currentPayloadBytes !== undefined && - candidate.payloadArtifact.latestHistoricalSnapshotBytes !== - undefined && - !bytesEqual( - candidate.payloadArtifact.currentPayloadBytes, - candidate.payloadArtifact.latestHistoricalSnapshotBytes, - ))); + if (slice === "laterPayloadWeave") { + if (candidate.payloadArtifact === undefined) { + return false; + } + if ( + options?.includeSettledPayloadTargets === true && + target !== undefined + ) { + return true; + } + return hasPayloadVersionNamingTarget(target) || + (candidate.payloadArtifact.latestHistoricalSnapshotTurtle !== + undefined && + candidate.payloadArtifact.currentPayloadTurtle !== + candidate.payloadArtifact.latestHistoricalSnapshotTurtle) || + (candidate.payloadArtifact.currentPayloadBytes !== undefined && + candidate.payloadArtifact.latestHistoricalSnapshotBytes !== + undefined && + !bytesEqual( + candidate.payloadArtifact.currentPayloadBytes, + candidate.payloadArtifact.latestHistoricalSnapshotBytes, + )); } return slice === "firstKnopWeave"; diff --git a/src/runtime/weave/page_generation.ts b/src/runtime/weave/page_generation.ts index 9d6b5c6..a4d87b5 100644 --- a/src/runtime/weave/page_generation.ts +++ b/src/runtime/weave/page_generation.ts @@ -32,6 +32,7 @@ export interface GeneratePreparedPagesOptions { now?: () => Date; includeSemanticFlowMetadata: boolean; historyTrackingPolicyOverride?: HistoryTrackingPolicy; + updateTimestampOnlyPages?: boolean; timing?: RuntimeTiming; phasePrefix?: string; } @@ -102,7 +103,10 @@ export async function generatePreparedPages( const writeResult = await timeOptional( options.timing, phase("writePages"), - () => writeGeneratedPagesUpsert(options.meshRoot, pageFiles), + () => + writeGeneratedPagesUpsert(options.meshRoot, pageFiles, { + updateTimestampOnlyPages: options.updateTimestampOnlyPages === true, + }), ); const result = { @@ -256,6 +260,7 @@ function normalizeGeneratedTimestampFooters(contents: string): string { async function writeGeneratedPagesUpsert( workspaceRoot: string, files: readonly PlannedFile[], + options: { updateTimestampOnlyPages: boolean }, ): Promise<{ createdPaths: string[]; updatedPaths: string[]; @@ -289,8 +294,10 @@ async function writeGeneratedPagesUpsert( normalizeGeneratedTimestampFooters(currentContents) === normalizeGeneratedTimestampFooters(file.contents) ) { - skippedTimestampOnlyPaths.push(file.path); - continue; + if (!options.updateTimestampOnlyPages) { + skippedTimestampOnlyPaths.push(file.path); + continue; + } } await Deno.mkdir(dirname(absolutePath), { recursive: true }); diff --git a/src/runtime/weave/prepared_execution.ts b/src/runtime/weave/prepared_execution.ts index 4be4756..73d48f0 100644 --- a/src/runtime/weave/prepared_execution.ts +++ b/src/runtime/weave/prepared_execution.ts @@ -4,6 +4,7 @@ import type { HistoryTrackingPolicy } from "../config/effective_config.ts"; import type { RuntimeTiming } from "../timing.ts"; import type { WeaveProgressHandler } from "./progress.ts"; import { + type InputSnapshotVerificationHooks, type PreparedVersionExecution, prepareVersionExecution, } from "./version_execution.ts"; @@ -20,6 +21,7 @@ export async function prepareWeaveExecution( historyTrackingPolicyOverride?: HistoryTrackingPolicy, onProgress?: WeaveProgressHandler, timing?: RuntimeTiming, + inputSnapshotVerification?: InputSnapshotVerificationHooks, ): Promise { const version = await prepareVersionExecution( workspaceRoot, @@ -29,6 +31,7 @@ export async function prepareWeaveExecution( historyTrackingPolicyOverride, onProgress, timing, + inputSnapshotVerification, ); return { diff --git a/src/runtime/weave/version_execution.ts b/src/runtime/weave/version_execution.ts index 9c3a458..c7220f2 100644 --- a/src/runtime/weave/version_execution.ts +++ b/src/runtime/weave/version_execution.ts @@ -1,22 +1,40 @@ -import { dirname, join } from "@std/path"; +import { dirname, join, relative } from "@std/path"; import { Parser } from "n3"; import type { PlannedBinaryFile, PlannedFile, } from "../../core/planned_file.ts"; +import { toKnopPath } from "../../core/designator_segments.ts"; import { type NormalizedVersionTargetSpec, resolveTargetSelections, } from "../../core/targeting.ts"; +import type { WeaveableKnopCandidate } from "../../core/weave/candidates.ts"; import { + detectPendingWeaveSlice, planMeshSupportResourcePages, planVersion, type VersionPlan, WeaveInputError, } from "../../core/weave/weave.ts"; -import { listKnopDesignatorPaths } from "../mesh/inventory.ts"; -import type { HistoryTrackingPolicy } from "../config/effective_config.ts"; -import type { OperationalLocalPathPolicy } from "../operational/local_path_policy.ts"; +import type { + ResourcePageGenerationConfig, + WeaveArtifactRole, +} from "../../core/weave/resource_page_policy.ts"; +import { + listKnopDesignatorPaths, + resolvePayloadArtifactInventoryState, +} from "../mesh/inventory.ts"; +import type { + EffectiveConfig, + HistoryTrackingPolicy, +} from "../config/effective_config.ts"; +import { + LocalPathAccessError, + type OperationalLocalPathPolicy, + resolveAllowedLocalPath, + resolveRepositorySourceFloatingLocalPath, +} from "../operational/local_path_policy.ts"; import type { RuntimeTiming } from "../timing.ts"; import { assertRequestedTargetsAreWeaveable, @@ -24,6 +42,7 @@ import { } from "./candidate_loader.ts"; import { createEffectiveConfigProviderForExecution, + type EffectiveConfigProvider, namingPoliciesFromEffectiveConfig, resourcePageGenerationConfigFromScopedEffectiveConfigs, resourcePageGenerationPoliciesFromEffectiveConfig, @@ -50,6 +69,11 @@ export interface PreparedVersionExecution { plan: VersionPlan; } +export interface InputSnapshotVerificationHooks { + afterInitialHash?: () => Promise | void; + afterVerifiedCapture?: () => Promise | void; +} + export interface PreparedVersionWriteResult { meshBase: string; versionedDesignatorPaths: readonly string[]; @@ -65,6 +89,7 @@ export async function prepareVersionExecution( historyTrackingPolicyOverride?: HistoryTrackingPolicy, onProgress?: WeaveProgressHandler, timing?: RuntimeTiming, + inputSnapshotVerification?: InputSnapshotVerificationHooks, ): Promise { await timeOptional( timing, @@ -108,6 +133,26 @@ export async function prepareVersionExecution( selection.target as NormalizedVersionTargetSpec | undefined, ]), ); + const shouldAttemptPayloadBatch = shouldAttemptExplicitPayloadBatch( + targets, + overwriteExistingState, + ); + const inputSnapshot = shouldAttemptPayloadBatch + ? await timeOptional( + timing, + "prepare.snapshotPayloadInputs.hash", + () => + createPayloadBatchWorkingFileSnapshot({ + workspaceRoot, + localPathPolicy, + meshBase: meshState.meshBase, + requestedDesignatorPaths, + }), + ) + : undefined; + if (inputSnapshot !== undefined) { + await inputSnapshotVerification?.afterInitialHash?.(); + } const overlay = new TextFileOverlay(); const initialWeaveableKnops = await timeOptional( timing, @@ -126,12 +171,39 @@ export async function prepareVersionExecution( ), ); timing?.setField("initialWeaveableKnops", initialWeaveableKnops.length); - assertRequestedTargetsAreWeaveable( - targets, - initialWeaveableKnops, - ); + const payloadBatchCandidates = shouldAttemptPayloadBatch + ? await timeOptional( + timing, + "prepare.loadPayloadBatchCandidates", + () => + loadWeaveableKnopCandidates( + workspaceRoot, + localPathPolicy, + meshState.meshBase, + meshState.currentMeshInventoryTurtle, + requestedDesignatorPaths, + targetByDesignatorPath, + undefined, + timing, + "prepare.loadPayloadBatchCandidates", + { includeSettledPayloadTargets: true }, + ), + ) + : []; + timing?.setField("payloadBatchCandidates", payloadBatchCandidates.length); + if (inputSnapshot !== undefined) { + await timeOptional( + timing, + "prepare.snapshotPayloadInputs.verify", + () => inputSnapshot.verify(), + ); + await inputSnapshotVerification?.afterVerifiedCapture?.(); + } + const configCandidateKnops = payloadBatchCandidates.length > 0 + ? payloadBatchCandidates + : initialWeaveableKnops; const targetMetadataTurtleByDesignatorPath = new Map( - initialWeaveableKnops.map((candidate) => [ + configCandidateKnops.map((candidate) => [ candidate.designatorPath, candidate.currentKnopMetadataTurtle, ]), @@ -148,6 +220,52 @@ export async function prepareVersionExecution( const meshEffectiveConfig = await effectiveConfigProvider .configForMeshScope(); + if ( + payloadBatchCandidates.length > 0 && + isExplicitPayloadBatch( + meshState.meshBase, + payloadBatchCandidates, + targetByDesignatorPath, + ) + ) { + assertRequestedTargetsAreWeaveable(targets, payloadBatchCandidates); + const batchPlan = await timeOptional( + timing, + "prepare.planPayloadBatch", + () => + planExplicitPayloadBatchVersion( + meshState, + payloadBatchCandidates, + targetByDesignatorPath, + meshEffectiveConfig, + effectiveConfigProvider, + overwriteExistingState, + timing, + ), + ); + for ( + const [index, designatorPath] of batchPlan.versionedDesignatorPaths + .entries() + ) { + onProgress?.({ + designatorPath, + completed: index + 1, + total: batchPlan.versionedDesignatorPaths.length, + percent: batchPlan.versionedDesignatorPaths.length === 0 + ? 100 + : Math.round( + ((index + 1) / batchPlan.versionedDesignatorPaths.length) * 100, + ), + }); + } + return { meshState, plan: batchPlan }; + } + + assertRequestedTargetsAreWeaveable( + targets, + initialWeaveableKnops, + ); + if (initialWeaveableKnops.length === 0) { if (targets.length === 0) { const supportHistoryPolicies = supportHistoryPoliciesFromEffectiveConfig( @@ -335,6 +453,418 @@ export async function prepareVersionExecution( }; } +interface PayloadBatchWorkingFileSnapshot { + verify(): Promise; +} + +async function createPayloadBatchWorkingFileSnapshot( + options: { + workspaceRoot: string; + localPathPolicy: OperationalLocalPathPolicy; + meshBase: string; + requestedDesignatorPaths: readonly string[]; + }, +): Promise { + const entryByAbsolutePath = new Map< + string, + { displayPath: string; digest: string } + >(); + + for (const designatorPath of options.requestedDesignatorPaths) { + const inventoryPath = join( + options.workspaceRoot, + `${toKnopPath(designatorPath)}/_inventory/inventory.ttl`, + ); + let inventoryTurtle: string; + try { + inventoryTurtle = await Deno.readTextFile(inventoryPath); + } catch (error) { + if (error instanceof Deno.errors.NotFound) { + continue; + } + throw error; + } + + const payloadArtifact = resolvePayloadArtifactInventoryState( + options.meshBase, + inventoryTurtle, + designatorPath, + { + parseErrorMessage: + `Could not parse the current Knop inventory while resolving the payload artifact for ${designatorPath}.`, + missingWorkingFileMessage: + `Could not resolve the working payload file for ${designatorPath}.`, + }, + ); + if (payloadArtifact === undefined) { + continue; + } + + let absolutePath: string; + try { + absolutePath = payloadArtifact.repositorySourceFloatingLocator + ? await resolveRepositorySourceFloatingLocalPath( + options.localPathPolicy, + payloadArtifact.repositorySourceFloatingLocator, + ) + : resolveAllowedLocalPath( + options.localPathPolicy, + "workingLocalRelativePath", + payloadArtifact.workingLocalRelativePath, + ); + } catch (error) { + if (error instanceof LocalPathAccessError) { + throw new WeaveRuntimeError( + `Working payload file for ${designatorPath} is outside the allowed local-path boundary: ${payloadArtifact.workingLocalRelativePath}`, + ); + } + throw error; + } + + if (entryByAbsolutePath.has(absolutePath)) { + continue; + } + + const displayPath = formatSnapshotDisplayPath( + options.localPathPolicy, + absolutePath, + payloadArtifact.workingLocalRelativePath, + ); + entryByAbsolutePath.set(absolutePath, { + displayPath, + digest: await hashPayloadSnapshotFile( + absolutePath, + displayPath, + designatorPath, + payloadArtifact.workingLocalRelativePath, + "initial", + ), + }); + } + + const entries = [...entryByAbsolutePath.entries()].map(( + [absolutePath, entry], + ) => ({ + absolutePath, + displayPath: entry.displayPath, + digest: entry.digest, + })); + + return { + async verify() { + for (const entry of entries) { + const digest = await hashPayloadSnapshotFile( + entry.absolutePath, + entry.displayPath, + undefined, + undefined, + "verify", + ); + if (digest !== entry.digest) { + throw new WeaveInputError( + `Input file changed during multi-target payload capture: ${entry.displayPath}`, + ); + } + } + }, + }; +} + +async function hashPayloadSnapshotFile( + absolutePath: string, + displayPath: string, + designatorPath: string | undefined, + workingLocalRelativePath: string | undefined, + phase: "initial" | "verify", +): Promise { + let bytes: Uint8Array; + try { + bytes = await Deno.readFile(absolutePath); + } catch (error) { + if (error instanceof Deno.errors.NotFound) { + if (phase === "initial" && designatorPath && workingLocalRelativePath) { + throw new WeaveRuntimeError( + `Workspace is missing the working payload file for ${designatorPath}: ${workingLocalRelativePath}`, + ); + } + throw new WeaveInputError( + `Input file changed during multi-target payload capture: ${displayPath}`, + ); + } + throw error; + } + + const digestInput = new ArrayBuffer(bytes.byteLength); + new Uint8Array(digestInput).set(bytes); + const digest = await crypto.subtle.digest("SHA-256", digestInput); + return [...new Uint8Array(digest)].map((byte) => + byte.toString(16).padStart(2, "0") + ).join(""); +} + +function formatSnapshotDisplayPath( + localPathPolicy: OperationalLocalPathPolicy, + absolutePath: string, + fallbackPath: string, +): string { + const workspaceRelativePath = relative( + localPathPolicy.workspaceRoot, + absolutePath, + ).replaceAll("\\", "/"); + if ( + workspaceRelativePath.length > 0 && + workspaceRelativePath !== ".." && + !workspaceRelativePath.startsWith("../") && + !workspaceRelativePath.startsWith("/") + ) { + return workspaceRelativePath; + } + return fallbackPath; +} + +function shouldAttemptExplicitPayloadBatch( + targets: readonly NormalizedVersionTargetSpec[], + overwriteExistingState: boolean, +): boolean { + return !overwriteExistingState && targets.length > 1 && + targets.every((target) => !target.recursive); +} + +function isExplicitPayloadBatch( + meshBase: string, + candidates: readonly WeaveableKnopCandidate[], + targetByDesignatorPath: ReadonlyMap< + string, + NormalizedVersionTargetSpec | undefined + >, +): boolean { + return candidates.length > 0 && candidates.every((candidate) => { + const slice = detectPendingWeaveSlice( + meshBase, + candidate.designatorPath, + candidate.currentKnopInventoryTurtle, + targetByDesignatorPath.get(candidate.designatorPath), + ); + return slice === "firstPayloadWeave" || slice === "laterPayloadWeave" || + (slice === undefined && + targetByDesignatorPath.get(candidate.designatorPath) !== undefined && + candidate.payloadArtifact?.currentArtifactHistoryPath !== undefined); + }); +} + +async function planExplicitPayloadBatchVersion( + meshState: MeshState, + candidates: readonly WeaveableKnopCandidate[], + targetByDesignatorPath: ReadonlyMap< + string, + NormalizedVersionTargetSpec | undefined + >, + meshEffectiveConfig: EffectiveConfig, + effectiveConfigProvider: EffectiveConfigProvider, + overwriteExistingState: boolean, + timing?: RuntimeTiming, +): Promise { + const orderedCandidates = [...candidates].sort((left, right) => + left.designatorPath.localeCompare(right.designatorPath) + ); + const policies = await resolvePayloadBatchPolicies( + meshState.meshBase, + orderedCandidates, + meshEffectiveConfig, + effectiveConfigProvider, + ); + return timeOptionalSync( + timing, + "prepare.planPayloadBatch.planVersion", + () => + planVersion({ + request: { + targets: orderedCandidates.flatMap((candidate) => { + const target = targetByDesignatorPath.get(candidate.designatorPath); + return target === undefined ? [] : [{ ...target.source }]; + }), + ...(overwriteExistingState ? { overwriteExistingState } : {}), + }, + meshBase: meshState.meshBase, + currentMeshInventoryTurtle: meshState.currentMeshInventoryTurtle, + currentMeshMetadataTurtle: meshState.currentMeshMetadataTurtle, + weaveableKnops: orderedCandidates, + supportHistoryPolicies: policies.supportHistoryPolicies, + namingPolicies: policies.namingPolicies, + resourcePageGenerationConfig: policies.resourcePageGenerationConfig, + resourcePageGenerationPolicies: policies.resourcePageGenerationPolicies, + }), + ); +} + +async function resolvePayloadBatchPolicies( + meshBase: string, + orderedCandidates: readonly WeaveableKnopCandidate[], + meshEffectiveConfig: EffectiveConfig, + effectiveConfigProvider: EffectiveConfigProvider, +): Promise<{ + supportHistoryPolicies: ReturnType< + typeof supportHistoryPoliciesFromScopedEffectiveConfigs + >; + namingPolicies: ReturnType; + resourcePageGenerationConfig: ReturnType< + typeof resourcePageGenerationConfigFromScopedEffectiveConfigs + >; + resourcePageGenerationPolicies: ReturnType< + typeof resourcePageGenerationPoliciesFromScopedEffectiveConfigs + >; +}> { + let first: + | { + designatorPath: string; + supportHistoryPolicies: ReturnType< + typeof supportHistoryPoliciesFromScopedEffectiveConfigs + >; + namingPolicies: ReturnType; + resourcePageGenerationConfig: ReturnType< + typeof resourcePageGenerationConfigFromScopedEffectiveConfigs + >; + resourcePageGenerationPolicies: ReturnType< + typeof resourcePageGenerationPoliciesFromScopedEffectiveConfigs + >; + comparisonKey: string; + } + | undefined; + + for (const candidate of orderedCandidates) { + const targetEffectiveConfig = await effectiveConfigProvider + .configForTarget(candidate.designatorPath); + const supportHistoryPolicies = + supportHistoryPoliciesFromScopedEffectiveConfigs( + meshEffectiveConfig, + targetEffectiveConfig, + ); + const namingPolicies = namingPoliciesFromEffectiveConfig( + targetEffectiveConfig, + ); + const resourcePageGenerationConfig = + resourcePageGenerationConfigFromScopedEffectiveConfigs( + meshEffectiveConfig, + targetEffectiveConfig, + ); + const resourcePageGenerationPolicies = + resourcePageGenerationPoliciesFromScopedEffectiveConfigs( + meshEffectiveConfig, + targetEffectiveConfig, + ); + const comparisonKey = JSON.stringify({ + supportHistoryPolicies, + namingPolicies, + resourcePageGenerationConfig: snapshotResourcePageGenerationConfig( + meshBase, + resourcePageGenerationConfig, + orderedCandidates, + ), + resourcePageGenerationPolicies, + }); + + if (first === undefined) { + first = { + designatorPath: candidate.designatorPath, + supportHistoryPolicies, + namingPolicies, + resourcePageGenerationConfig, + resourcePageGenerationPolicies, + comparisonKey, + }; + continue; + } + if (comparisonKey !== first.comparisonKey) { + throw new WeaveInputError( + `Multi-target payload weave requires consistent target-scoped planning policies; ${candidate.designatorPath} differs from ${first.designatorPath}.`, + ); + } + } + + if (first === undefined) { + throw new WeaveInputError( + "Multi-target payload weave requires at least one payload target.", + ); + } + + return { + supportHistoryPolicies: first.supportHistoryPolicies, + namingPolicies: first.namingPolicies, + resourcePageGenerationConfig: first.resourcePageGenerationConfig, + resourcePageGenerationPolicies: first.resourcePageGenerationPolicies, + }; +} + +function snapshotResourcePageGenerationConfig( + meshBase: string, + config: ResourcePageGenerationConfig, + candidates: readonly WeaveableKnopCandidate[], +): readonly { + artifactPath: string; + artifactRole: WeaveArtifactRole; + policy: ReturnType< + ResourcePageGenerationConfig[ + "resourcePageGenerationPolicyForArtifactRole" + ] + >; +}[] { + return payloadBatchResourcePagePolicyProbes(candidates).map((probe) => { + const artifactIri = new URL(probe.artifactPath, meshBase).href; + return { + ...probe, + policy: config.resourcePageGenerationPolicyForArtifactTarget?.({ + artifactIri, + artifactRole: probe.artifactRole, + }) ?? config.resourcePageGenerationPolicyForArtifactRole( + probe.artifactRole, + ), + }; + }); +} + +function payloadBatchResourcePagePolicyProbes( + candidates: readonly WeaveableKnopCandidate[], +): readonly { artifactPath: string; artifactRole: WeaveArtifactRole }[] { + const probes = new Map< + string, + { artifactPath: string; artifactRole: WeaveArtifactRole } + >(); + const add = ( + artifactPath: string, + artifactRole: WeaveArtifactRole, + ) => { + probes.set(`${artifactRole} ${artifactPath}`, { + artifactPath, + artifactRole, + }); + }; + + for (const candidate of candidates) { + const knopPath = toKnopPath(candidate.designatorPath); + add(`${knopPath}/_meta`, "knopMetadata"); + add(`${knopPath}/_inventory`, "knopInventory"); + + if (candidate.payloadArtifact !== undefined) { + add(candidate.designatorPath, "payload"); + } + if (candidate.referenceCatalogArtifact !== undefined) { + add(`${knopPath}/_references`, "referenceCatalog"); + } + if (candidate.resourcePageDefinitionArtifact !== undefined) { + add( + candidate.resourcePageDefinitionArtifact.artifactPath, + "resourcePageDefinition", + ); + } + } + + return [...probes.values()].sort((left, right) => + `${left.artifactRole} ${left.artifactPath}`.localeCompare( + `${right.artifactRole} ${right.artifactPath}`, + ) + ); +} + export async function writePreparedVersion( meshRoot: string, localPathPolicy: OperationalLocalPathPolicy, diff --git a/src/runtime/weave/weave.ts b/src/runtime/weave/weave.ts index cb8b382..663faf5 100644 --- a/src/runtime/weave/weave.ts +++ b/src/runtime/weave/weave.ts @@ -31,6 +31,7 @@ import { } from "./request_normalization.ts"; import { generatePreparedPages } from "./page_generation.ts"; import { + type InputSnapshotVerificationHooks, prepareVersionExecution, validateVersionPlanRdf, writePreparedVersion, @@ -49,6 +50,7 @@ export interface ExecuteVersionOptions { request?: VersionRequest; historyTrackingPolicyOverride?: HistoryTrackingPolicy; onProgress?: WeaveProgressHandler; + inputSnapshotVerification?: InputSnapshotVerificationHooks; } export interface ExecuteGenerateOptions { @@ -58,6 +60,7 @@ export interface ExecuteGenerateOptions { now?: () => Date; includeSemanticFlowMetadata?: boolean; historyTrackingPolicyOverride?: HistoryTrackingPolicy; + updateTimestampOnlyPages?: boolean; } export interface ExecuteWeaveOptions { @@ -70,6 +73,8 @@ export interface ExecuteWeaveOptions { onProgress?: WeaveProgressHandler; validateBefore?: boolean; validateAfter?: boolean; + inputSnapshotVerification?: InputSnapshotVerificationHooks; + updateTimestampOnlyPages?: boolean; } export type ValidateScope = "mesh" | "publication"; @@ -232,6 +237,7 @@ export async function executeVersion( options.historyTrackingPolicyOverride, options.onProgress, timing, + options.inputSnapshotVerification, ); const result = await writePreparedVersion( meshRoot, @@ -286,6 +292,7 @@ export async function executeGenerate( now: options.now, includeSemanticFlowMetadata: options.includeSemanticFlowMetadata ?? false, historyTrackingPolicyOverride: options.historyTrackingPolicyOverride, + updateTimestampOnlyPages: options.updateTimestampOnlyPages === true, timing, }); timing.setField( @@ -353,6 +360,7 @@ export async function executeWeave( options.historyTrackingPolicyOverride, options.onProgress, timing, + options.inputSnapshotVerification, ); timing.timeSync( "version.validateRdf", @@ -384,6 +392,7 @@ export async function executeWeave( now: options.now, includeSemanticFlowMetadata: false, historyTrackingPolicyOverride: options.historyTrackingPolicyOverride, + updateTimestampOnlyPages: options.updateTimestampOnlyPages === true, timing, phasePrefix: "generate", }), diff --git a/tests/e2e/weave_cli_test.ts b/tests/e2e/weave_cli_test.ts index dc9f0e4..b3187f8 100644 --- a/tests/e2e/weave_cli_test.ts +++ b/tests/e2e/weave_cli_test.ts @@ -1,4 +1,10 @@ -import { assert, assertEquals, assertRejects } from "@std/assert"; +import { + assert, + assertEquals, + assertFalse, + assertRejects, + assertStringIncludes, +} from "@std/assert"; import { fromFileUrl, join, relative, toFileUrl } from "@std/path"; import { compareRdfContent } from "../../dependencies/github.com/spectacular-voyage/accord/src/checker/compare_rdf.ts"; import { @@ -565,6 +571,94 @@ Deno.test("weave generate succeeds as a black-box CLI run", async () => { ); }); +Deno.test("weave --generated-at stamps every generated page canonically", async () => { + const workspaceRoot = await createTestTmpDir( + "weave-e2e-generated-at-weave-", + ); + await materializeMeshAliceBioBranch("04-alice-knop-created", workspaceRoot); + + const output = await runCliCommand([ + "--generated-at", + "2026-07-06T08:34:56-04:00", + ], workspaceRoot); + const stdout = new TextDecoder().decode(output.stdout); + const stderr = new TextDecoder().decode(output.stderr); + + assert(output.success, stderr); + const generatedPagePaths = stdout.split(/\r?\n/) + .map((line) => line.trim()) + .filter((line) => line.endsWith(".html")); + assert(generatedPagePaths.length > 0); + for (const path of generatedPagePaths) { + const page = await Deno.readTextFile(join(workspaceRoot, path)); + assertStringIncludes(page, "2026-07-06T12:34:56.000Z"); + assertFalse(page.includes("2026-07-06T08:34:56-04:00"), path); + } +}); + +Deno.test("weave generate --generated-at makes page output byte-identical", async () => { + const leftRoot = await createTestTmpDir( + "weave-e2e-generated-at-generate-left-", + ); + const rightRoot = await createTestTmpDir( + "weave-e2e-generated-at-generate-right-", + ); + await materializeMeshAliceBioBranch("10-alice-bio-updated", leftRoot); + await materializeMeshAliceBioBranch("10-alice-bio-updated", rightRoot); + const args = [ + "generate", + "--target", + "designatorPath=alice/data", + "--generated-at", + "2026-07-06T15:04:05+02:30", + ]; + + const leftOutput = await runCliCommand(args, leftRoot); + const rightOutput = await runCliCommand(args, rightRoot); + + assert(leftOutput.success, new TextDecoder().decode(leftOutput.stderr)); + assert(rightOutput.success, new TextDecoder().decode(rightOutput.stderr)); + const leftFiles = await listRelativeFiles(leftRoot, ".weave/"); + const rightFiles = await listRelativeFiles(rightRoot, ".weave/"); + assertEquals(leftFiles, rightFiles); + for (const path of leftFiles) { + assertEquals( + [...await Deno.readFile(join(leftRoot, path))], + [...await Deno.readFile(join(rightRoot, path))], + `workspace file differed: ${path}`, + ); + } + const generatedPage = await Deno.readTextFile( + join(leftRoot, "alice/data/index.html"), + ); + assertStringIncludes(generatedPage, "2026-07-06T12:34:05.000Z"); +}); + +Deno.test("weave --generated-at rejects invalid instants before planning", async () => { + const workspaceRoot = await createTestTmpDir( + "weave-e2e-generated-at-invalid-", + ); + await materializeMeshAliceBioBranch("04-alice-knop-created", workspaceRoot); + const before = await snapshotRelativeFiles(workspaceRoot, ".weave/"); + + const output = await runCliCommand([ + "--generated-at", + "2026-02-31T00:00:00Z", + ], workspaceRoot); + const stderr = new TextDecoder().decode(output.stderr); + + assertFalse(output.success, stderr); + assertEquals(output.code, 1); + assertStringIncludes( + stderr, + "weave --generated-at must be an ISO 8601 instant", + ); + assertRelativeSnapshotsEqual( + await snapshotRelativeFiles(workspaceRoot, ".weave/"), + before, + ); +}); + Deno.test("weave generate reports timestamp-only skips as a black-box CLI run", async () => { const workspaceRoot = await createTestTmpDir( "weave-e2e-generate-timestamp-skip-", @@ -1268,6 +1362,33 @@ async function listRelativeFiles( return paths.sort(); } +async function snapshotRelativeFiles( + root: string, + excludedPrefix: string, +): Promise> { + const snapshot = new Map(); + for (const path of await listRelativeFiles(root, excludedPrefix)) { + snapshot.set(path, await Deno.readFile(join(root, path))); + } + return snapshot; +} + +function assertRelativeSnapshotsEqual( + actual: ReadonlyMap, + expected: ReadonlyMap, +): void { + assertEquals([...actual.keys()].sort(), [...expected.keys()].sort()); + for (const [path, actualBytes] of actual) { + const expectedBytes = expected.get(path); + assert(expectedBytes, `expected snapshot to include ${path}`); + assertEquals( + [...actualBytes], + [...expectedBytes], + `workspace file differed: ${path}`, + ); + } +} + async function* walkFiles(root: string): AsyncGenerator { for await (const entry of Deno.readDir(root)) { const path = join(root, entry.name); diff --git a/tests/integration/weave_test.ts b/tests/integration/weave_test.ts index 8b075e4..c4c9466 100644 --- a/tests/integration/weave_test.ts +++ b/tests/integration/weave_test.ts @@ -5,7 +5,7 @@ import { assertRejects, assertStringIncludes, } from "@std/assert"; -import { join } from "@std/path"; +import { dirname, join } from "@std/path"; import { Parser, type Quad, type Term } from "n3"; import { compareRdfContent } from "../../dependencies/github.com/spectacular-voyage/accord/src/checker/compare_rdf.ts"; import { WeaveInputError } from "../../src/core/weave/weave.ts"; @@ -1705,6 +1705,473 @@ Deno.test("executeWeave materializes the second alice bio payload weave slice", ); }); +Deno.test("executeWeave advances a later ordinal payload without rewriting prior snapshots", async () => { + const workspaceRoot = await createTestTmpDir( + "weave-weave-later-payload-", + ); + const meshBase = "https://semantic-flow.github.io/stagecraft-test/"; + const designatorPath = "projections/contracts/inn-ambush-contract-context"; + const knopPath = `${designatorPath}/_knop`; + const historyPath = `${designatorPath}/_history001`; + const snapshotPaths = [1, 2, 3].map((ordinal) => + `${historyPath}/_s000${ordinal}/ttl/inn-ambush-contract-context.ttl` + ); + const priorSnapshotContents = new Map( + snapshotPaths.map((path, index) => [ + path, + `@base <${meshBase}> . +@prefix dcterms: . + +<${designatorPath}> dcterms:title "Inn Ambush Contract Context v${index + 1}" . +`, + ]), + ); + const workingPayload = `@base <${meshBase}> . +@prefix dcterms: . + +<${designatorPath}> dcterms:title "Inn Ambush Contract Context v4" . +`; + + await writeTextFileEnsuringDir( + join(workspaceRoot, "_mesh/_meta/meta.ttl"), + `@base <${meshBase}> . +@prefix sflo: . +@prefix xsd: . + +<_mesh> a sflo:SemanticMesh ; + sflo:meshBase "${meshBase}"^^xsd:anyURI ; + sflo:hasMeshMetadata <_mesh/_meta> ; + sflo:hasMeshInventory <_mesh/_inventory> . + +<_mesh/_meta> a sflo:MeshMetadata, sflo:DigitalArtifact, sflo:RdfDocument ; + sflo:hasWorkingLocatedFile <_mesh/_meta/meta.ttl> . + +<_mesh/_inventory> a sflo:MeshInventory, sflo:DigitalArtifact, sflo:RdfDocument ; + sflo:hasWorkingLocatedFile <_mesh/_inventory/inventory.ttl> . +`, + ); + await writeTextFileEnsuringDir( + join(workspaceRoot, "_mesh/_inventory/inventory.ttl"), + `@base <${meshBase}> . +@prefix sflo: . +@prefix xsd: . + +<_mesh> a sflo:SemanticMesh ; + sflo:meshBase "${meshBase}"^^xsd:anyURI ; + sflo:hasMeshMetadata <_mesh/_meta> ; + sflo:hasMeshInventory <_mesh/_inventory> ; + sflo:hasKnop <${knopPath}> . + +<_mesh/_inventory> a sflo:MeshInventory, sflo:DigitalArtifact, sflo:RdfDocument ; + sflo:hasWorkingLocatedFile <_mesh/_inventory/inventory.ttl> . + +<${knopPath}> a sflo:Knop ; + sflo:hasWorkingKnopInventoryFile <${knopPath}/_inventory/inventory.ttl> . +`, + ); + await writeTextFileEnsuringDir( + join(workspaceRoot, `${knopPath}/_meta/meta.ttl`), + `@base <${meshBase}> . +@prefix sflo: . + +<${knopPath}> a sflo:Knop ; + sflo:designatorPath "${designatorPath}" ; + sflo:hasWorkingKnopInventoryFile <${knopPath}/_inventory/inventory.ttl> . +`, + ); + await writeTextFileEnsuringDir( + join(workspaceRoot, `${knopPath}/_inventory/inventory.ttl`), + `@base <${meshBase}> . +@prefix sflo: . +@prefix xsd: . + +<${knopPath}> a sflo:Knop ; + sflo:hasKnopMetadata <${knopPath}/_meta> ; + sflo:hasKnopInventory <${knopPath}/_inventory> ; + sflo:hasWorkingKnopInventoryFile <${knopPath}/_inventory/inventory.ttl> ; + sflo:hasPayloadArtifact <${designatorPath}> ; + sflo:hasKnopSourceRegistry <${knopPath}/_sources> ; + sflo:hasReferenceCatalog <${knopPath}/_references> ; + sflo:hasResourcePage <${knopPath}/index.html> . + +<${designatorPath}> a sflo:PayloadArtifact, sflo:DigitalArtifact, sflo:RdfDocument ; + sflo:hasArtifactHistory <${historyPath}> ; + sflo:currentArtifactHistory <${historyPath}> ; + sflo:nextHistoryOrdinal "2"^^xsd:nonNegativeInteger ; + sflo:hasWorkingLocatedFile <${designatorPath}.ttl> ; + sflo:hasResourcePage <${designatorPath}/index.html> . + +<${historyPath}> a sflo:ArtifactHistory ; + sflo:historyOrdinal "1"^^xsd:nonNegativeInteger ; + sflo:hasHistoricalState <${historyPath}/_s0001> ; + sflo:hasHistoricalState <${historyPath}/_s0002> ; + sflo:hasHistoricalState <${historyPath}/_s0003> ; + sflo:latestHistoricalState <${historyPath}/_s0003> ; + sflo:nextStateOrdinal "4"^^xsd:nonNegativeInteger ; + sflo:hasResourcePage <${historyPath}/index.html> . + +<${historyPath}/_s0001> a sflo:HistoricalState ; + sflo:stateOrdinal "1"^^xsd:nonNegativeInteger ; + sflo:hasManifestation <${historyPath}/_s0001/ttl> ; + sflo:locatedFileForState <${historyPath}/_s0001/ttl/inn-ambush-contract-context.ttl> ; + sflo:hasResourcePage <${historyPath}/_s0001/index.html> . + +<${historyPath}/_s0001/ttl> a sflo:ArtifactManifestation, sflo:RdfDocument ; + sflo:locatedFileForManifestation <${historyPath}/_s0001/ttl/inn-ambush-contract-context.ttl> ; + sflo:hasResourcePage <${historyPath}/_s0001/ttl/index.html> . + +<${historyPath}/_s0002> a sflo:HistoricalState ; + sflo:stateOrdinal "2"^^xsd:nonNegativeInteger ; + sflo:previousHistoricalState <${historyPath}/_s0001> ; + sflo:hasManifestation <${historyPath}/_s0002/ttl> ; + sflo:locatedFileForState <${historyPath}/_s0002/ttl/inn-ambush-contract-context.ttl> ; + sflo:hasResourcePage <${historyPath}/_s0002/index.html> . + +<${historyPath}/_s0002/ttl> a sflo:ArtifactManifestation, sflo:RdfDocument ; + sflo:locatedFileForManifestation <${historyPath}/_s0002/ttl/inn-ambush-contract-context.ttl> ; + sflo:hasResourcePage <${historyPath}/_s0002/ttl/index.html> . + +<${historyPath}/_s0003> a sflo:HistoricalState ; + sflo:stateOrdinal "3"^^xsd:nonNegativeInteger ; + sflo:previousHistoricalState <${historyPath}/_s0002> ; + sflo:hasManifestation <${historyPath}/_s0003/ttl> ; + sflo:locatedFileForState <${historyPath}/_s0003/ttl/inn-ambush-contract-context.ttl> ; + sflo:hasResourcePage <${historyPath}/_s0003/index.html> . + +<${historyPath}/_s0003/ttl> a sflo:ArtifactManifestation, sflo:RdfDocument ; + sflo:locatedFileForManifestation <${historyPath}/_s0003/ttl/inn-ambush-contract-context.ttl> ; + sflo:hasResourcePage <${historyPath}/_s0003/ttl/index.html> . + +<${knopPath}/_meta> a sflo:KnopMetadata, sflo:DigitalArtifact, sflo:RdfDocument ; + sflo:hasWorkingLocatedFile <${knopPath}/_meta/meta.ttl> ; + sflo:hasResourcePage <${knopPath}/_meta/index.html> . + +<${knopPath}/_inventory> a sflo:KnopInventory, sflo:DigitalArtifact, sflo:RdfDocument ; + sflo:hasWorkingLocatedFile <${knopPath}/_inventory/inventory.ttl> ; + sflo:hasResourcePage <${knopPath}/_inventory/index.html> . + +<${knopPath}/_sources> a sflo:KnopSourceRegistry, sflo:DigitalArtifact, sflo:RdfDocument ; + sflo:hasWorkingLocatedFile <${knopPath}/_sources/sources.ttl> ; + sflo:hasResourcePage <${knopPath}/_sources/index.html> . + +<${knopPath}/_references> a sflo:ReferenceCatalog, sflo:DigitalArtifact, sflo:RdfDocument ; + sflo:hasWorkingLocatedFile <${knopPath}/_references/references.ttl> ; + sflo:hasResourcePage <${knopPath}/_references/index.html> . + +<${designatorPath}.ttl> a sflo:LocatedFile, sflo:RdfDocument . +`, + ); + await writeTextFileEnsuringDir( + join(workspaceRoot, `${designatorPath}.ttl`), + workingPayload, + ); + await writeTextFileEnsuringDir( + join(workspaceRoot, `${knopPath}/_sources/sources.ttl`), + `@base <${meshBase}> . +@prefix sflo: . + +<${knopPath}/_sources> a sflo:KnopSourceRegistry . +`, + ); + await writeTextFileEnsuringDir( + join(workspaceRoot, `${knopPath}/_references/references.ttl`), + `@base <${meshBase}> . +@prefix sflo: . + +<${knopPath}/_references> a sflo:ReferenceCatalog . +`, + ); + for (const [path, contents] of priorSnapshotContents) { + await writeTextFileEnsuringDir(join(workspaceRoot, path), contents); + } + + const result = await executeWeave({ + meshRoot: workspaceRoot, + request: { + targets: [{ designatorPath }], + }, + }); + + assertEquals(result.wovenDesignatorPaths, [designatorPath]); + assert( + result.createdPaths.includes( + `${historyPath}/_s0004/ttl/inn-ambush-contract-context.ttl`, + ), + ); + assertFalse( + result.createdPaths.some((path) => + path.startsWith(`${knopPath}/_inventory/_history001/`) + ), + ); + assertEquals( + await Deno.readTextFile( + join( + workspaceRoot, + `${historyPath}/_s0004/ttl/inn-ambush-contract-context.ttl`, + ), + ), + workingPayload, + ); + for (const [path, contents] of priorSnapshotContents) { + assertEquals(await Deno.readTextFile(join(workspaceRoot, path)), contents); + } + const updatedInventory = await Deno.readTextFile( + join(workspaceRoot, `${knopPath}/_inventory/inventory.ttl`), + ); + assertStringIncludes( + updatedInventory, + `sflo:latestHistoricalState <${historyPath}/_s0004> ;`, + ); + assertStringIncludes( + updatedInventory, + `sflo:nextStateOrdinal "5"^^xsd:nonNegativeInteger ;`, + ); + assertFalse(updatedInventory.includes(`${knopPath}/_inventory/_history001`)); +}); + +Deno.test("executeWeave advances temporal later payload targets as one deterministic batch", async () => { + const batchRoot = await createTestTmpDir( + "weave-weave-temporal-payload-batch-", + ); + const sequentialRoot = await createTestTmpDir( + "weave-weave-temporal-payload-sequential-", + ); + const deterministicRoot = await createTestTmpDir( + "weave-weave-temporal-payload-deterministic-", + ); + const request = { + targets: temporalLaterPayloadTargets.map((target) => ({ + designatorPath: target.designatorPath, + })), + }; + const fixedNow = () => new Date("2026-07-06T12:00:00.000Z"); + + await materializeTemporalLaterPayloadWorkspace(batchRoot); + await materializeTemporalLaterPayloadWorkspace(sequentialRoot); + await materializeTemporalLaterPayloadWorkspace(deterministicRoot); + const priorSnapshotPaths = temporalLaterPayloadTargets.flatMap((target) => + Array.from( + { length: target.latestOrdinal }, + (_, index) => temporalLaterPayloadSnapshotPath(target, index + 1), + ) + ); + const priorSnapshots = await snapshotWorkspacePaths( + batchRoot, + priorSnapshotPaths, + ); + + const batchResult = await executeWeave({ + meshRoot: batchRoot, + request, + now: fixedNow, + }); + for (const target of temporalLaterPayloadTargets) { + await executeWeave({ + meshRoot: sequentialRoot, + request: { targets: [{ designatorPath: target.designatorPath }] }, + now: fixedNow, + }); + } + await executeWeave({ + meshRoot: deterministicRoot, + request, + now: fixedNow, + }); + + assertEquals( + batchResult.wovenDesignatorPaths, + temporalLaterPayloadTargets.map((target) => target.designatorPath), + ); + for (const target of temporalLaterPayloadTargets) { + const nextStatePath = `${target.designatorPath}/_history001/${ + toTemporalStateSegment(target.latestOrdinal + 1) + }`; + assert( + batchResult.createdPaths.includes( + `${nextStatePath}/ttl/${target.fileName}`, + ), + ); + const inventory = await Deno.readTextFile( + join( + batchRoot, + `${target.designatorPath}/_knop/_inventory/inventory.ttl`, + ), + ); + assertStringIncludes( + inventory, + `sflo:latestHistoricalState <${nextStatePath}> ;`, + ); + assertStringIncludes( + inventory, + `sflo:nextStateOrdinal "${ + target.latestOrdinal + 2 + }"^^xsd:nonNegativeInteger ;`, + ); + } + assertWorkspaceSnapshotsEqual( + await snapshotWorkspacePaths(batchRoot, priorSnapshotPaths), + priorSnapshots, + ); + assertWorkspaceSnapshotsEqual( + await snapshotWorkspaceFiles(batchRoot), + await snapshotWorkspaceFiles(sequentialRoot), + ); + assertWorkspaceSnapshotsEqual( + await snapshotWorkspaceFiles(batchRoot), + await snapshotWorkspaceFiles(deterministicRoot), + ); + + const beforeRerun = await snapshotWorkspaceFiles(batchRoot); + const rerun = await executeWeave({ + meshRoot: batchRoot, + request, + now: fixedNow, + }); + + assertEquals(rerun.wovenDesignatorPaths, []); + assertWorkspaceSnapshotsEqual( + await snapshotWorkspaceFiles(batchRoot), + beforeRerun, + ); +}); + +Deno.test("executeWeave refuses a temporal payload batch when one target is incoherent and writes nothing", async () => { + const workspaceRoot = await createTestTmpDir( + "weave-weave-temporal-payload-batch-bad-target-", + ); + await materializeTemporalLaterPayloadWorkspace(workspaceRoot); + const badTarget = temporalLaterPayloadTargets[1]!; + const inventoryPath = join( + workspaceRoot, + `${badTarget.designatorPath}/_knop/_inventory/inventory.ttl`, + ); + await replaceFileText( + inventoryPath, + ` sflo:latestHistoricalState <${badTarget.designatorPath}/_history001/${ + toTemporalStateSegment(badTarget.latestOrdinal) + }> ;\n`, + "", + ); + const before = await snapshotWorkspaceFiles(workspaceRoot); + + await assertRejects( + () => + executeWeave({ + meshRoot: workspaceRoot, + request: { + targets: temporalLaterPayloadTargets.map((target) => ({ + designatorPath: target.designatorPath, + })), + }, + now: () => new Date("2026-07-06T12:00:00.000Z"), + }), + WeaveInputError, + `Target ${badTarget.designatorPath}: Payload history ${badTarget.designatorPath}/_history001 for ${badTarget.designatorPath} is missing sflo:latestHistoricalState.`, + ); + assertWorkspaceSnapshotsEqual( + await snapshotWorkspaceFiles(workspaceRoot), + before, + ); +}); + +Deno.test("executeWeave refuses a temporal payload batch when a working payload changes during capture", async () => { + const workspaceRoot = await createTestTmpDir( + "weave-weave-temporal-payload-batch-capture-change-", + ); + await materializeTemporalLaterPayloadWorkspace(workspaceRoot); + const changedTarget = temporalLaterPayloadTargets[0]!; + let afterConcurrentMutation: Map | undefined; + + await assertRejects( + () => + executeWeave({ + meshRoot: workspaceRoot, + request: { + targets: temporalLaterPayloadTargets.map((target) => ({ + designatorPath: target.designatorPath, + })), + }, + now: () => new Date("2026-07-06T12:00:00.000Z"), + inputSnapshotVerification: { + afterInitialHash: async () => { + await Deno.writeTextFile( + join(workspaceRoot, `${changedTarget.designatorPath}.ttl`), + temporalPayloadTurtle( + changedTarget.designatorPath, + changedTarget.label, + 99, + ), + ); + afterConcurrentMutation = await snapshotWorkspaceFiles( + workspaceRoot, + ); + }, + }, + }), + WeaveInputError, + `Input file changed during multi-target payload capture: ${changedTarget.designatorPath}.ttl`, + ); + assert(afterConcurrentMutation !== undefined); + assertWorkspaceSnapshotsEqual( + await snapshotWorkspaceFiles(workspaceRoot), + afterConcurrentMutation, + ); +}); + +Deno.test("executeWeave ignores working payload changes after temporal batch capture", async () => { + const workspaceRoot = await createTestTmpDir( + "weave-weave-temporal-payload-batch-post-capture-change-", + ); + const expectedRoot = await createTestTmpDir( + "weave-weave-temporal-payload-batch-post-capture-expected-", + ); + await materializeTemporalLaterPayloadWorkspace(workspaceRoot); + await materializeTemporalLaterPayloadWorkspace(expectedRoot); + const changedTarget = temporalLaterPayloadTargets[0]!; + const request = { + targets: temporalLaterPayloadTargets.map((target) => ({ + designatorPath: target.designatorPath, + })), + }; + const fixedNow = () => new Date("2026-07-06T12:00:00.000Z"); + + const actual = await executeWeave({ + meshRoot: workspaceRoot, + request, + now: fixedNow, + inputSnapshotVerification: { + afterVerifiedCapture: async () => { + await Deno.writeTextFile( + join(workspaceRoot, `${changedTarget.designatorPath}.ttl`), + temporalPayloadTurtle( + changedTarget.designatorPath, + changedTarget.label, + 99, + ), + ); + }, + }, + }); + const expected = await executeWeave({ + meshRoot: expectedRoot, + request, + now: fixedNow, + }); + const outputPaths = [ + ...new Set([ + ...actual.createdPaths, + ...actual.updatedPaths, + ]), + ].sort(); + + assertEquals(actual.createdPaths, expected.createdPaths); + assertEquals(actual.updatedPaths, expected.updatedPaths); + assertWorkspaceSnapshotsEqual( + await snapshotWorkspacePaths(workspaceRoot, outputPaths), + await snapshotWorkspacePaths(expectedRoot, outputPaths), + ); +}); + Deno.test("executeWeave materializes the extracted bob woven slice", async () => { const workspaceRoot = await createTestTmpDir("weave-weave-bob-extracted-"); await materializeMeshAliceBioBranch("12-bob-extracted", workspaceRoot); @@ -2592,6 +3059,311 @@ async function replaceAliceSidebarPageSource( ); } +async function writeTextFileEnsuringDir( + path: string, + contents: string, +): Promise { + await Deno.mkdir(dirname(path), { recursive: true }); + await Deno.writeTextFile(path, contents); +} + +interface TemporalLaterPayloadTarget { + designatorPath: string; + fileName: string; + latestOrdinal: number; + label: string; +} + +const temporalLaterPayloadTargets: readonly TemporalLaterPayloadTarget[] = [ + { + designatorPath: "projections/contracts/inn-ambush-contract-context", + fileName: "inn-ambush-contract-context.ttl", + latestOrdinal: 3, + label: "Inn Ambush Contract Context", + }, + { + designatorPath: "projections/contracts/inn-ambush-contract-shapes", + fileName: "inn-ambush-contract-shapes.ttl", + latestOrdinal: 3, + label: "Inn Ambush Contract Shapes", + }, + { + designatorPath: "world/states/inn-ambush-plan-b-state", + fileName: "inn-ambush-plan-b-state.ttl", + latestOrdinal: 2, + label: "Inn Ambush Plan B State", + }, +]; + +const temporalLaterPayloadMeshBase = + "https://semantic-flow.github.io/stagecraft-test/"; + +async function materializeTemporalLaterPayloadWorkspace( + workspaceRoot: string, + targets = temporalLaterPayloadTargets, +): Promise { + await writeTextFileEnsuringDir( + join(workspaceRoot, "_mesh/_meta/meta.ttl"), + `@base <${temporalLaterPayloadMeshBase}> . +@prefix sflo: . +@prefix xsd: . + +<_mesh> a sflo:SemanticMesh ; + sflo:meshBase "${temporalLaterPayloadMeshBase}"^^xsd:anyURI ; + sflo:hasMeshMetadata <_mesh/_meta> ; + sflo:hasMeshInventory <_mesh/_inventory> . + +<_mesh/_meta> a sflo:MeshMetadata, sflo:DigitalArtifact, sflo:RdfDocument ; + sflo:hasWorkingLocatedFile <_mesh/_meta/meta.ttl> . + +<_mesh/_inventory> a sflo:MeshInventory, sflo:DigitalArtifact, sflo:RdfDocument ; + sflo:hasWorkingLocatedFile <_mesh/_inventory/inventory.ttl> . +`, + ); + await writeTextFileEnsuringDir( + join(workspaceRoot, "_mesh/_inventory/inventory.ttl"), + `@base <${temporalLaterPayloadMeshBase}> . +@prefix sflo: . +@prefix xsd: . + +<_mesh> a sflo:SemanticMesh ; + sflo:meshBase "${temporalLaterPayloadMeshBase}"^^xsd:anyURI ; + sflo:hasMeshMetadata <_mesh/_meta> ; + sflo:hasMeshInventory <_mesh/_inventory> ; + ${ + targets.map((target) => `sflo:hasKnop <${target.designatorPath}/_knop>`) + .join(" ;\n ") + } . + +<_mesh/_inventory> a sflo:MeshInventory, sflo:DigitalArtifact, sflo:RdfDocument ; + sflo:hasWorkingLocatedFile <_mesh/_inventory/inventory.ttl> . + +${ + targets.map((target) => + `<${target.designatorPath}/_knop> a sflo:Knop ; + sflo:hasWorkingKnopInventoryFile <${target.designatorPath}/_knop/_inventory/inventory.ttl> .` + ).join("\n\n") + } +`, + ); + + for (const target of targets) { + await materializeTemporalLaterPayloadTarget(workspaceRoot, target); + } +} + +async function materializeTemporalLaterPayloadTarget( + workspaceRoot: string, + target: TemporalLaterPayloadTarget, +): Promise { + const { designatorPath, latestOrdinal, label } = target; + const knopPath = `${designatorPath}/_knop`; + await writeTextFileEnsuringDir( + join(workspaceRoot, `${knopPath}/_meta/meta.ttl`), + `@base <${temporalLaterPayloadMeshBase}> . +@prefix sflo: . + +<${knopPath}> a sflo:Knop ; + sflo:designatorPath "${designatorPath}" ; + sflo:hasWorkingKnopInventoryFile <${knopPath}/_inventory/inventory.ttl> . +`, + ); + await writeTextFileEnsuringDir( + join(workspaceRoot, `${knopPath}/_inventory/inventory.ttl`), + temporalLaterPayloadInventoryTurtle(target), + ); + await writeTextFileEnsuringDir( + join(workspaceRoot, `${designatorPath}.ttl`), + temporalPayloadTurtle(designatorPath, label, latestOrdinal + 1), + ); + await writeTextFileEnsuringDir( + join(workspaceRoot, `${knopPath}/_sources/sources.ttl`), + `@base <${temporalLaterPayloadMeshBase}> . +@prefix sflo: . + +<${knopPath}/_sources> a sflo:KnopSourceRegistry . +`, + ); + await writeTextFileEnsuringDir( + join(workspaceRoot, `${knopPath}/_references/references.ttl`), + `@base <${temporalLaterPayloadMeshBase}> . +@prefix sflo: . + +<${knopPath}/_references> a sflo:ReferenceCatalog . +`, + ); + for (let ordinal = 1; ordinal <= latestOrdinal; ordinal += 1) { + await writeTextFileEnsuringDir( + join( + workspaceRoot, + temporalLaterPayloadSnapshotPath(target, ordinal), + ), + temporalPayloadTurtle(designatorPath, label, ordinal), + ); + } +} + +function temporalLaterPayloadInventoryTurtle( + target: TemporalLaterPayloadTarget, +): string { + const { designatorPath, fileName, latestOrdinal } = target; + const knopPath = `${designatorPath}/_knop`; + const historyPath = `${designatorPath}/_history001`; + const stateBlocks = Array.from({ length: latestOrdinal }, (_, index) => { + const ordinal = index + 1; + const statePath = `${historyPath}/${toTemporalStateSegment(ordinal)}`; + const previous = ordinal === 1 ? "" : ` + sflo:previousHistoricalState <${historyPath}/${ + toTemporalStateSegment(ordinal - 1) + }> ;`; + return `<${statePath}> a sflo:HistoricalState ; + sflo:stateOrdinal "${ordinal}"^^xsd:nonNegativeInteger ;${previous} + sflo:hasManifestation <${statePath}/ttl> ; + sflo:locatedFileForState <${statePath}/ttl/${fileName}> ; + sflo:hasResourcePage <${statePath}/index.html> . + +<${statePath}/ttl> a sflo:ArtifactManifestation, sflo:RdfDocument ; + sflo:locatedFileForManifestation <${statePath}/ttl/${fileName}> ; + sflo:hasResourcePage <${statePath}/ttl/index.html> .`; + }).join("\n\n"); + const historyStates = Array.from( + { length: latestOrdinal }, + (_, index) => + ` sflo:hasHistoricalState <${historyPath}/${ + toTemporalStateSegment(index + 1) + }> ;`, + ).join("\n"); + + return `@base <${temporalLaterPayloadMeshBase}> . +@prefix sflo: . +@prefix xsd: . + +<${knopPath}> a sflo:Knop ; + sflo:hasKnopMetadata <${knopPath}/_meta> ; + sflo:hasKnopInventory <${knopPath}/_inventory> ; + sflo:hasWorkingKnopInventoryFile <${knopPath}/_inventory/inventory.ttl> ; + sflo:hasPayloadArtifact <${designatorPath}> ; + sflo:hasKnopSourceRegistry <${knopPath}/_sources> ; + sflo:hasReferenceCatalog <${knopPath}/_references> ; + sflo:hasResourcePage <${knopPath}/index.html> . + +<${designatorPath}> a sflo:PayloadArtifact, sflo:DigitalArtifact, sflo:RdfDocument ; + sflo:hasArtifactHistory <${historyPath}> ; + sflo:currentArtifactHistory <${historyPath}> ; + sflo:nextHistoryOrdinal "2"^^xsd:nonNegativeInteger ; + sflo:hasWorkingLocatedFile <${designatorPath}.ttl> ; + sflo:hasResourcePage <${designatorPath}/index.html> . + +<${historyPath}> a sflo:ArtifactHistory ; + sflo:historyOrdinal "1"^^xsd:nonNegativeInteger ; +${historyStates} + sflo:latestHistoricalState <${historyPath}/${ + toTemporalStateSegment(latestOrdinal) + }> ; + sflo:nextStateOrdinal "${latestOrdinal + 1}"^^xsd:nonNegativeInteger ; + sflo:hasResourcePage <${historyPath}/index.html> . + +${stateBlocks} + +<${knopPath}/_meta> a sflo:KnopMetadata, sflo:DigitalArtifact, sflo:RdfDocument ; + sflo:hasWorkingLocatedFile <${knopPath}/_meta/meta.ttl> ; + sflo:hasResourcePage <${knopPath}/_meta/index.html> . + +<${knopPath}/_inventory> a sflo:KnopInventory, sflo:DigitalArtifact, sflo:RdfDocument ; + sflo:hasWorkingLocatedFile <${knopPath}/_inventory/inventory.ttl> ; + sflo:hasResourcePage <${knopPath}/_inventory/index.html> . + +<${knopPath}/_sources> a sflo:KnopSourceRegistry, sflo:DigitalArtifact, sflo:RdfDocument ; + sflo:hasWorkingLocatedFile <${knopPath}/_sources/sources.ttl> ; + sflo:hasResourcePage <${knopPath}/_sources/index.html> . + +<${knopPath}/_references> a sflo:ReferenceCatalog, sflo:DigitalArtifact, sflo:RdfDocument ; + sflo:hasWorkingLocatedFile <${knopPath}/_references/references.ttl> ; + sflo:hasResourcePage <${knopPath}/_references/index.html> . + +<${designatorPath}.ttl> a sflo:LocatedFile, sflo:RdfDocument . +`; +} + +function temporalPayloadTurtle( + designatorPath: string, + label: string, + ordinal: number, +): string { + return `@base <${temporalLaterPayloadMeshBase}> . +@prefix dcterms: . + +<${designatorPath}> dcterms:title "${label} v${ordinal}" . +`; +} + +function temporalLaterPayloadSnapshotPath( + target: TemporalLaterPayloadTarget, + ordinal: number, +): string { + return `${target.designatorPath}/_history001/${ + toTemporalStateSegment(ordinal) + }/ttl/${target.fileName}`; +} + +function toTemporalStateSegment(ordinal: number): string { + return `_s${String(ordinal).padStart(4, "0")}`; +} + +async function snapshotWorkspaceFiles( + root: string, +): Promise> { + const paths = await listWorkspaceFiles(root); + const snapshot = new Map(); + for (const path of paths) { + snapshot.set(path, await Deno.readFile(join(root, path))); + } + return snapshot; +} + +async function snapshotWorkspacePaths( + root: string, + paths: readonly string[], +): Promise> { + const snapshot = new Map(); + for (const path of paths) { + snapshot.set(path, await Deno.readFile(join(root, path))); + } + return snapshot; +} + +async function listWorkspaceFiles( + root: string, + prefix = "", +): Promise { + const files: string[] = []; + for await (const entry of Deno.readDir(join(root, prefix))) { + const path = prefix.length === 0 ? entry.name : `${prefix}/${entry.name}`; + if (entry.isDirectory) { + files.push(...await listWorkspaceFiles(root, path)); + } else if (entry.isFile) { + files.push(path); + } + } + return files.sort(); +} + +function assertWorkspaceSnapshotsEqual( + actual: ReadonlyMap, + expected: ReadonlyMap, +): void { + assertEquals([...actual.keys()].sort(), [...expected.keys()].sort()); + for (const [path, actualBytes] of actual) { + const expectedBytes = expected.get(path); + assert(expectedBytes, `expected snapshot to include ${path}`); + assertEquals( + [...actualBytes], + [...expectedBytes], + `workspace file differed: ${path}`, + ); + } +} + async function replaceFileText( path: string, before: string, diff --git a/tests/support/test_tmp.ts b/tests/support/test_tmp.ts index ba797e8..5f9d7a6 100644 --- a/tests/support/test_tmp.ts +++ b/tests/support/test_tmp.ts @@ -1,5 +1,7 @@ const keepTestTmpEnvVar = "WEAVE_KEEP_TEST_TMP"; +export const testDenoDir = "/tmp/semantic-flow-deno-test-cache"; const isolatedEnvVars = [ + "DENO_DIR", "HOME", "USERPROFILE", "WEAVE_SETTINGS", @@ -95,6 +97,7 @@ async function installIsolatedTestEnv(): Promise { Deno.env.set("XDG_CONFIG_HOME", `${root}/config`); Deno.env.set("XDG_STATE_HOME", `${root}/state`); Deno.env.set("XDG_CACHE_HOME", `${root}/cache`); + Deno.env.set("DENO_DIR", testDenoDir); return { restore() { diff --git a/tests/support/test_tmp_test.ts b/tests/support/test_tmp_test.ts index 3ac21ce..7c11a11 100644 --- a/tests/support/test_tmp_test.ts +++ b/tests/support/test_tmp_test.ts @@ -1,6 +1,6 @@ import { assertEquals, assertFalse, assertStringIncludes } from "@std/assert"; import { fromFileUrl, isAbsolute, relative } from "@std/path"; -import { createTestTmpDir } from "./test_tmp.ts"; +import { createTestTmpDir, testDenoDir } from "./test_tmp.ts"; const repoRoot = fromFileUrl(new URL("../../", import.meta.url)); const harnessPath = fromFileUrl( @@ -16,6 +16,14 @@ Deno.test("createTestTmpDir allocates outside the repository checkout", async () ); }); +Deno.test("test env gives Deno subprocesses a stable cache outside the per-test XDG cache", () => { + assertEquals(Deno.env.get("DENO_DIR"), testDenoDir); + assertFalse( + isPathWithin(Deno.env.get("XDG_CACHE_HOME")!, testDenoDir), + "DENO_DIR should not live under the per-test XDG cache root", + ); +}); + Deno.test("test tmp preload preserves original JUnit test locations", async () => { const root = await createTestTmpDir("weave-test-tmp-junit-"); const testPath = `${root}/location_test.ts`;