@clossys/launcher 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1328 -60
- package/contracts/conversation-contract.md +2 -1
- package/contracts/product-ci-workflow.yml +74 -0
- package/contracts/repository-inventory.json +53 -0
- package/dist/admission-fixture.d.ts +168 -0
- package/dist/admission-fixture.d.ts.map +1 -0
- package/dist/admission-fixture.js +453 -0
- package/dist/admission-fixture.js.map +1 -0
- package/dist/admission.d.ts +124 -0
- package/dist/admission.d.ts.map +1 -0
- package/dist/admission.js +799 -0
- package/dist/admission.js.map +1 -0
- package/dist/agents-guide.d.ts +9 -0
- package/dist/agents-guide.d.ts.map +1 -0
- package/dist/agents-guide.js +26 -0
- package/dist/agents-guide.js.map +1 -0
- package/dist/apply-command-options.check.d.ts +12 -0
- package/dist/apply-command-options.check.d.ts.map +1 -0
- package/dist/apply-command-options.check.js +20 -0
- package/dist/apply-command-options.check.js.map +1 -0
- package/dist/apply-plan-cli.d.ts +39 -1
- package/dist/apply-plan-cli.d.ts.map +1 -1
- package/dist/apply-plan-cli.js +432 -15
- package/dist/apply-plan-cli.js.map +1 -1
- package/dist/apply-plan.d.ts +46 -59
- package/dist/apply-plan.d.ts.map +1 -1
- package/dist/apply-plan.js +112 -97
- package/dist/apply-plan.js.map +1 -1
- package/dist/apply-step-fixture.d.ts +87 -0
- package/dist/apply-step-fixture.d.ts.map +1 -0
- package/dist/apply-step-fixture.js +199 -0
- package/dist/apply-step-fixture.js.map +1 -0
- package/dist/apply-store.d.ts +93 -0
- package/dist/apply-store.d.ts.map +1 -0
- package/dist/apply-store.js +625 -0
- package/dist/apply-store.js.map +1 -0
- package/dist/approval-sheet.d.ts +21 -0
- package/dist/approval-sheet.d.ts.map +1 -0
- package/dist/approval-sheet.js +157 -0
- package/dist/approval-sheet.js.map +1 -0
- package/dist/body-command.d.ts +42 -0
- package/dist/body-command.d.ts.map +1 -0
- package/dist/body-command.js +143 -0
- package/dist/body-command.js.map +1 -0
- package/dist/change-set-contract.d.ts +381 -0
- package/dist/change-set-contract.d.ts.map +1 -0
- package/dist/change-set-contract.js +738 -0
- package/dist/change-set-contract.js.map +1 -0
- package/dist/change-set-digest.d.ts +28 -0
- package/dist/change-set-digest.d.ts.map +1 -0
- package/dist/change-set-digest.js +65 -0
- package/dist/change-set-digest.js.map +1 -0
- package/dist/check-cli.d.ts.map +1 -1
- package/dist/check-cli.js +14 -3
- package/dist/check-cli.js.map +1 -1
- package/dist/cli.d.ts +17 -6
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +84 -23
- package/dist/cli.js.map +1 -1
- package/dist/core.d.ts +79 -22
- package/dist/core.d.ts.map +1 -1
- package/dist/core.js +843 -268
- package/dist/core.js.map +1 -1
- package/dist/dry-materialize.d.ts +63 -0
- package/dist/dry-materialize.d.ts.map +1 -0
- package/dist/dry-materialize.js +330 -0
- package/dist/dry-materialize.js.map +1 -0
- package/dist/generated/contract-schema.generated.d.ts +97 -0
- package/dist/generated/contract-schema.generated.d.ts.map +1 -0
- package/dist/generated/contract-schema.generated.js +496 -0
- package/dist/generated/contract-schema.generated.js.map +1 -0
- package/dist/generated/package-scope.generated.d.ts +6 -0
- package/dist/generated/package-scope.generated.d.ts.map +1 -0
- package/dist/generated/package-scope.generated.js +10 -0
- package/dist/generated/package-scope.generated.js.map +1 -0
- package/dist/generated/plan-contracts.generated.d.ts +3 -0
- package/dist/generated/plan-contracts.generated.d.ts.map +1 -0
- package/dist/generated/plan-contracts.generated.js +2840 -0
- package/dist/generated/plan-contracts.generated.js.map +1 -0
- package/dist/host.d.ts.map +1 -1
- package/dist/host.js +11 -0
- package/dist/host.js.map +1 -1
- package/dist/identity.d.ts +15 -0
- package/dist/identity.d.ts.map +1 -0
- package/dist/identity.js +48 -0
- package/dist/identity.js.map +1 -0
- package/dist/index.d.ts +34 -5
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +19 -2
- package/dist/index.js.map +1 -1
- package/dist/inventory-adoption.d.ts +24 -5
- package/dist/inventory-adoption.d.ts.map +1 -1
- package/dist/inventory-adoption.js +70 -25
- package/dist/inventory-adoption.js.map +1 -1
- package/dist/inventory-choice.d.ts +40 -0
- package/dist/inventory-choice.d.ts.map +1 -0
- package/dist/inventory-choice.js +156 -0
- package/dist/inventory-choice.js.map +1 -0
- package/dist/inventory-contract.d.ts +89 -0
- package/dist/inventory-contract.d.ts.map +1 -0
- package/dist/inventory-contract.js +121 -0
- package/dist/inventory-contract.js.map +1 -0
- package/dist/key-editor.d.ts +30 -0
- package/dist/key-editor.d.ts.map +1 -0
- package/dist/key-editor.js +445 -0
- package/dist/key-editor.js.map +1 -0
- package/dist/ledger-contract.d.ts +187 -0
- package/dist/ledger-contract.d.ts.map +1 -0
- package/dist/ledger-contract.js +532 -0
- package/dist/ledger-contract.js.map +1 -0
- package/dist/ledger-trust.d.ts +90 -0
- package/dist/ledger-trust.d.ts.map +1 -0
- package/dist/ledger-trust.js +198 -0
- package/dist/ledger-trust.js.map +1 -0
- package/dist/lockfile-invariants.d.ts +48 -0
- package/dist/lockfile-invariants.d.ts.map +1 -0
- package/dist/lockfile-invariants.js +375 -0
- package/dist/lockfile-invariants.js.map +1 -0
- package/dist/lockfile-readers.d.ts +72 -0
- package/dist/lockfile-readers.d.ts.map +1 -0
- package/dist/lockfile-readers.js +713 -0
- package/dist/lockfile-readers.js.map +1 -0
- package/dist/lockfile-regen.d.ts +106 -0
- package/dist/lockfile-regen.d.ts.map +1 -0
- package/dist/lockfile-regen.js +760 -0
- package/dist/lockfile-regen.js.map +1 -0
- package/dist/lockfile-tool-env.d.ts +29 -0
- package/dist/lockfile-tool-env.d.ts.map +1 -0
- package/dist/lockfile-tool-env.js +111 -0
- package/dist/lockfile-tool-env.js.map +1 -0
- package/dist/materialize.d.ts +113 -0
- package/dist/materialize.d.ts.map +1 -0
- package/dist/materialize.js +840 -0
- package/dist/materialize.js.map +1 -0
- package/dist/observe-repository.d.ts +90 -0
- package/dist/observe-repository.d.ts.map +1 -0
- package/dist/observe-repository.js +1367 -0
- package/dist/observe-repository.js.map +1 -0
- package/dist/plan-bundle-setup-fixture.d.ts +68 -0
- package/dist/plan-bundle-setup-fixture.d.ts.map +1 -0
- package/dist/plan-bundle-setup-fixture.js +167 -0
- package/dist/plan-bundle-setup-fixture.js.map +1 -0
- package/dist/plan-bundle.d.ts +250 -0
- package/dist/plan-bundle.d.ts.map +1 -0
- package/dist/plan-bundle.js +827 -0
- package/dist/plan-bundle.js.map +1 -0
- package/dist/plan-command.d.ts +29 -0
- package/dist/plan-command.d.ts.map +1 -0
- package/dist/plan-command.js +493 -0
- package/dist/plan-command.js.map +1 -0
- package/dist/plan-contract.d.ts +153 -0
- package/dist/plan-contract.d.ts.map +1 -0
- package/dist/plan-contract.js +61 -0
- package/dist/plan-contract.js.map +1 -0
- package/dist/plan-digest.d.ts +25 -0
- package/dist/plan-digest.d.ts.map +1 -0
- package/dist/plan-digest.js +106 -0
- package/dist/plan-digest.js.map +1 -0
- package/dist/plan-rules.d.ts +23 -0
- package/dist/plan-rules.d.ts.map +1 -0
- package/dist/plan-rules.js +177 -0
- package/dist/plan-rules.js.map +1 -0
- package/dist/planned-bundle.d.ts +20 -0
- package/dist/planned-bundle.d.ts.map +1 -0
- package/dist/planned-bundle.js +191 -0
- package/dist/planned-bundle.js.map +1 -0
- package/dist/product-repository.d.ts +4 -0
- package/dist/product-repository.d.ts.map +1 -1
- package/dist/product-repository.js +9 -1
- package/dist/product-repository.js.map +1 -1
- package/dist/provenance-gate.d.ts +48 -0
- package/dist/provenance-gate.d.ts.map +1 -0
- package/dist/provenance-gate.js +324 -0
- package/dist/provenance-gate.js.map +1 -0
- package/dist/pull-request-body.d.ts +45 -0
- package/dist/pull-request-body.d.ts.map +1 -0
- package/dist/pull-request-body.js +232 -0
- package/dist/pull-request-body.js.map +1 -0
- package/dist/registry-snapshot.d.ts +141 -0
- package/dist/registry-snapshot.d.ts.map +1 -0
- package/dist/registry-snapshot.js +483 -0
- package/dist/registry-snapshot.js.map +1 -0
- package/dist/release-age-edit.d.ts +52 -0
- package/dist/release-age-edit.d.ts.map +1 -0
- package/dist/release-age-edit.js +413 -0
- package/dist/release-age-edit.js.map +1 -0
- package/dist/root-entries.d.ts +36 -0
- package/dist/root-entries.d.ts.map +1 -0
- package/dist/root-entries.js +80 -0
- package/dist/root-entries.js.map +1 -0
- package/dist/setup-template-scripts.d.ts +34 -0
- package/dist/setup-template-scripts.d.ts.map +1 -0
- package/dist/setup-template-scripts.js +557 -0
- package/dist/setup-template-scripts.js.map +1 -0
- package/dist/setup-templates.d.ts +54 -0
- package/dist/setup-templates.d.ts.map +1 -0
- package/dist/setup-templates.js +427 -0
- package/dist/setup-templates.js.map +1 -0
- package/dist/skills.d.ts +34 -1
- package/dist/skills.d.ts.map +1 -1
- package/dist/skills.js +129 -17
- package/dist/skills.js.map +1 -1
- package/dist/status.d.ts +63 -0
- package/dist/status.d.ts.map +1 -0
- package/dist/status.js +539 -0
- package/dist/status.js.map +1 -0
- package/dist/types.d.ts +151 -13
- package/dist/types.d.ts.map +1 -1
- package/package.json +4 -5
- package/skeleton/README.md +14 -9
- package/skeleton/package.json +2 -1
- package/skill/SKILL.md +20 -16
- package/skill-catalogue/advisor/SKILL.md +100 -11
- package/skill-catalogue/architect/SKILL.md +2 -13
- package/skill-catalogue/bouncer/SKILL.md +2 -13
- package/skill-catalogue/builder/SKILL.md +2 -13
- package/skill-catalogue/butler/SKILL.md +2 -13
- package/skill-catalogue/controller/SKILL.md +2 -13
- package/skill-catalogue/customer/SKILL.md +4 -13
- package/skill-catalogue/designer/SKILL.md +9 -14
- package/skill-catalogue/giver/SKILL.md +2 -13
- package/skill-catalogue/influencer/SKILL.md +2 -13
- package/skill-catalogue/inspector/SKILL.md +2 -13
- package/skill-catalogue/integrator/SKILL.md +2 -13
- package/skill-catalogue/keeper/SKILL.md +2 -13
- package/skill-catalogue/launcher/SKILL.md +20 -16
- package/skill-catalogue/locksmith/SKILL.md +2 -13
- package/skill-catalogue/messenger/SKILL.md +2 -13
- package/skill-catalogue/observer/SKILL.md +2 -13
- package/skill-catalogue/publisher/SKILL.md +11 -17
- package/skill-catalogue/starter/SKILL.md +3 -13
- package/skill-catalogue/strategist/SKILL.md +14 -17
- package/skill-catalogue/writer/SKILL.md +7 -14
- package/src/admission-fixture.ts +572 -0
- package/src/admission.ts +816 -0
- package/src/agents-guide.ts +29 -0
- package/src/apply-command-options.check.ts +27 -0
- package/src/apply-plan-cli.ts +454 -14
- package/src/apply-plan.ts +112 -124
- package/src/apply-step-fixture.ts +236 -0
- package/src/apply-store.ts +584 -0
- package/src/approval-sheet.ts +164 -0
- package/src/body-command.ts +162 -0
- package/src/change-set-contract.ts +937 -0
- package/src/change-set-digest.ts +70 -0
- package/src/check-cli.ts +14 -3
- package/src/cli.ts +90 -22
- package/src/core.ts +973 -275
- package/src/dry-materialize.ts +353 -0
- package/src/generated/contract-schema.generated.ts +520 -0
- package/src/generated/package-scope.generated.ts +10 -0
- package/src/generated/plan-contracts.generated.ts +2840 -0
- package/src/host.ts +10 -0
- package/src/identity.ts +51 -0
- package/src/index.ts +72 -3
- package/src/inventory-adoption.ts +107 -29
- package/src/inventory-choice.ts +172 -0
- package/src/inventory-contract.ts +166 -0
- package/src/key-editor.ts +446 -0
- package/src/ledger-contract.ts +637 -0
- package/src/ledger-trust.ts +267 -0
- package/src/lockfile-invariants.ts +421 -0
- package/src/lockfile-readers.ts +749 -0
- package/src/lockfile-regen.ts +851 -0
- package/src/lockfile-tool-env.ts +131 -0
- package/src/materialize.ts +886 -0
- package/src/observe-repository.ts +1365 -0
- package/src/plan-bundle-setup-fixture.ts +200 -0
- package/src/plan-bundle.ts +964 -0
- package/src/plan-command.ts +509 -0
- package/src/plan-contract.ts +179 -0
- package/src/plan-digest.ts +102 -0
- package/src/plan-rules.ts +188 -0
- package/src/planned-bundle.ts +211 -0
- package/src/product-repository.ts +10 -1
- package/src/provenance-gate.ts +352 -0
- package/src/pull-request-body.ts +261 -0
- package/src/registry-snapshot.ts +534 -0
- package/src/release-age-edit.ts +430 -0
- package/src/root-entries.ts +81 -0
- package/src/setup-template-scripts.ts +571 -0
- package/src/setup-templates.ts +471 -0
- package/src/skills.ts +161 -18
- package/src/status.ts +557 -0
- package/src/types.ts +148 -13
- package/CHANGELOG.md +0 -131
|
@@ -0,0 +1,827 @@
|
|
|
1
|
+
// The pure apply planner (issue #1178): from an approved plan's validated
|
|
2
|
+
// records and from observations of each staffed repository's default
|
|
3
|
+
// branch, it computes one change set per repository and the report-mode
|
|
4
|
+
// bundle that holds them. It performs no I/O of any kind -- no file, no
|
|
5
|
+
// network, no process, no clock -- so the same inputs always give the same
|
|
6
|
+
// bytes. Reading the repositories, the hub and the composed skills is the
|
|
7
|
+
// caller's job, and the caller hands the results in.
|
|
8
|
+
//
|
|
9
|
+
// The shapes it produces are defined by the shared contracts
|
|
10
|
+
// repository-change-set.json and apply-bundle.json, and the digests by
|
|
11
|
+
// apply-change-set-digest.md (all in the public repository, not shipped in
|
|
12
|
+
// this package). Every set and the bundle are validated against those
|
|
13
|
+
// contracts, code rules included, before they are returned; a planner
|
|
14
|
+
// defect throws rather than returning a set that does not validate.
|
|
15
|
+
//
|
|
16
|
+
// Ownership follows the apply RFC's desired-minus-installed table (§12.1)
|
|
17
|
+
// over the installed-state ledger the base carries, and only once that
|
|
18
|
+
// ledger is trusted (ledger-trust.ts): every generation it records must be a
|
|
19
|
+
// change set the hub holds, and every row one of those sets' own writes;
|
|
20
|
+
// anything less skips the whole repository. A whole file is written only by
|
|
21
|
+
// compare-and-swap against the ledger's row: added where neither the ledger
|
|
22
|
+
// nor the base has it, kept or updated where the base still holds the bytes
|
|
23
|
+
// the flow last wrote, and refused otherwise (unowned-existing,
|
|
24
|
+
// client-edited, deleted). An owned package key follows the same table. The
|
|
25
|
+
// planner never removes what the ledger records and the desired state no
|
|
26
|
+
// longer names; it reports that (removal-unbuilt) and leaves the row to be
|
|
27
|
+
// carried forward. The bundle still claims no repository state.
|
|
28
|
+
//
|
|
29
|
+
// A setup-phase repository gets a setup set (code rules C10 to C12): the
|
|
30
|
+
// brief, the skills and the ledger as an apply set has them, exactly one item
|
|
31
|
+
// for each of the four setup templates -- whose bytes come only from
|
|
32
|
+
// renderSetupTemplate() -- the Starter pin the plan names, every install the
|
|
33
|
+
// plan names deferred until after setup, and, for pnpm, the one edit that
|
|
34
|
+
// exempts the publishing scope from the release-age window. A setup set is
|
|
35
|
+
// also the only place the generation-0 adoption pass runs (RFC §12.2): a file
|
|
36
|
+
// the base already holds is adopted only by byte proof. Every ambiguity is a
|
|
37
|
+
// skip with its own reason id (`package-manager-unsupported`,
|
|
38
|
+
// `starter-pin-absent`, `starter-pin-unsupported`, `release-age-text-absent`,
|
|
39
|
+
// `starter-request-invalid`, and, for an apply set, `starter-request-stale`),
|
|
40
|
+
// never a guess. A repository whose Controller profile needs root entries
|
|
41
|
+
// added (code rule C13) is skipped as root-entry-edit-unbuilt when the
|
|
42
|
+
// observation does not carry the profile text; when repositoryProfileText is
|
|
43
|
+
// present, the profile is edited here.
|
|
44
|
+
import { loadContract, validateAdvisorPlan, validateEngagementBrief } from "./plan-contract.js";
|
|
45
|
+
import { planDigest } from "./plan-digest.js";
|
|
46
|
+
import { AGENTS_GUIDE_PATH, AGENTS_GUIDE_TEXT } from "./agents-guide.js";
|
|
47
|
+
import { HUB_ONLY_ROLES } from "./plan-rules.js";
|
|
48
|
+
import { bundleDigest, changeSetDigest } from "./change-set-digest.js";
|
|
49
|
+
import { AUTHORIZATION_ABSENT, AUTHORIZATION_PLAN_MISMATCH, BRIEF_PATH, CANONICAL_KEYS, DISCOVERY_ROOTS, EXEMPTION_SURFACES, ID_TOKEN, LEDGER_PATH, derivedPlanItem, SKILLS_MANIFEST_PATH, TEMPLATE_PATHS, canonicalOrder, contentDigest, dependencyPointer, discoveryLinkPath, discoveryLinkTarget, isSafeRelativePath, lockfilePath, matchesPathPattern, skillPath, validateApplyBundle, validateRepositoryChangeSet, worstVerdict, } from "./change-set-contract.js";
|
|
50
|
+
import { PACKAGE_SCOPE } from "./generated/package-scope.generated.js";
|
|
51
|
+
import { JsonEditUnstableError, editJsonPointer } from "./key-editor.js";
|
|
52
|
+
import { reconcileWholeFile, trustInstalledLedger } from "./ledger-trust.js";
|
|
53
|
+
import { editReleaseAgeExemption } from "./release-age-edit.js";
|
|
54
|
+
import { renderSetupTemplate } from "./setup-templates.js";
|
|
55
|
+
/** The fixed text a brief carries as its problem in a repository that is not private, read from the packed brief contract. */
|
|
56
|
+
export const PUBLIC_PROBLEM_PLACEHOLDER = (() => {
|
|
57
|
+
const definitions = loadContract("engagement-brief.json").definitions;
|
|
58
|
+
const text = definitions?.publicProblemPlaceholder?.const;
|
|
59
|
+
if (typeof text !== "string")
|
|
60
|
+
throw new Error("the packed brief contract has no publicProblemPlaceholder text");
|
|
61
|
+
return text;
|
|
62
|
+
})();
|
|
63
|
+
const BASE_ALLOW_LIST = [".agents/skills/clossys-*/**", "clossys/**"];
|
|
64
|
+
const ROOT_ENTRIES_ITEM = "root-entries";
|
|
65
|
+
const PROFILE_ALLOW_PATTERNS = ["**/repository-profile.json", "**/repository-declaration.json"];
|
|
66
|
+
/** Each setup template act, with the item id a set gives it. */
|
|
67
|
+
const TEMPLATE_ITEMS = [
|
|
68
|
+
{ act: "add-caller-workflow", id: "caller-workflow" },
|
|
69
|
+
{ act: "write-starter-request", id: "starter-request" },
|
|
70
|
+
{ act: "add-ci-template", id: "ci-template" },
|
|
71
|
+
{ act: "add-path-scope-job", id: "path-scope-job" },
|
|
72
|
+
];
|
|
73
|
+
/** The patterns a set that names the setup templates adds to its pathAllowList. */
|
|
74
|
+
const TEMPLATE_ALLOW_LIST = [".github/scripts/clossys-*", ".github/workflows/clossys-*", ".starter/request.json"];
|
|
75
|
+
/** The one exempt-release-age item a pnpm set carries: a fixed id no plan text can spell, because a planItem is `repository:package`. */
|
|
76
|
+
const RELEASE_AGE_ITEM = "release-age";
|
|
77
|
+
const RELEASE_AGE_SURFACE = "pnpm-workspace";
|
|
78
|
+
const AGENTS_GUIDE_ITEM = "agents-guide";
|
|
79
|
+
const RESERVED_ITEM_IDS = new Set(["brief", AGENTS_GUIDE_ITEM, "skills", "ledger", ROOT_ENTRIES_ITEM, RELEASE_AGE_ITEM, ...TEMPLATE_ITEMS.map((template) => template.id)]);
|
|
80
|
+
const NPMRC_PATH = ".npmrc";
|
|
81
|
+
/**
|
|
82
|
+
* The Advisor voice (D33): every staffed repository gets it beside its
|
|
83
|
+
* staffed roles' voices. It is a hub-only role, never staffed itself (plan
|
|
84
|
+
* rule R11), so it can never repeat a staffed role; a packed plan contract
|
|
85
|
+
* that does not list it as hub-only is a build defect.
|
|
86
|
+
*/
|
|
87
|
+
const ADVISOR_VOICE = "advisor";
|
|
88
|
+
if (!HUB_ONLY_ROLES.includes(ADVISOR_VOICE))
|
|
89
|
+
throw new Error("the packed plan contract's hub-only roles do not include the Advisor voice");
|
|
90
|
+
function projectRole(role) {
|
|
91
|
+
return {
|
|
92
|
+
role: role.role,
|
|
93
|
+
why: role.why,
|
|
94
|
+
goal: { metric: role.goal.metric, direction: role.goal.direction },
|
|
95
|
+
inputsFrom: [...role.inputsFrom],
|
|
96
|
+
outputsTo: [...role.outputsTo],
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
function projectContext(context) {
|
|
100
|
+
return {
|
|
101
|
+
schemaVersion: context.schemaVersion,
|
|
102
|
+
fields: context.fields.map((field) => (field.state === "known" ? { id: field.id, state: field.state, value: field.value } : { id: field.id, state: field.state })),
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* One repository's brief, projected from the hub brief: `staffedHere` is set
|
|
107
|
+
* to the roles staffed there, in plan order, and `problem` is replaced by
|
|
108
|
+
* PUBLIC_PROBLEM_PLACEHOLDER unless the repository is private. Nothing else
|
|
109
|
+
* changes. Members are written in one fixed order at every depth -- the order
|
|
110
|
+
* the brief and context contracts declare them -- whatever order the hub
|
|
111
|
+
* brief's file used, because these bytes feed the change-set digest.
|
|
112
|
+
*/
|
|
113
|
+
export function projectEngagementBrief(hubBrief, staffedHere, visibility) {
|
|
114
|
+
return {
|
|
115
|
+
schemaVersion: hubBrief.schemaVersion,
|
|
116
|
+
problem: visibility === "private" ? hubBrief.problem : PUBLIC_PROBLEM_PLACEHOLDER,
|
|
117
|
+
roles: hubBrief.roles.map(projectRole),
|
|
118
|
+
sequence: [...hubBrief.sequence],
|
|
119
|
+
deliverables: [...hubBrief.deliverables],
|
|
120
|
+
staffedHere: [...staffedHere],
|
|
121
|
+
...(hubBrief.context === undefined ? {} : { context: projectContext(hubBrief.context) }),
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
/** The exact bytes written to clossys/brief.json: two-space JSON and one final newline. */
|
|
125
|
+
export function serializeEngagementBrief(brief) {
|
|
126
|
+
return `${JSON.stringify(brief, null, 2)}\n`;
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* The exact bytes of clossys/.state/skills.json for the skills a set writes:
|
|
130
|
+
* each role's name, source catalogue, the hex SHA-256 of its SKILL.md and the
|
|
131
|
+
* producer's version, sorted by name, as two-space JSON and one line feed,
|
|
132
|
+
* with no time in it (apply-change-set-digest.md, Content digests).
|
|
133
|
+
*/
|
|
134
|
+
export function serializeComposedSkillsManifest(skills, producerVersion) {
|
|
135
|
+
const entries = canonicalOrder(skills, (skill) => [skill.role]).map((skill) => ({
|
|
136
|
+
name: skill.role,
|
|
137
|
+
source: "catalogue",
|
|
138
|
+
sha256: skill.sha256.slice("sha256:".length),
|
|
139
|
+
version: producerVersion,
|
|
140
|
+
}));
|
|
141
|
+
return `${JSON.stringify({ schemaVersion: 1, skills: entries }, null, 2)}\n`;
|
|
142
|
+
}
|
|
143
|
+
function isSkipped(entry) {
|
|
144
|
+
return Object.hasOwn(entry, "skipped");
|
|
145
|
+
}
|
|
146
|
+
/** Checks in one order, by check id, then rule. */
|
|
147
|
+
function sortChecks(checks) {
|
|
148
|
+
return canonicalOrder(checks, (check) => [check.check, check.rule ?? ""]);
|
|
149
|
+
}
|
|
150
|
+
const CASE_VARIANT = "case-variant";
|
|
151
|
+
/** The content digest the observation lists for a path (compared case-insensitively, as C3 does); null when none, CASE_VARIANT when several. */
|
|
152
|
+
function observedDigest(observation, path) {
|
|
153
|
+
const lower = path.toLowerCase();
|
|
154
|
+
const held = observation.files.filter((file) => file.path.toLowerCase() === lower);
|
|
155
|
+
if (held.length === 0)
|
|
156
|
+
return null;
|
|
157
|
+
return held.length === 1 ? held[0].sha256 : CASE_VARIANT;
|
|
158
|
+
}
|
|
159
|
+
/** Throws unless each release-age surface text the observation supplies is exactly the file it digests. Names the field, never the text. */
|
|
160
|
+
function checkSurfaceTexts(observation) {
|
|
161
|
+
for (const { field, path, text } of [
|
|
162
|
+
{ field: "pnpmWorkspaceText", path: EXEMPTION_SURFACES[RELEASE_AGE_SURFACE].path, text: observation.pnpmWorkspaceText },
|
|
163
|
+
{ field: "npmrcText", path: NPMRC_PATH, text: observation.npmrcText },
|
|
164
|
+
]) {
|
|
165
|
+
if (typeof text !== "string")
|
|
166
|
+
continue;
|
|
167
|
+
if (observedDigest(observation, path) !== contentDigest(text))
|
|
168
|
+
throw new TypeError(`${field} does not match the observed ${path} file`);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* Renders the four setup templates for a repository in the setup phase, or
|
|
173
|
+
* names why it cannot: every reason is an id, and the first one found is the
|
|
174
|
+
* one given. The bytes come from renderSetupTemplate() alone; the request
|
|
175
|
+
* takes the manager, the repository id and the plan's one Starter pin, and
|
|
176
|
+
* nothing else reaches a template.
|
|
177
|
+
*/
|
|
178
|
+
function prepareSetup(observation, acts) {
|
|
179
|
+
const packageManager = observation.packageManager;
|
|
180
|
+
if (packageManager !== "npm" && packageManager !== "pnpm")
|
|
181
|
+
return { skip: "package-manager-unsupported" };
|
|
182
|
+
const pins = acts.filter((act) => act.act === "pin-starter");
|
|
183
|
+
// Plan rule R10 allows at most one, so any other count is an absent pin: a set pins Starter exactly once (C11).
|
|
184
|
+
if (pins.length !== 1)
|
|
185
|
+
return { skip: "starter-pin-absent" };
|
|
186
|
+
const pin = pins[0];
|
|
187
|
+
const request = renderSetupTemplate("write-starter-request", { packageManager, repository: observation.id, starter: { name: pin.name, version: pin.version, integrity: pin.integrity } });
|
|
188
|
+
if (!request.ok)
|
|
189
|
+
return { skip: request.refusal.reason === "starter-pin-unsupported" ? "starter-pin-unsupported" : "starter-request-invalid" };
|
|
190
|
+
if (packageManager === "pnpm") {
|
|
191
|
+
// The edit needs the surface's exact text, and the .npmrc's when there is one: nothing is edited from a digest.
|
|
192
|
+
const surface = observedDigest(observation, EXEMPTION_SURFACES[RELEASE_AGE_SURFACE].path);
|
|
193
|
+
if (surface !== null && typeof observation.pnpmWorkspaceText !== "string")
|
|
194
|
+
return { skip: "release-age-text-absent" };
|
|
195
|
+
const npmrc = observation.releaseAgeSurfaces.some((entry) => entry.path === NPMRC_PATH) || observedDigest(observation, NPMRC_PATH) !== null;
|
|
196
|
+
if (npmrc && typeof observation.npmrcText !== "string")
|
|
197
|
+
return { skip: "release-age-text-absent" };
|
|
198
|
+
}
|
|
199
|
+
const templates = [];
|
|
200
|
+
for (const { act, id } of TEMPLATE_ITEMS) {
|
|
201
|
+
const rendered = act === "write-starter-request" ? request : renderSetupTemplate(act, act === "add-caller-workflow" ? { packageManager } : undefined);
|
|
202
|
+
if (!rendered.ok)
|
|
203
|
+
throw new TypeError(`the setup template ${act} does not render`);
|
|
204
|
+
templates.push({ id, files: rendered.files });
|
|
205
|
+
}
|
|
206
|
+
return { templates };
|
|
207
|
+
}
|
|
208
|
+
function computeChangeSet(inputs, planDigestValue, observation, ledger, roles, acts, skillContent, setupTemplates) {
|
|
209
|
+
// Paths compare case-insensitively (code rule C3): a base file that differs only in case is the same file on many checkouts.
|
|
210
|
+
// Two (or more) observed files at the same lowercase path -- distinct case variants, or a repeated entry -- have no single
|
|
211
|
+
// base digest between them: the path is occupied by other bytes than any one of them, so it can never be kept, updated or
|
|
212
|
+
// adopted, whatever the ledger says (fix for issue #1545). CASE_VARIANT_BASE is a sentinel that never equals a real content
|
|
213
|
+
// digest (those are always `sha256:` and 64 hex digits) or null, so it always reads as "occupied by bytes the flow does not
|
|
214
|
+
// own" wherever a base digest is compared.
|
|
215
|
+
const CASE_VARIANT_BASE = "case-variant";
|
|
216
|
+
const fileGroups = new Map();
|
|
217
|
+
for (const file of observation.files) {
|
|
218
|
+
const key = file.path.toLowerCase();
|
|
219
|
+
const group = fileGroups.get(key);
|
|
220
|
+
if (group === undefined)
|
|
221
|
+
fileGroups.set(key, [file.sha256]);
|
|
222
|
+
else
|
|
223
|
+
group.push(file.sha256);
|
|
224
|
+
}
|
|
225
|
+
const isCaseVariant = (path) => (fileGroups.get(path.toLowerCase())?.length ?? 0) > 1;
|
|
226
|
+
const existingAt = (path) => {
|
|
227
|
+
const group = fileGroups.get(path.toLowerCase());
|
|
228
|
+
if (group === undefined)
|
|
229
|
+
return undefined;
|
|
230
|
+
return group.length > 1 ? CASE_VARIANT_BASE : group[0];
|
|
231
|
+
};
|
|
232
|
+
// The lockfile and the ledger are derived files this planner writes unconditionally; a case-variant there leaves no single
|
|
233
|
+
// base to compare-and-swap against, so the whole repository is skipped rather than guessing which variant is real.
|
|
234
|
+
const lockfile = lockfilePath(observation);
|
|
235
|
+
if (isCaseVariant(LEDGER_PATH) || (lockfile !== null && isCaseVariant(lockfile))) {
|
|
236
|
+
return { skip: { verdict: "indeterminate", reason: "case-variant-path" } };
|
|
237
|
+
}
|
|
238
|
+
// The trusted ledger's rows: files by path (case-insensitively, as C3 and L8 compare paths), keys by pointer.
|
|
239
|
+
const fileRows = new Map((ledger?.files ?? []).map((row) => [row.path.toLowerCase(), row.after]));
|
|
240
|
+
const fileRowAt = (path) => fileRows.get(path.toLowerCase()) ?? null;
|
|
241
|
+
const keyRows = new Map((ledger?.keys ?? []).map((row) => [row.pointer, row.value]));
|
|
242
|
+
const items = [];
|
|
243
|
+
const files = [];
|
|
244
|
+
const keys = [];
|
|
245
|
+
const refused = [];
|
|
246
|
+
const deferred = [];
|
|
247
|
+
const texts = {};
|
|
248
|
+
const pathAllowList = [...BASE_ALLOW_LIST];
|
|
249
|
+
// A path is present when a file is there, or when it is a directory holding one.
|
|
250
|
+
const presentAt = (path) => existingAt(path) !== undefined || observation.files.some((file) => file.path.toLowerCase().startsWith(`${path.toLowerCase()}/`));
|
|
251
|
+
// The one compare-and-swap table for a whole file (RFC §12.1); only a setup set may adopt what the base has (§12.2).
|
|
252
|
+
const reconcile = (path, desired) => reconcileWholeFile({
|
|
253
|
+
path,
|
|
254
|
+
desired,
|
|
255
|
+
row: fileRowAt(path),
|
|
256
|
+
base: existingAt(path) ?? null,
|
|
257
|
+
occupied: presentAt(path),
|
|
258
|
+
phase: observation.phase,
|
|
259
|
+
skillsManifest: observation.skillsManifest,
|
|
260
|
+
});
|
|
261
|
+
const writeWhole = (path, text, item, mode = "100644") => {
|
|
262
|
+
if (!isSafeRelativePath(path) || !pathAllowList.some((pattern) => matchesPathPattern(path, pattern))) {
|
|
263
|
+
refused.push({ path, reason: "unsafe-path", item });
|
|
264
|
+
return false;
|
|
265
|
+
}
|
|
266
|
+
const desired = contentDigest(text);
|
|
267
|
+
// A repository set up before the guide existed has a trusted ledger with no row for it, so an apply set may add the guide, and only
|
|
268
|
+
// the guide, and only where nothing at all is on disk. A file or directory already there is the client's: it falls through to reconcile.
|
|
269
|
+
const guideAdd = observation.phase === "apply" && ledger !== null && path === AGENTS_GUIDE_PATH && fileRowAt(path) === null && !presentAt(path);
|
|
270
|
+
const outcome = guideAdd ? { write: true, before: null } : reconcile(path, desired);
|
|
271
|
+
if (!outcome.write) {
|
|
272
|
+
refused.push({ path, reason: outcome.reason, item });
|
|
273
|
+
return false;
|
|
274
|
+
}
|
|
275
|
+
// An apply set may keep or update only where the trusted ledger already has a row (issue #1545 fix 7).
|
|
276
|
+
if (observation.phase === "apply" && fileRowAt(path) === null && outcome.before === null && !guideAdd) {
|
|
277
|
+
refused.push({ path, reason: "unowned-existing", item });
|
|
278
|
+
return false;
|
|
279
|
+
}
|
|
280
|
+
// before is null (add), the desired digest (keep), or the bytes the flow last wrote (update).
|
|
281
|
+
files.push({ path, mode, before: outcome.before, after: desired, item });
|
|
282
|
+
texts[path] = text;
|
|
283
|
+
return true;
|
|
284
|
+
};
|
|
285
|
+
items.push({ id: "brief", act: "write-record", source: "engagement-brief" });
|
|
286
|
+
// The brief's staffedHere names the staffed roles only; the Advisor voice is not staffed (D33).
|
|
287
|
+
const brief = projectEngagementBrief(inputs.hubBrief, roles, observation.visibility);
|
|
288
|
+
const briefValidation = validateEngagementBrief(brief);
|
|
289
|
+
if (!briefValidation.valid)
|
|
290
|
+
throw new TypeError(`a projected brief does not validate: ${briefValidation.reason}`);
|
|
291
|
+
writeWhole(BRIEF_PATH, serializeEngagementBrief(brief), "brief");
|
|
292
|
+
// The Launcher's own guide to clossys/ and the clossys-* skills: constant bytes, owned by the ledger row's digest (D10, D30).
|
|
293
|
+
items.push({ id: AGENTS_GUIDE_ITEM, act: "write-record", source: "agents-guide" });
|
|
294
|
+
writeWhole(AGENTS_GUIDE_PATH, AGENTS_GUIDE_TEXT, AGENTS_GUIDE_ITEM);
|
|
295
|
+
// Every staffed repository gets the Advisor voice beside its staffed roles' voices (D33), first, then plan order.
|
|
296
|
+
const voices = [ADVISOR_VOICE, ...roles];
|
|
297
|
+
items.push({ id: "skills", act: "compose-skills", roles: voices });
|
|
298
|
+
// A discovery link is written under each root the base does not have as a symbolic link: a write through one would land where it points.
|
|
299
|
+
const linkedRoots = DISCOVERY_ROOTS.filter((root) => !observation.symlinkedSkillRoots.includes(root));
|
|
300
|
+
for (const root of linkedRoots)
|
|
301
|
+
pathAllowList.push(`${root}/clossys-*`);
|
|
302
|
+
const composed = [];
|
|
303
|
+
for (const role of voices) {
|
|
304
|
+
const content = skillContent.get(role);
|
|
305
|
+
if (content === undefined)
|
|
306
|
+
throw new TypeError("a staffed role has no composed skill content in skills");
|
|
307
|
+
const skill = skillPath(role);
|
|
308
|
+
// Never write through a symbolic link: the bytes would land wherever it points.
|
|
309
|
+
if (observation.linkedAgentsPaths.some((link) => skill.startsWith(`${link}/`)))
|
|
310
|
+
refused.push({ path: skill, reason: "skills-root-is-link", item: "skills" });
|
|
311
|
+
else if (writeWhole(skill, content, "skills")) {
|
|
312
|
+
// Added, kept or updated: the skill is the flow's, so the manifest lists it and its links are written.
|
|
313
|
+
composed.push({ role, sha256: contentDigest(content) });
|
|
314
|
+
// Links only to a skill the set writes: a link to a refused skill would expose one the flow does not own.
|
|
315
|
+
for (const root of linkedRoots)
|
|
316
|
+
writeWhole(discoveryLinkPath(root, role), discoveryLinkTarget(role), "skills", "120000");
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
writeWhole(SKILLS_MANIFEST_PATH, serializeComposedSkillsManifest(composed, inputs.producer.version), "skills");
|
|
320
|
+
if (setupTemplates !== null) {
|
|
321
|
+
// A setup set holds each template once, its bytes from the renderer alone. The template patterns join the allow list before any
|
|
322
|
+
// write, so a template path is never refused as unsafe; a base file already there is adopted only when its bytes are exactly
|
|
323
|
+
// the set's own, and is unowned-existing otherwise (RFC §12.2).
|
|
324
|
+
pathAllowList.push(...TEMPLATE_ALLOW_LIST);
|
|
325
|
+
for (const { id, files: templateFiles } of setupTemplates) {
|
|
326
|
+
items.push({ id, act: TEMPLATE_ITEMS.find((template) => template.id === id).act });
|
|
327
|
+
for (const file of templateFiles)
|
|
328
|
+
writeWhole(file.path, file.bytes, id);
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
else {
|
|
332
|
+
// The setup templates in an apply set are no-ops: never written anew, only kept where the trusted ledger records them all.
|
|
333
|
+
// A template act the ledger records none of gets no item.
|
|
334
|
+
for (const { act, id } of TEMPLATE_ITEMS) {
|
|
335
|
+
const rows = TEMPLATE_PATHS[act].map((path) => ({ path, row: fileRowAt(path) }));
|
|
336
|
+
const recorded = rows.filter((entry) => entry.row !== null).length;
|
|
337
|
+
if (recorded === 0)
|
|
338
|
+
continue;
|
|
339
|
+
// A ledger that records some of an act's files and not the others cannot be kept as that act, and nothing here writes the rest.
|
|
340
|
+
if (recorded < rows.length)
|
|
341
|
+
return { skip: { verdict: "indeterminate", reason: "template-rows-partial" } };
|
|
342
|
+
items.push({ id, act });
|
|
343
|
+
for (const { path, row } of rows) {
|
|
344
|
+
const outcome = reconcile(path, row);
|
|
345
|
+
if (outcome.write)
|
|
346
|
+
files.push({ path, mode: "100644", before: row, after: row, item: id });
|
|
347
|
+
else
|
|
348
|
+
refused.push({ path, reason: outcome.reason, item: id });
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
if (items.some((item) => TEMPLATE_ITEMS.some((template) => template.id === item.id)))
|
|
352
|
+
pathAllowList.push(...TEMPLATE_ALLOW_LIST);
|
|
353
|
+
}
|
|
354
|
+
const invariants = [];
|
|
355
|
+
for (const act of acts) {
|
|
356
|
+
if (RESERVED_ITEM_IDS.has(act.planItem)) {
|
|
357
|
+
throw new TypeError("a package act's planItem is an item id the change set reserves (brief, agents-guide, skills, ledger, root-entries, release-age, caller-workflow, starter-request, ci-template or path-scope-job)");
|
|
358
|
+
}
|
|
359
|
+
// A setup set defers every install until after setup (code rule C10): no item, no key, no invariant.
|
|
360
|
+
if (observation.phase === "setup" && act.act === "install") {
|
|
361
|
+
deferred.push({ planItem: act.planItem, reason: "after-setup" });
|
|
362
|
+
continue;
|
|
363
|
+
}
|
|
364
|
+
const pinned = { name: act.name, version: act.version, integrity: act.integrity };
|
|
365
|
+
const entries = observation.manifestEntries.filter((entry) => entry.name === act.name);
|
|
366
|
+
const pointer = dependencyPointer(act.placement, act.name);
|
|
367
|
+
// The owned key follows the same table as a whole file (RFC §12.1): no row and nothing there adds it; no row and a value
|
|
368
|
+
// there is unowned-existing; a row and nothing there is deleted; a row and another value there is client-edited; a row and
|
|
369
|
+
// its own value there is the flow's key, updated when the plan names another version. This table is consulted before the
|
|
370
|
+
// satisfied-in-base shortcut below (fix for issue #1545): a trusted row the base no longer holds is a client edit even
|
|
371
|
+
// when the base happens to already carry the version the plan wants, so it is never waved through as satisfied.
|
|
372
|
+
const row = keyRows.get(pointer);
|
|
373
|
+
const baseAt = entries.find((entry) => entry.placement === act.placement);
|
|
374
|
+
const own = row === undefined ? (baseAt === undefined ? null : "unowned-existing") : baseAt === undefined ? "deleted" : baseAt.value !== row ? "client-edited" : null;
|
|
375
|
+
const satisfiedInBase = entries.length === 1 &&
|
|
376
|
+
baseAt !== undefined &&
|
|
377
|
+
baseAt.value === act.version &&
|
|
378
|
+
(row === undefined || (row === baseAt.value && row === act.version)) &&
|
|
379
|
+
observation.lockedPackages.some((locked) => locked.name === act.name && locked.version === act.version && locked.integrity === act.integrity);
|
|
380
|
+
items.push({ id: act.planItem, act: act.act, planItem: act.planItem, package: pinned, placement: act.placement, satisfiedInBase });
|
|
381
|
+
if (satisfiedInBase)
|
|
382
|
+
continue;
|
|
383
|
+
if (observation.packageManager === "none") {
|
|
384
|
+
refused.push({ file: "package.json", pointer, reason: "manifest-absent", item: act.planItem });
|
|
385
|
+
continue;
|
|
386
|
+
}
|
|
387
|
+
const others = new Set(entries.filter((entry) => entry.placement !== act.placement).map((entry) => dependencyPointer(entry.placement, entry.name)));
|
|
388
|
+
if (others.size > 0) {
|
|
389
|
+
// The package is also at the other placement, which the flow never wrote: nothing is written for it.
|
|
390
|
+
for (const other of others)
|
|
391
|
+
refused.push({ file: "package.json", pointer: other, reason: "unowned-existing", item: act.planItem });
|
|
392
|
+
if (own !== null)
|
|
393
|
+
refused.push({ file: "package.json", pointer, reason: own, item: act.planItem });
|
|
394
|
+
continue;
|
|
395
|
+
}
|
|
396
|
+
if (own !== null) {
|
|
397
|
+
refused.push({ file: "package.json", pointer, reason: own, item: act.planItem });
|
|
398
|
+
continue;
|
|
399
|
+
}
|
|
400
|
+
if (row === undefined || act.version !== row) {
|
|
401
|
+
// The request a setup set wrote names this pin; an apply set that changes it would leave the request naming another, and
|
|
402
|
+
// rewriting the request is not something an apply set does.
|
|
403
|
+
if (act.act === "pin-starter" && observation.phase === "apply")
|
|
404
|
+
return { skip: { verdict: "indeterminate", reason: "starter-request-stale" } };
|
|
405
|
+
keys.push({ file: "package.json", pointer, before: row ?? null, after: act.version, item: act.planItem });
|
|
406
|
+
invariants.push({ item: act.planItem, ...pinned });
|
|
407
|
+
continue;
|
|
408
|
+
}
|
|
409
|
+
// The key holds the desired version and the flow wrote it, yet the lockfile does not resolve that version at the plan's
|
|
410
|
+
// integrity: a supply-chain signal, never repaired (§12.6), so the repository gets no set.
|
|
411
|
+
return { skip: { verdict: "violated", reason: "integrity-mismatch" } };
|
|
412
|
+
}
|
|
413
|
+
if (invariants.length > 0 && lockfile !== null) {
|
|
414
|
+
pathAllowList.push("package.json", lockfile);
|
|
415
|
+
const sorted = canonicalOrder(invariants, CANONICAL_KEYS.invariant);
|
|
416
|
+
files.push({ path: lockfile, mode: "100644", derived: true, item: sorted[0].item, invariants: sorted, before: existingAt(lockfile) ?? null });
|
|
417
|
+
}
|
|
418
|
+
// pnpm reads the release-age window from the workspace file. A setup set exempts the publishing scope there with the one edit
|
|
419
|
+
// editReleaseAgeExemption() makes (code rule C12); an apply set only carries the item when the trusted ledger records that entry,
|
|
420
|
+
// so that it is the setup set's item over again, with no file of its own (RFC D26).
|
|
421
|
+
if (observation.packageManager === "pnpm") {
|
|
422
|
+
const surface = EXEMPTION_SURFACES[RELEASE_AGE_SURFACE];
|
|
423
|
+
const scopeEntry = `${PACKAGE_SCOPE.scope}/*`;
|
|
424
|
+
const recorded = (ledger?.entries ?? []).some((row) => row.file === surface.path && row.key === surface.key && row.value === scopeEntry);
|
|
425
|
+
if (setupTemplates !== null || recorded) {
|
|
426
|
+
items.push({ id: RELEASE_AGE_ITEM, act: "exempt-release-age", scope: PACKAGE_SCOPE.scope, surface: RELEASE_AGE_SURFACE, path: surface.path });
|
|
427
|
+
pathAllowList.push(surface.path);
|
|
428
|
+
}
|
|
429
|
+
if (setupTemplates !== null) {
|
|
430
|
+
const baseDigest = existingAt(surface.path);
|
|
431
|
+
if (baseDigest === undefined && presentAt(surface.path)) {
|
|
432
|
+
// A directory holds the path: there is no file to edit and none to create.
|
|
433
|
+
refused.push({ path: surface.path, reason: "release-age-surface-unparseable", item: RELEASE_AGE_ITEM });
|
|
434
|
+
}
|
|
435
|
+
else {
|
|
436
|
+
// prepareSetup() skipped every repository that has the file and not its text, and checkSurfaceTexts() held the text to the digest.
|
|
437
|
+
const text = baseDigest === undefined ? null : observation.pnpmWorkspaceText;
|
|
438
|
+
if (typeof text !== "string" && text !== null)
|
|
439
|
+
throw new TypeError("pnpmWorkspaceText is absent for a pnpm-workspace.yaml the observation digests");
|
|
440
|
+
const edit = editReleaseAgeExemption({ surface: RELEASE_AGE_SURFACE, text, npmrc: observation.npmrcText ?? null });
|
|
441
|
+
if (edit.kind === "edited") {
|
|
442
|
+
files.push({ path: surface.path, mode: "100644", before: baseDigest ?? null, after: contentDigest(edit.text), item: RELEASE_AGE_ITEM });
|
|
443
|
+
texts[surface.path] = edit.text;
|
|
444
|
+
}
|
|
445
|
+
else if (edit.kind === "refused") {
|
|
446
|
+
refused.push({ path: surface.path, reason: edit.reason, item: RELEASE_AGE_ITEM });
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
const generation = ledger?.generation ?? 0;
|
|
452
|
+
items.push({ id: "ledger", act: "write-ledger" });
|
|
453
|
+
files.push({
|
|
454
|
+
path: LEDGER_PATH,
|
|
455
|
+
mode: "100644",
|
|
456
|
+
derived: true,
|
|
457
|
+
item: "ledger",
|
|
458
|
+
invariants: [{ ledgerGeneration: generation + 1 }],
|
|
459
|
+
before: existingAt(LEDGER_PATH) ?? null,
|
|
460
|
+
});
|
|
461
|
+
// A profile that needs no new entry needs no item; one that needs entries added is skipped by the caller when it did not
|
|
462
|
+
// supply the profile text, or edited here when it did. That refusal stays even over a ledger with rootEntries rows: code
|
|
463
|
+
// rule C13 requires the item whenever the observed profile needs one. No declare-root-entry is emitted to carry the
|
|
464
|
+
// ledger's entries rows, and the release-age row is the one entries row an apply set names an item for (an
|
|
465
|
+
// exempt-release-age item, added above when the trusted ledger records it): RENDER carries entries rows forward unchanged.
|
|
466
|
+
const profile = observation.repositoryProfile;
|
|
467
|
+
if (profile !== null &&
|
|
468
|
+
profile.rootVocabulary === "checked" &&
|
|
469
|
+
profile.undeclaredRoots.length > 0 &&
|
|
470
|
+
profile.prohibitedRoots.length === 0 &&
|
|
471
|
+
typeof observation.repositoryProfileText === "string") {
|
|
472
|
+
const sortedRoots = canonicalOrder([...new Set(profile.undeclaredRoots)], CANONICAL_KEYS.name);
|
|
473
|
+
const entries = sortedRoots.map((name) => ({ name, classification: "extension", disposition: "allowed" }));
|
|
474
|
+
const text = observation.repositoryProfileText;
|
|
475
|
+
const before = contentDigest(text);
|
|
476
|
+
if (existingAt(profile.path) !== before)
|
|
477
|
+
throw new TypeError("repositoryProfileText does not match the observed profile file");
|
|
478
|
+
let edited;
|
|
479
|
+
try {
|
|
480
|
+
edited = editJsonPointer(text, entries.map((entry) => ({ pointer: "/rootEntries/-", value: entry })));
|
|
481
|
+
}
|
|
482
|
+
catch (cause) {
|
|
483
|
+
if (cause instanceof JsonEditUnstableError)
|
|
484
|
+
return { skip: { verdict: "indeterminate", reason: "json-edit-unstable" } };
|
|
485
|
+
throw cause;
|
|
486
|
+
}
|
|
487
|
+
const pattern = PROFILE_ALLOW_PATTERNS.find((candidate) => matchesPathPattern(profile.path, candidate));
|
|
488
|
+
if (pattern !== undefined && !pathAllowList.some((allowed) => matchesPathPattern(profile.path, allowed)))
|
|
489
|
+
pathAllowList.push(pattern);
|
|
490
|
+
items.push({ id: ROOT_ENTRIES_ITEM, act: "declare-root-entry", path: profile.path, entries });
|
|
491
|
+
files.push({ path: profile.path, mode: "100644", before, after: contentDigest(edited), item: ROOT_ENTRIES_ITEM });
|
|
492
|
+
texts[profile.path] = edited;
|
|
493
|
+
}
|
|
494
|
+
else if (profile !== null && (profile.rootVocabulary === "unparseable" || profile.prohibitedRoots.length > 0)) {
|
|
495
|
+
items.push({
|
|
496
|
+
id: ROOT_ENTRIES_ITEM,
|
|
497
|
+
act: "declare-root-entry",
|
|
498
|
+
path: profile.path,
|
|
499
|
+
entries: profile.rootVocabulary === "unparseable" ? [] : profile.undeclaredRoots.map((name) => ({ name, classification: "extension", disposition: "allowed" })),
|
|
500
|
+
});
|
|
501
|
+
refused.push({ path: profile.path, reason: profile.rootVocabulary === "unparseable" ? "root-vocabulary-unknown" : "root-entry-prohibited", item: ROOT_ENTRIES_ITEM });
|
|
502
|
+
}
|
|
503
|
+
// A ledger row the desired state no longer names would be a removal, which this planner does not compute: it is reported,
|
|
504
|
+
// and RENDER carries the row forward. A files row where an entries row names its file is the compare-and-swap record of a
|
|
505
|
+
// file the flow edited (a release-age surface or the Controller profile), carried with its entries, not a removal.
|
|
506
|
+
let unnamed = false;
|
|
507
|
+
if (ledger !== null) {
|
|
508
|
+
const named = new Set([...files.map((file) => file.path), ...refused.flatMap((refusal) => ("path" in refusal ? [refusal.path] : []))].map((path) => path.toLowerCase()));
|
|
509
|
+
const edited = new Set(ledger.entries.map((row) => row.file.toLowerCase()));
|
|
510
|
+
const pointers = new Set(acts.map((act) => dependencyPointer(act.placement, act.name)));
|
|
511
|
+
const planItems = new Set(acts.map((act) => act.planItem));
|
|
512
|
+
unnamed =
|
|
513
|
+
ledger.files.some((row) => !named.has(row.path.toLowerCase()) && !edited.has(row.path.toLowerCase())) ||
|
|
514
|
+
ledger.keys.some((row) => !pointers.has(row.pointer)) ||
|
|
515
|
+
ledger.packages.some((row) => !planItems.has(row.planItem));
|
|
516
|
+
}
|
|
517
|
+
const checks = [];
|
|
518
|
+
const reasons = new Set(refused.map((refusal) => refusal.reason));
|
|
519
|
+
if (reasons.has("unsafe-path"))
|
|
520
|
+
checks.push({ check: "V6", verdict: "violated", rule: "unsafe-path" });
|
|
521
|
+
if (reasons.has("manifest-absent"))
|
|
522
|
+
checks.push({ check: "V6", verdict: "indeterminate", rule: "manifest-absent" });
|
|
523
|
+
if (reasons.has("root-vocabulary-unknown"))
|
|
524
|
+
checks.push({ check: "V6", verdict: "indeterminate", rule: "root-vocabulary-unknown" });
|
|
525
|
+
if (reasons.has("root-entry-prohibited"))
|
|
526
|
+
checks.push({ check: "V6", verdict: "indeterminate", rule: "root-entry-prohibited" });
|
|
527
|
+
if (reasons.has("skills-root-is-link"))
|
|
528
|
+
checks.push({ check: "V6", verdict: "indeterminate", rule: "skills-root-is-link" });
|
|
529
|
+
if (reasons.has("release-age-surface-conflict"))
|
|
530
|
+
checks.push({ check: "V6", verdict: "indeterminate", rule: "release-age-surface-conflict" });
|
|
531
|
+
if (reasons.has("release-age-surface-unparseable"))
|
|
532
|
+
checks.push({ check: "V6", verdict: "indeterminate", rule: "release-age-surface-unparseable" });
|
|
533
|
+
// V6 also regenerates the lockfile and checks its invariants; that part is not run here, so a set that changes a lockfile is not satisfied.
|
|
534
|
+
if (files.some((file) => "derived" in file && file.path !== LEDGER_PATH))
|
|
535
|
+
checks.push({ check: "V6", verdict: "indeterminate", rule: "lockfile-not-run" });
|
|
536
|
+
if (checks.length === 0)
|
|
537
|
+
checks.push({ check: "V6", verdict: "satisfied" });
|
|
538
|
+
// V8, ledger and ownership (RFC §7): every write compare-and-swaps against the trusted ledger, and a refused path or key
|
|
539
|
+
// holds the repository. Computed independently of V6.
|
|
540
|
+
const ownership = [];
|
|
541
|
+
for (const rule of ["unowned-existing", "client-edited", "deleted"]) {
|
|
542
|
+
if (reasons.has(rule))
|
|
543
|
+
ownership.push({ check: "V8", verdict: "indeterminate", rule });
|
|
544
|
+
}
|
|
545
|
+
if (unnamed)
|
|
546
|
+
ownership.push({ check: "V8", verdict: "indeterminate", rule: "removal-unbuilt" });
|
|
547
|
+
if (ownership.length === 0)
|
|
548
|
+
ownership.push({ check: "V8", verdict: "satisfied" });
|
|
549
|
+
checks.push(...ownership);
|
|
550
|
+
return {
|
|
551
|
+
changeSet: {
|
|
552
|
+
schemaVersion: 1,
|
|
553
|
+
kind: "clossys.repository-change-set",
|
|
554
|
+
producer: { name: inputs.producer.name, version: inputs.producer.version },
|
|
555
|
+
planDigest: planDigestValue,
|
|
556
|
+
repository: {
|
|
557
|
+
id: observation.id,
|
|
558
|
+
nodeId: observation.nodeId,
|
|
559
|
+
visibility: observation.visibility,
|
|
560
|
+
defaultBranch: observation.defaultBranch,
|
|
561
|
+
baseCommit: observation.baseCommit,
|
|
562
|
+
},
|
|
563
|
+
ledger: { generation },
|
|
564
|
+
phase: observation.phase,
|
|
565
|
+
engine: { name: inputs.engine.name, version: inputs.engine.version, integrity: inputs.engine.integrity },
|
|
566
|
+
integrator: { name: inputs.integrator.name, version: inputs.integrator.version, integrity: inputs.integrator.integrity },
|
|
567
|
+
observed: {
|
|
568
|
+
packageManager: observation.packageManager,
|
|
569
|
+
lockfile: observation.lockfile,
|
|
570
|
+
releaseAgeSurfaces: canonicalOrder(observation.releaseAgeSurfaces.map((surface) => ({ surface: surface.surface, path: surface.path })), CANONICAL_KEYS.surface),
|
|
571
|
+
consumerCi: observation.consumerCi,
|
|
572
|
+
symlinkedSkillRoots: canonicalOrder([...new Set(observation.symlinkedSkillRoots)], CANONICAL_KEYS.root),
|
|
573
|
+
repositoryProfile: profile === null
|
|
574
|
+
? null
|
|
575
|
+
: {
|
|
576
|
+
path: profile.path,
|
|
577
|
+
rootVocabulary: profile.rootVocabulary,
|
|
578
|
+
undeclaredRoots: canonicalOrder([...new Set(profile.undeclaredRoots)], CANONICAL_KEYS.name),
|
|
579
|
+
prohibitedRoots: canonicalOrder([...new Set(profile.prohibitedRoots)], CANONICAL_KEYS.name),
|
|
580
|
+
},
|
|
581
|
+
linkedAgentsPaths: canonicalOrder([...new Set(observation.linkedAgentsPaths)], CANONICAL_KEYS.name),
|
|
582
|
+
},
|
|
583
|
+
// Every array whose order carries no meaning is written in the contract's canonical order (code rule C8).
|
|
584
|
+
items: canonicalOrder(items, CANONICAL_KEYS.item),
|
|
585
|
+
files: canonicalOrder(files, CANONICAL_KEYS.file),
|
|
586
|
+
keys: canonicalOrder(keys, CANONICAL_KEYS.key),
|
|
587
|
+
refused: canonicalOrder(refused, CANONICAL_KEYS.refusal),
|
|
588
|
+
// A setup set defers each install until after setup, and an apply set defers nothing (code rule C10).
|
|
589
|
+
deferred: canonicalOrder(deferred, CANONICAL_KEYS.deferral),
|
|
590
|
+
pathAllowList: canonicalOrder(pathAllowList, CANONICAL_KEYS.pattern),
|
|
591
|
+
...(Object.keys(texts).length > 0
|
|
592
|
+
? { texts: canonicalOrder(Object.entries(texts).map(([path, text]) => ({ path, text })), (entry) => [entry.path]) }
|
|
593
|
+
: {}),
|
|
594
|
+
},
|
|
595
|
+
checks,
|
|
596
|
+
};
|
|
597
|
+
}
|
|
598
|
+
/**
|
|
599
|
+
* Computes one change set per staffed repository and the report-mode bundle
|
|
600
|
+
* holding them, from observations only. Pure: it reads no file, clock,
|
|
601
|
+
* network or process, and the same inputs give the same bytes.
|
|
602
|
+
*
|
|
603
|
+
* - Each repository's items are the brief (its projection of the hub brief,
|
|
604
|
+
* with the public placeholder unless it is private), the skills of the
|
|
605
|
+
* Advisor voice and of its staffed roles (D33: compose-skills roles are
|
|
606
|
+
* `advisor` then the staffed roles in plan order; the brief's staffedHere
|
|
607
|
+
* names the staffed roles only) with their discovery links and manifest,
|
|
608
|
+
* every package act the plan names for it, and the ledger. Every act the
|
|
609
|
+
* plan authorizes is an item, never dropped, and no act the plan does not
|
|
610
|
+
* name is ever added.
|
|
611
|
+
* - Trust first: the base's ledger (`ledger`, null when absent) must pass
|
|
612
|
+
* trustInstalledLedger() against `heldChangeSets`, or the repository is
|
|
613
|
+
* skipped with the rule as its reason (`ledger-unreadable`, `identity`,
|
|
614
|
+
* `renamed`, `ledger-chain` or `ledger-foreign-row`), verdict
|
|
615
|
+
* indeterminate. The set is computed over the trusted ledger's generation
|
|
616
|
+
* (0 without one).
|
|
617
|
+
* - Every whole file goes through reconcileWholeFile(), the compare-and-swap
|
|
618
|
+
* table: add where neither the ledger nor the base has the path; keep or
|
|
619
|
+
* update where the base holds the ledger's `after`; otherwise refuse the
|
|
620
|
+
* path as `unowned-existing`, `client-edited` or `deleted`. Adoption of
|
|
621
|
+
* bytes the base already has happens only in a setup set. A package key
|
|
622
|
+
* follows the same table against the ledger's keys rows; a key that
|
|
623
|
+
* already holds the desired version the flow wrote, while the lockfile
|
|
624
|
+
* does not resolve it at the plan's integrity, skips the repository as
|
|
625
|
+
* `integrity-mismatch`, verdict violated: never repaired.
|
|
626
|
+
* - In an apply set the setup templates are no-ops: a template act whose
|
|
627
|
+
* files the trusted ledger records is an item whose files are kept (or
|
|
628
|
+
* refused as `client-edited` or `deleted`); one it records none of has no
|
|
629
|
+
* item; one it records only some of skips the repository as
|
|
630
|
+
* `template-rows-partial`. A pnpm apply set carries the release-age
|
|
631
|
+
* exemption item, with no file, when the trusted ledger records that
|
|
632
|
+
* entry, so it matches the setup set's item; it adds no other entries.
|
|
633
|
+
* - A setup set holds one item for each of the four setup templates, their
|
|
634
|
+
* bytes only from renderSetupTemplate(), the plan's one Starter pin, and
|
|
635
|
+
* every install the plan names as a deferral (`after-setup`) with no item,
|
|
636
|
+
* key or invariant. A template file the base already has is adopted only
|
|
637
|
+
* when its bytes are the set's own (else `unowned-existing`), and a
|
|
638
|
+
* composed skill only when the skills manifest records its digest. For
|
|
639
|
+
* pnpm it also holds one `release-age` item: the workspace file is created
|
|
640
|
+
* or edited by editReleaseAgeExemption() over the observed text (`before`
|
|
641
|
+
* the observed digest, or null), left alone when the entry is already
|
|
642
|
+
* listed, or refused (`release-age-surface-unparseable`,
|
|
643
|
+
* `release-age-surface-conflict`, with a V6 `indeterminate` check).
|
|
644
|
+
* - Two or more observed files at the same path, compared case-insensitively
|
|
645
|
+
* (or a repeated entry), have no single base digest between them: the path
|
|
646
|
+
* is occupied by other bytes than any one of them, so it is never kept,
|
|
647
|
+
* updated or adopted -- `client-edited` with a trusted row, `unowned-existing`
|
|
648
|
+
* without one. When the lockfile's own path or the ledger's has a case
|
|
649
|
+
* variant, the whole repository is skipped as `case-variant-path`, verdict
|
|
650
|
+
* indeterminate, the same way as `template-rows-partial`.
|
|
651
|
+
* - A trusted ledger row the desired state no longer names is not removed:
|
|
652
|
+
* V8 reports `removal-unbuilt`, and the row is carried forward.
|
|
653
|
+
* - V8 carries one indeterminate check per ownership refusal reason present
|
|
654
|
+
* (`unowned-existing`, `client-edited`, `deleted`) and for
|
|
655
|
+
* `removal-unbuilt`, and is satisfied otherwise; V6 is computed apart from
|
|
656
|
+
* it.
|
|
657
|
+
* - A repository whose Controller profile needs root entries added is
|
|
658
|
+
* edited when `repositoryProfileText` is present, and skipped as
|
|
659
|
+
* `root-entry-edit-unbuilt` when that text is absent. A profile that is
|
|
660
|
+
* unparseable, or that prohibits a root name the set introduces, gets a
|
|
661
|
+
* declare-root-entry item refused as `root-vocabulary-unknown` or
|
|
662
|
+
* `root-entry-prohibited`.
|
|
663
|
+
* - A role's skill under a symbolic link (`.agents`, `.agents/skills` or its
|
|
664
|
+
* own directory) is refused as `skills-root-is-link`, never written.
|
|
665
|
+
* - A package act the default branch already satisfies exactly is kept as an
|
|
666
|
+
* item with `satisfiedInBase: true` and writes nothing.
|
|
667
|
+
* - A setup repository is skipped, `indeterminate`, as
|
|
668
|
+
* `package-manager-unsupported` (neither npm nor pnpm), `starter-pin-absent`
|
|
669
|
+
* (no single pin-starter act), `starter-pin-unsupported` (a pin outside the
|
|
670
|
+
* templates' range), `starter-request-invalid` (a request the renderer
|
|
671
|
+
* refuses) or `release-age-text-absent` (a pnpm workspace file or `.npmrc`
|
|
672
|
+
* is there and its text was not supplied). An apply set whose pin-starter
|
|
673
|
+
* writes a key is skipped as `starter-request-stale`.
|
|
674
|
+
* - A staffed repository with no observation, with a skip reason, whose
|
|
675
|
+
* ledger is not trusted, whose profile needs root entries added and has no
|
|
676
|
+
* text, or skipped for any reason above, `integrity-mismatch`,
|
|
677
|
+
* `template-rows-partial` or `case-variant-path` is left out of the bundle
|
|
678
|
+
* digest.
|
|
679
|
+
*
|
|
680
|
+
* Throws, naming positions and never values, when the release-age text an
|
|
681
|
+
* observation supplies is not the file it digests, when the plan or hub brief
|
|
682
|
+
* does not validate, the plan has no staffing, a staffed role is not a lowercase id
|
|
683
|
+
* token (`role-not-an-id`), a package act's planItem is not its repository id,
|
|
684
|
+
* a colon and its package name (`plan-item-not-derived`), the hub brief has `staffedHere`, an
|
|
685
|
+
* observation repeats or names an unstaffed repository, a staffed role or
|
|
686
|
+
* the Advisor voice has no skill content, or a computed set or the bundle
|
|
687
|
+
* fails its contract.
|
|
688
|
+
*/
|
|
689
|
+
export function planApplyBundle(inputs) {
|
|
690
|
+
const planValidation = validateAdvisorPlan(inputs.plan);
|
|
691
|
+
if (!planValidation.valid)
|
|
692
|
+
throw new TypeError(`the plan does not validate: ${planValidation.reason}`);
|
|
693
|
+
const staffing = inputs.plan.staffing;
|
|
694
|
+
if (staffing === undefined)
|
|
695
|
+
throw new TypeError("the plan has no staffing, so no repository has a change set");
|
|
696
|
+
const briefValidation = validateEngagementBrief(inputs.hubBrief);
|
|
697
|
+
if (!briefValidation.valid)
|
|
698
|
+
throw new TypeError(`the hub brief does not validate: ${briefValidation.reason}`);
|
|
699
|
+
if (inputs.hubBrief.staffedHere !== undefined)
|
|
700
|
+
throw new TypeError("the hub brief must not have staffedHere; each repository's brief is projected from it");
|
|
701
|
+
// Plan text never reaches a public ledger: a role becomes part of paths, and a planItem is written as it is (code rules C16, L5 and L10).
|
|
702
|
+
staffing.forEach((entry, index) => {
|
|
703
|
+
entry.roles.forEach((role, at) => {
|
|
704
|
+
if (!ID_TOKEN.test(role))
|
|
705
|
+
throw new TypeError(`staffing[${index}].roles[${at}] is not a lowercase id token (role-not-an-id)`);
|
|
706
|
+
});
|
|
707
|
+
});
|
|
708
|
+
(inputs.plan.packages ?? []).forEach((act, index) => {
|
|
709
|
+
if (act.planItem !== derivedPlanItem(act.repository, act.name)) {
|
|
710
|
+
throw new TypeError(`packages[${index}].planItem is not the repository id, a colon and the package name (plan-item-not-derived)`);
|
|
711
|
+
}
|
|
712
|
+
});
|
|
713
|
+
const skillContent = new Map();
|
|
714
|
+
inputs.skills.forEach((skill, index) => {
|
|
715
|
+
if (skillContent.has(skill.role))
|
|
716
|
+
throw new TypeError(`skills[${index}] repeats a role`);
|
|
717
|
+
skillContent.set(skill.role, skill.content);
|
|
718
|
+
});
|
|
719
|
+
// The hub carries every voice, whatever any one repository staffs (D33): a missing Advisor voice is a hub defect.
|
|
720
|
+
if (!skillContent.has(ADVISOR_VOICE))
|
|
721
|
+
throw new TypeError("the Advisor voice has no composed skill content in skills");
|
|
722
|
+
const staffedIds = new Set(staffing.map((entry) => entry.repository));
|
|
723
|
+
const observations = new Map();
|
|
724
|
+
inputs.repositories.forEach((entry, index) => {
|
|
725
|
+
if (!staffedIds.has(entry.id))
|
|
726
|
+
throw new TypeError(`repositories[${index}] is not a repository the plan staffs (ids must be spelled as in staffing)`);
|
|
727
|
+
if (observations.has(entry.id))
|
|
728
|
+
throw new TypeError(`repositories[${index}] repeats a repository`);
|
|
729
|
+
observations.set(entry.id, entry);
|
|
730
|
+
});
|
|
731
|
+
const digestOfPlan = planDigest(inputs.plan);
|
|
732
|
+
const computed = [];
|
|
733
|
+
const entries = [];
|
|
734
|
+
for (const [staffingIndex, staffingEntry] of staffing.entries()) {
|
|
735
|
+
const observation = observations.get(staffingEntry.repository);
|
|
736
|
+
if (observation === undefined || isSkipped(observation)) {
|
|
737
|
+
entries.push({
|
|
738
|
+
id: staffingEntry.repository,
|
|
739
|
+
verdict: observation?.verdict ?? "indeterminate",
|
|
740
|
+
reason: observation === undefined ? "not-observed" : observation.skipped,
|
|
741
|
+
checks: [],
|
|
742
|
+
});
|
|
743
|
+
continue;
|
|
744
|
+
}
|
|
745
|
+
checkSurfaceTexts(observation);
|
|
746
|
+
// A ledger the hub cannot account for refuses the whole repository, and nothing is inferred from it (RFC §12.2, §12.6).
|
|
747
|
+
const repositoryPackages = (inputs.plan.packages ?? [])
|
|
748
|
+
.filter((act) => act.repository === staffingEntry.repository)
|
|
749
|
+
.map(({ planItem, act, name, version, integrity, placement }) => ({ planItem, act, name, version, integrity, placement }));
|
|
750
|
+
const planPackageActs = [{ planDigest: digestOfPlan, packages: repositoryPackages }, ...(inputs.planPackageActs ?? [])];
|
|
751
|
+
const trust = trustInstalledLedger(observation.ledger, { id: observation.id, nodeId: observation.nodeId }, inputs.heldChangeSets, { planPackageActs });
|
|
752
|
+
if (trust.state === "refused") {
|
|
753
|
+
entries.push({ id: staffingEntry.repository, verdict: "indeterminate", reason: trust.rule, checks: [] });
|
|
754
|
+
continue;
|
|
755
|
+
}
|
|
756
|
+
const acts = (inputs.plan.packages ?? []).filter((act) => act.repository === staffingEntry.repository);
|
|
757
|
+
let setupTemplates = null;
|
|
758
|
+
if (observation.phase === "setup") {
|
|
759
|
+
// A setup set holds the setup templates (code rule C11); anything that stops them being rendered or the pin being safe is a skip.
|
|
760
|
+
const prepared = prepareSetup(observation, acts);
|
|
761
|
+
if ("skip" in prepared) {
|
|
762
|
+
entries.push({ id: staffingEntry.repository, verdict: "indeterminate", reason: prepared.skip, checks: [] });
|
|
763
|
+
continue;
|
|
764
|
+
}
|
|
765
|
+
setupTemplates = prepared.templates;
|
|
766
|
+
}
|
|
767
|
+
const profile = observation.repositoryProfile;
|
|
768
|
+
if (profile !== null &&
|
|
769
|
+
profile.rootVocabulary === "checked" &&
|
|
770
|
+
profile.undeclaredRoots.length > 0 &&
|
|
771
|
+
profile.prohibitedRoots.length === 0 &&
|
|
772
|
+
(observation.repositoryProfileText === null || observation.repositoryProfileText === undefined)) {
|
|
773
|
+
entries.push({ id: staffingEntry.repository, verdict: "indeterminate", reason: "root-entry-edit-unbuilt", checks: [] });
|
|
774
|
+
continue;
|
|
775
|
+
}
|
|
776
|
+
const result = computeChangeSet(inputs, digestOfPlan, observation, trust.ledger, staffingEntry.roles, acts, skillContent, setupTemplates);
|
|
777
|
+
if ("skip" in result) {
|
|
778
|
+
entries.push({ id: staffingEntry.repository, verdict: result.skip.verdict, reason: result.skip.reason, checks: [] });
|
|
779
|
+
continue;
|
|
780
|
+
}
|
|
781
|
+
const { changeSet, checks } = result;
|
|
782
|
+
const digest = changeSetDigest(changeSet);
|
|
783
|
+
const short = digest.slice("sha256:".length, "sha256:".length + 12);
|
|
784
|
+
const set = { ...changeSet, branch: `clossys/apply-${short}`, pullRequest: { title: `Clossys: apply plan ${short}` }, changeSetDigest: digest };
|
|
785
|
+
entries.push({ pending: computed.length });
|
|
786
|
+
computed.push({ id: staffingEntry.repository, staffingIndex, phase: observation.phase, set, checks });
|
|
787
|
+
}
|
|
788
|
+
const authorizationMismatch = inputs.authorization !== null && inputs.authorization.planDigest !== digestOfPlan;
|
|
789
|
+
// Package acts need an execution authorization; a plan that has them and no authorization permits none of them (code rule A4).
|
|
790
|
+
const authorizationAbsent = inputs.plan.packages !== undefined && inputs.authorization === null;
|
|
791
|
+
const digestOfBundle = bundleDigest(digestOfPlan, computed.map((entry) => ({ id: entry.id, changeSetDigest: entry.set.changeSetDigest })));
|
|
792
|
+
const changeSets = computed.map((entry) => ({ ...entry.set, bundle: digestOfBundle }));
|
|
793
|
+
changeSets.forEach((set, index) => {
|
|
794
|
+
const validation = validateRepositoryChangeSet(set);
|
|
795
|
+
if (!validation.valid)
|
|
796
|
+
throw new Error(`the change set computed for staffed repository ${computed[index].staffingIndex} does not validate: ${validation.reason}`);
|
|
797
|
+
});
|
|
798
|
+
const bundle = {
|
|
799
|
+
schemaVersion: 1,
|
|
800
|
+
kind: "clossys.apply-bundle",
|
|
801
|
+
mode: "report",
|
|
802
|
+
plan: { path: "clossys/advisor/plan.json", digest: digestOfPlan, committed: inputs.planCommitted },
|
|
803
|
+
snapshot: inputs.plan.resolution === undefined ? null : { path: "clossys/.state/apply/registry-snapshot.json", digest: inputs.plan.resolution.snapshotDigest },
|
|
804
|
+
engine: { name: inputs.engine.name, version: inputs.engine.version, integrity: inputs.engine.integrity },
|
|
805
|
+
authorization: inputs.authorization === null ? null : { planDigest: inputs.authorization.planDigest, expiresAt: inputs.authorization.expiresAt },
|
|
806
|
+
computedAt: inputs.computedAt,
|
|
807
|
+
repositories: entries.map((entry) => {
|
|
808
|
+
if (!("pending" in entry))
|
|
809
|
+
return entry;
|
|
810
|
+
const { id, phase, set, checks: own } = computed[entry.pending];
|
|
811
|
+
// Pure checks that need no observation (code rule A4): an authorization issued for another plan permits none of this one,
|
|
812
|
+
// and a plan with package acts and no authorization permits none of them.
|
|
813
|
+
const authority = [
|
|
814
|
+
...(authorizationMismatch ? [{ check: "V3", verdict: "violated", rule: AUTHORIZATION_PLAN_MISMATCH }] : []),
|
|
815
|
+
...(authorizationAbsent ? [{ check: "V3", verdict: "violated", rule: AUTHORIZATION_ABSENT }] : []),
|
|
816
|
+
];
|
|
817
|
+
const checks = sortChecks([...own, ...authority]);
|
|
818
|
+
return { id, verdict: worstVerdict(checks.map((check) => check.verdict)), phase, changeSet: set.changeSetDigest, checks };
|
|
819
|
+
}),
|
|
820
|
+
bundleDigest: digestOfBundle,
|
|
821
|
+
};
|
|
822
|
+
const bundleValidation = validateApplyBundle(bundle);
|
|
823
|
+
if (!bundleValidation.valid)
|
|
824
|
+
throw new Error(`the computed bundle does not validate: ${bundleValidation.reason}`);
|
|
825
|
+
return { bundle, changeSets };
|
|
826
|
+
}
|
|
827
|
+
//# sourceMappingURL=plan-bundle.js.map
|