@kungfu-tech/buildchain 4.1.3-alpha.0 → 4.1.3-alpha.2
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/architecture/action-taxonomy.json +16 -0
- package/architecture/agent-change-map.md +245 -4
- package/architecture/ci-lane-change-budget.json +289 -0
- package/architecture/decisions/0005-minimal-consumer-contract.md +122 -0
- package/architecture/decisions/0006-business-attempt-journal.md +89 -0
- package/architecture/decisions/0007-hosted-pipeline-controller.md +106 -0
- package/architecture/decisions/0008-pipeline-product-publication.md +99 -0
- package/architecture/internal-capabilities.json +340 -4
- package/architecture/maintainability-debt.json +3 -1
- package/architecture/maintainability-policy.json +8 -8
- package/architecture/minimal-consumer-migration.json +7558 -0
- package/architecture/release-topology.json +248 -11
- package/architecture/universal-workflow-bootstrap.json +10 -0
- package/architecture/universal-workflow-capability-policy.json +6 -6
- package/architecture/workflow-taxonomy.json +55 -0
- package/contracts/release-discussion-v1.schema.json +3 -0
- package/dist/readers/business-attempt.cjs +805 -0
- package/dist/readers/release-discussion.cjs +4 -0
- package/dist/site/buildchain-contract.json +7 -5
- package/dist/site/buildchain-site.json +25 -15
- package/dist/site/capability-registry.json +3 -3
- package/dist/site/kfd-claims.json +311 -10
- package/dist/site/kfd-upstream-aggregate.json +1 -1
- package/dist/site/manual-registry.json +1 -1
- package/dist/site/node-api-registry.json +9 -9
- package/dist/site/page-registry.json +20 -10
- package/dist/site/public-surface-audit.json +201 -7
- package/dist/site/publication-authority-registry.json +121 -1
- package/dist/site/publication-registry.json +4 -4
- package/dist/site/site-manifest.json +5 -5
- package/dist/site/workflow-registry.json +416 -7
- package/docs/node-api-reference.md +12 -12
- package/docs/release-discussions.md +44 -5
- package/docs/runtime-entry.md +45 -5
- package/docs/workflow-catalog.md +5 -0
- package/package.json +4 -3
- package/packages/core/consumer/contract/entries.js +85 -0
- package/packages/core/consumer/contract/examples.js +96 -0
- package/packages/core/consumer/contract/identity.js +65 -0
- package/packages/core/consumer/contract/inspection.js +45 -0
- package/packages/core/consumer/contract/plan.js +99 -0
- package/packages/core/consumer/contract/products.js +101 -0
- package/packages/core/consumer/contract/shape.js +46 -0
- package/packages/core/dev-delivery/candidate/admission.js +9 -5
- package/packages/core/dev-delivery/candidate/reservation.js +11 -0
- package/packages/core/dev-delivery/candidate/source-paths.js +12 -4
- package/packages/core/dev-delivery/native/actions.js +7 -1
- package/packages/core/dev-delivery/native/heartbeat-action.js +16 -0
- package/packages/core/dev-delivery/native/heartbeat.js +6 -4
- package/packages/core/dev-delivery/queue/landing-action.js +5 -0
- package/packages/core/governance/buildchain-publication-authority.js +5 -0
- package/packages/core/paper/operations/bootstrap.js +2 -0
- package/packages/core/paper/paper-npm-bootstrap.js +2 -2
- package/packages/core/providers/github/attempt-index.js +60 -0
- package/packages/core/providers/github/attempt-journal.js +154 -0
- package/packages/core/providers/github/discussions/materials.js +23 -7
- package/packages/core/providers/github/discussions/transport.js +16 -5
- package/packages/core/providers/github/pipeline-checkout.js +49 -0
- package/packages/core/providers/github/pipeline-events.js +52 -0
- package/packages/core/providers/github/pipeline-integration.js +95 -0
- package/packages/core/providers/github/pipeline-policy.js +213 -0
- package/packages/core/providers/github/pipeline-product-payload.js +50 -0
- package/packages/core/providers/github/pipeline-product-release.js +191 -0
- package/packages/core/providers/github/pipeline-publication-artifacts.js +153 -0
- package/packages/core/providers/github/pipeline-run-entry.js +86 -0
- package/packages/core/providers/github/pipeline-runs.js +132 -0
- package/packages/core/providers/github/pipeline-source.js +178 -0
- package/packages/core/providers/github/pipeline-version.js +201 -0
- package/packages/core/providers/github/pipeline-worker.js +108 -0
- package/packages/core/publication/candidate/registry-hydration.js +2 -2
- package/packages/core/publication/npm/pack-preview.js +2 -2
- package/packages/core/publication/npm/pack-result.js +31 -0
- package/packages/core/publication/npm/package.js +3 -6
- package/packages/core/publication/npm/pipeline-channel.js +87 -0
- package/packages/core/publication/npm/pipeline-provider.js +109 -0
- package/packages/core/publication/npm/registry.js +12 -9
- package/packages/core/publication/pipeline/actions.js +100 -0
- package/packages/core/publication/pipeline/apply.js +182 -0
- package/packages/core/publication/pipeline/build-segments.js +107 -0
- package/packages/core/publication/pipeline/capsules.js +89 -0
- package/packages/core/publication/pipeline/context.js +39 -0
- package/packages/core/publication/pipeline/development-anchor.js +91 -0
- package/packages/core/publication/pipeline/development-current.js +63 -0
- package/packages/core/publication/pipeline/development-pr.js +85 -0
- package/packages/core/publication/pipeline/development-proof.js +76 -0
- package/packages/core/publication/pipeline/development-transition.js +112 -0
- package/packages/core/publication/pipeline/distribution.js +124 -0
- package/packages/core/publication/pipeline/documents.js +156 -0
- package/packages/core/publication/pipeline/effects.js +162 -0
- package/packages/core/publication/pipeline/files.js +66 -0
- package/packages/core/publication/pipeline/imported-materials.js +32 -0
- package/packages/core/publication/pipeline/journal.js +110 -0
- package/packages/core/publication/pipeline/next-development.js +116 -0
- package/packages/core/publication/pipeline/pack.js +151 -0
- package/packages/core/publication/pipeline/package-policy.js +22 -0
- package/packages/core/publication/pipeline/plan.js +114 -0
- package/packages/core/publication/pipeline/prepare.js +151 -0
- package/packages/core/publication/pipeline/qualification.js +170 -0
- package/packages/core/publication/pipeline/qualify.js +104 -0
- package/packages/core/publication/pipeline/recovery-admission.js +122 -0
- package/packages/core/publication/pipeline/recovery-build-download.js +80 -0
- package/packages/core/publication/pipeline/recovery-build-plan.js +122 -0
- package/packages/core/publication/pipeline/recovery-build-readback.js +68 -0
- package/packages/core/publication/pipeline/recovery-capsules.js +54 -0
- package/packages/core/publication/pipeline/recovery-import.js +80 -0
- package/packages/core/publication/pipeline/recovery-inspection.js +218 -0
- package/packages/core/publication/pipeline/recovery-materials.js +104 -0
- package/packages/core/publication/pipeline/recovery-plan.js +46 -0
- package/packages/core/publication/pipeline/recovery-prepare.js +73 -0
- package/packages/core/publication/pipeline/recovery-qualification.js +128 -0
- package/packages/core/publication/pipeline/recovery-readback.js +56 -0
- package/packages/core/publication/pipeline/recovery-signing.js +58 -0
- package/packages/core/publication/pipeline/sealed-products.js +79 -0
- package/packages/core/publication/pipeline/settle.js +43 -0
- package/packages/core/publication/pipeline/signing.js +140 -0
- package/packages/core/publication/pipeline/source-plan.js +83 -0
- package/packages/core/publication/pipeline/version.js +81 -0
- package/packages/core/publication/publication-reproducibility.js +2 -2
- package/packages/core/release/discussion/binary.js +11 -0
- package/packages/core/release/discussion/checkpoints.js +45 -4
- package/packages/core/release/discussion/envelope.js +1 -0
- package/packages/core/release/discussion/evidence.js +51 -0
- package/packages/core/release/discussion/presentation.js +92 -0
- package/packages/core/release/discussion/publication.js +3 -1
- package/packages/core/release/discussion/qualification.js +1 -0
- package/packages/core/release/discussion/reader.js +9 -0
- package/packages/core/release/discussion/session.js +2 -0
- package/packages/core/release/discussion/store.js +28 -31
- package/packages/core/release/discussion/threads.js +83 -0
- package/packages/core/release/promote-candidate/product-provider-adapters.js +4 -3
- package/packages/core/release/promote-ref/internal/npm-existing-evidence.js +2 -1
- package/packages/core/release/promote-ref/internal/publish-command.js +16 -5
- package/packages/core/runtime/buildchain-domain.wasm +0 -0
- package/packages/core/runtime/domain-wasm-artifact.js +2 -2
- package/packages/core/runtime/entry/actions.js +27 -25
- package/packages/core/runtime/entry/attempt.js +25 -0
- package/packages/core/runtime/entry/selection.js +14 -0
- package/packages/core/runtime/entry/source.js +62 -0
- package/packages/core/workflow/attempt/identity.js +165 -0
- package/packages/core/workflow/attempt/journal.js +87 -0
- package/packages/core/workflow/attempt/materials.js +72 -0
- package/packages/core/workflow/attempt/reader-entry.js +12 -0
- package/packages/core/workflow/attempt/reader.js +149 -0
- package/packages/core/workflow/attempt/records.js +153 -0
- package/packages/core/workflow/attempt/store.js +75 -0
- package/packages/core/workflow/commands/pipeline-build.mjs +23 -0
- package/packages/core/workflow/pipeline/action-output.js +11 -0
- package/packages/core/workflow/pipeline/actions.js +62 -0
- package/packages/core/workflow/pipeline/build-control.js +140 -0
- package/packages/core/workflow/pipeline/build-evidence.js +92 -0
- package/packages/core/workflow/pipeline/build-qualification.js +145 -0
- package/packages/core/workflow/pipeline/build-result.js +69 -0
- package/packages/core/workflow/pipeline/build.js +95 -0
- package/packages/core/workflow/pipeline/cancellation.js +169 -0
- package/packages/core/workflow/pipeline/channel-control.js +127 -0
- package/packages/core/workflow/pipeline/controller.js +133 -0
- package/packages/core/workflow/pipeline/delivery-control.js +204 -0
- package/packages/core/workflow/pipeline/delivery-observation.js +118 -0
- package/packages/core/workflow/pipeline/delivery-request.js +198 -0
- package/packages/core/workflow/pipeline/events.js +84 -0
- package/packages/core/workflow/pipeline/fence.js +42 -0
- package/packages/core/workflow/pipeline/group-control.js +56 -0
- package/packages/core/workflow/pipeline/guard-build.js +37 -0
- package/packages/core/workflow/pipeline/guard.js +156 -0
- package/packages/core/workflow/pipeline/host.js +114 -0
- package/packages/core/workflow/pipeline/materials.js +58 -0
- package/packages/core/workflow/pipeline/notifications.js +36 -0
- package/packages/core/workflow/pipeline/parent-notification.js +27 -0
- package/packages/core/workflow/pipeline/platforms.js +21 -0
- package/packages/core/workflow/pipeline/progress.js +108 -0
- package/packages/core/workflow/pipeline/projection.js +20 -0
- package/packages/core/workflow/pipeline/reconcile.js +194 -0
- package/packages/core/workflow/pipeline/recovery-action.js +30 -0
- package/packages/core/workflow/pipeline/recovery-admission.js +122 -0
- package/packages/core/workflow/pipeline/recovery-build-control.js +95 -0
- package/packages/core/workflow/pipeline/recovery-build-evidence.js +71 -0
- package/packages/core/workflow/pipeline/recovery-build.js +121 -0
- package/packages/core/workflow/pipeline/recovery-controller.js +91 -0
- package/packages/core/workflow/pipeline/recovery-integration.js +66 -0
- package/packages/core/workflow/pipeline/recovery-merge-proof.js +90 -0
- package/packages/core/workflow/pipeline/recovery-ownership-settlement.js +150 -0
- package/packages/core/workflow/pipeline/recovery-ownership.js +91 -0
- package/packages/core/workflow/pipeline/recovery-plan.js +126 -0
- package/packages/core/workflow/pipeline/recovery-runtime.js +68 -0
- package/packages/core/workflow/pipeline/recovery-session.js +129 -0
- package/packages/core/workflow/pipeline/recovery-transition.js +135 -0
- package/packages/core/workflow/pipeline/recovery-unrecorded-build.js +74 -0
- package/packages/core/workflow/pipeline/runtime-source.js +111 -0
- package/packages/core/workflow/pipeline/selection.js +106 -0
- package/packages/core/workflow/pipeline/session.js +72 -0
- package/packages/core/workflow/pipeline/settlement.js +137 -0
- package/packages/core/workflow/pipeline/wake.js +38 -0
- package/packages/core/workflow/pipeline/web-status.js +108 -0
- package/scripts/build-release-discussion-reader.mjs +11 -7
- package/scripts/check-pipeline-publication-topology.mjs +36 -0
- package/scripts/check-release-topology.mjs +6 -1
- package/scripts/generate-minimal-consumer-contract.mjs +155 -0
- package/scripts/verify-golden-path.mjs +5 -1
- package/templates/minimal-consumer/binary/.buildchain/buildchain.toml +45 -0
- package/templates/minimal-consumer/binary/.github/workflows/buildchain-recover.yml +30 -0
- package/templates/minimal-consumer/binary/.github/workflows/buildchain.yml +28 -0
- package/templates/minimal-consumer/binary/package.json +6 -0
- package/templates/minimal-consumer/binary/src/build.mjs +5 -0
- package/templates/minimal-consumer/binary/src/hello.c +2 -0
- package/templates/minimal-consumer/binary/src/verify.mjs +3 -0
- package/templates/minimal-consumer/npm/.buildchain/buildchain.toml +46 -0
- package/templates/minimal-consumer/npm/.github/workflows/buildchain-recover.yml +30 -0
- package/templates/minimal-consumer/npm/.github/workflows/buildchain.yml +28 -0
- package/templates/minimal-consumer/npm/package.json +6 -0
- package/templates/minimal-consumer/npm/src/build.mjs +5 -0
- package/templates/minimal-consumer/npm/src/verify.mjs +3 -0
- package/templates/minimal-consumer/paper/.buildchain/buildchain.toml +45 -0
- package/templates/minimal-consumer/paper/.github/workflows/buildchain-recover.yml +30 -0
- package/templates/minimal-consumer/paper/.github/workflows/buildchain.yml +28 -0
- package/templates/minimal-consumer/paper/package.json +6 -0
- package/templates/minimal-consumer/paper/src/build.mjs +12 -0
- package/templates/minimal-consumer/paper/src/verify.mjs +3 -0
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createIntent,
|
|
3
|
+
recordDigest,
|
|
4
|
+
} from "../../release/discussion/envelope.js";
|
|
5
|
+
import {
|
|
6
|
+
object,
|
|
7
|
+
text,
|
|
8
|
+
list,
|
|
9
|
+
choice,
|
|
10
|
+
unique,
|
|
11
|
+
relativePath,
|
|
12
|
+
} from "../../consumer/contract/shape.js";
|
|
13
|
+
import { CONSUMER_CONTRACT } from "../../consumer/contract/plan.js";
|
|
14
|
+
|
|
15
|
+
export const PIPELINE_PHASES = [
|
|
16
|
+
"admission",
|
|
17
|
+
"build",
|
|
18
|
+
"review",
|
|
19
|
+
"warrant",
|
|
20
|
+
"merge",
|
|
21
|
+
"publish",
|
|
22
|
+
"distribution",
|
|
23
|
+
"next-development",
|
|
24
|
+
];
|
|
25
|
+
export const SOURCE_GENERATION = "buildchain.source-generation/v1";
|
|
26
|
+
export const BUSINESS_ATTEMPT = "buildchain.business-attempt/v1";
|
|
27
|
+
const ROOT = /^sha256:[0-9a-f]{64}$/u;
|
|
28
|
+
|
|
29
|
+
export function pipelineIntent({
|
|
30
|
+
repository,
|
|
31
|
+
repositoryId,
|
|
32
|
+
pullRequest,
|
|
33
|
+
targetBranch,
|
|
34
|
+
phases,
|
|
35
|
+
runtime,
|
|
36
|
+
}) {
|
|
37
|
+
text(repository, "repository", /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/u);
|
|
38
|
+
text(repositoryId, "repositoryId");
|
|
39
|
+
if (!Number.isSafeInteger(pullRequest) || pullRequest < 1)
|
|
40
|
+
throw new Error("Invalid PR intent number");
|
|
41
|
+
text(
|
|
42
|
+
targetBranch,
|
|
43
|
+
"targetBranch",
|
|
44
|
+
/^(?:dev|alpha|release|publish-gate)\/[A-Za-z0-9/._-]+$/u,
|
|
45
|
+
);
|
|
46
|
+
list(phases, "phases", (phase, field) =>
|
|
47
|
+
choice(phase, PIPELINE_PHASES, field),
|
|
48
|
+
);
|
|
49
|
+
unique(phases, "phases");
|
|
50
|
+
if (
|
|
51
|
+
phases.some(
|
|
52
|
+
(phase, i) =>
|
|
53
|
+
i &&
|
|
54
|
+
PIPELINE_PHASES.indexOf(phase) <=
|
|
55
|
+
PIPELINE_PHASES.indexOf(phases[i - 1]),
|
|
56
|
+
)
|
|
57
|
+
)
|
|
58
|
+
throw new Error("Intent phases must follow pipeline order");
|
|
59
|
+
if (phases[0] !== "admission")
|
|
60
|
+
throw new Error("Intent must begin with admission");
|
|
61
|
+
return createIntent({
|
|
62
|
+
repository,
|
|
63
|
+
key: `pr-${pullRequest}:${targetBranch}`,
|
|
64
|
+
expectedNodes: phases,
|
|
65
|
+
runtime,
|
|
66
|
+
source: { kind: "pipeline", repositoryId, pullRequest, targetBranch },
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function sourceGeneration(intent, source, baseCommit) {
|
|
71
|
+
object(
|
|
72
|
+
source,
|
|
73
|
+
[
|
|
74
|
+
"schema",
|
|
75
|
+
"repository",
|
|
76
|
+
"commit",
|
|
77
|
+
"tree",
|
|
78
|
+
"configPath",
|
|
79
|
+
"configBlob",
|
|
80
|
+
"configDigest",
|
|
81
|
+
"contract",
|
|
82
|
+
],
|
|
83
|
+
[],
|
|
84
|
+
"source",
|
|
85
|
+
);
|
|
86
|
+
if (
|
|
87
|
+
source.schema !== "buildchain.consumer-source/v1" ||
|
|
88
|
+
source.contract !== CONSUMER_CONTRACT ||
|
|
89
|
+
source.repository !== intent.repository
|
|
90
|
+
)
|
|
91
|
+
throw new Error("Generation requires the admitted consumer source");
|
|
92
|
+
for (const key of ["commit", "tree", "configBlob"])
|
|
93
|
+
text(source[key], `source.${key}`, /^[0-9a-f]{40}$/u);
|
|
94
|
+
relativePath(source.configPath, "source.configPath");
|
|
95
|
+
text(source.configDigest, "source.configDigest", ROOT);
|
|
96
|
+
text(baseCommit, "baseCommit", /^[0-9a-f]{40}$/u);
|
|
97
|
+
const value = {
|
|
98
|
+
schema: SOURCE_GENERATION,
|
|
99
|
+
intent: intent.id,
|
|
100
|
+
source: structuredClone(source),
|
|
101
|
+
baseCommit,
|
|
102
|
+
};
|
|
103
|
+
return { ...value, id: recordDigest(value) };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function validateGeneration(value, intent) {
|
|
107
|
+
object(
|
|
108
|
+
value,
|
|
109
|
+
["schema", "intent", "source", "baseCommit", "id"],
|
|
110
|
+
[],
|
|
111
|
+
"generation",
|
|
112
|
+
);
|
|
113
|
+
const expected = sourceGeneration(intent, value.source, value.baseCommit);
|
|
114
|
+
if (recordDigest(value) !== recordDigest(expected))
|
|
115
|
+
throw new Error("Generation identity mismatch");
|
|
116
|
+
return value;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function businessAttempt({
|
|
120
|
+
intent,
|
|
121
|
+
generation,
|
|
122
|
+
predecessor = "",
|
|
123
|
+
requestKey,
|
|
124
|
+
}) {
|
|
125
|
+
validateGeneration(generation, intent);
|
|
126
|
+
text(requestKey, "requestKey");
|
|
127
|
+
if (requestKey.length > 240)
|
|
128
|
+
throw new Error("Attempt request key exceeds its bound");
|
|
129
|
+
if (predecessor) text(predecessor, "predecessor", /^attempt-[0-9a-f]{64}$/u);
|
|
130
|
+
const value = {
|
|
131
|
+
schema: BUSINESS_ATTEMPT,
|
|
132
|
+
intent: intent.id,
|
|
133
|
+
generation: generation.id,
|
|
134
|
+
predecessor,
|
|
135
|
+
requestKey,
|
|
136
|
+
};
|
|
137
|
+
return { ...value, id: `attempt-${recordDigest(value).slice(7)}` };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function validateAttempt(value, intent, generation) {
|
|
141
|
+
object(
|
|
142
|
+
value,
|
|
143
|
+
["schema", "intent", "generation", "predecessor", "requestKey", "id"],
|
|
144
|
+
[],
|
|
145
|
+
"attempt",
|
|
146
|
+
);
|
|
147
|
+
const expected = businessAttempt({
|
|
148
|
+
intent,
|
|
149
|
+
generation,
|
|
150
|
+
predecessor: value.predecessor,
|
|
151
|
+
requestKey: value.requestKey,
|
|
152
|
+
});
|
|
153
|
+
if (recordDigest(value) !== recordDigest(expected))
|
|
154
|
+
throw new Error("Business attempt identity mismatch");
|
|
155
|
+
return value;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export function providerWriter(value, repository) {
|
|
159
|
+
object(value, ["repository", "runId", "runAttempt", "jobId"], [], "writer");
|
|
160
|
+
if (value.repository !== repository)
|
|
161
|
+
throw new Error("Writer belongs to a different repository");
|
|
162
|
+
for (const key of ["runId", "runAttempt", "jobId"])
|
|
163
|
+
text(value[key], `writer.${key}`, /^[1-9][0-9]*$/u);
|
|
164
|
+
return `${value.runId}:${value.runAttempt}:${value.jobId}`;
|
|
165
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import {
|
|
2
|
+
canonicalJson,
|
|
3
|
+
recordDigest,
|
|
4
|
+
} from "../../release/discussion/envelope.js";
|
|
5
|
+
import { readBusinessAttempt } from "./reader.js";
|
|
6
|
+
|
|
7
|
+
export const ATTEMPT_JOURNAL = "buildchain.attempt-journal/v1";
|
|
8
|
+
|
|
9
|
+
export function journalSnapshot(intent, records) {
|
|
10
|
+
readBusinessAttempt({ intent, records });
|
|
11
|
+
const body = { schema: ATTEMPT_JOURNAL, intent, records };
|
|
12
|
+
return { ...body, root: recordDigest(body) };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function validateJournal(snapshot, intent) {
|
|
16
|
+
if (
|
|
17
|
+
snapshot?.schema !== ATTEMPT_JOURNAL ||
|
|
18
|
+
canonicalJson(snapshot.intent) !== canonicalJson(intent) ||
|
|
19
|
+
canonicalJson(snapshot) !==
|
|
20
|
+
canonicalJson(journalSnapshot(intent, snapshot.records))
|
|
21
|
+
)
|
|
22
|
+
throw new Error("Attempt journal identity or content root drift");
|
|
23
|
+
return snapshot;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// The provider atomically appends a single-parent Git commit with force=false.
|
|
27
|
+
// Discussion is a projection of these admitted bytes, never a competing writer.
|
|
28
|
+
export function atomicAttemptJournal(provider, intent) {
|
|
29
|
+
readBusinessAttempt({ intent, records: [] });
|
|
30
|
+
async function read() {
|
|
31
|
+
const loaded = await provider.read(intent);
|
|
32
|
+
const snapshot = loaded
|
|
33
|
+
? validateJournal(loaded.snapshot, intent)
|
|
34
|
+
: journalSnapshot(intent, []);
|
|
35
|
+
return {
|
|
36
|
+
commit: loaded?.commit || "",
|
|
37
|
+
snapshot,
|
|
38
|
+
...readBusinessAttempt(snapshot),
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
async function append(record, expectedHead) {
|
|
42
|
+
const observed = await read();
|
|
43
|
+
if (observed.snapshot.records.some((event) => event.id === record.id)) {
|
|
44
|
+
// Never acknowledge an invalid body merely because its ID was copied.
|
|
45
|
+
const prior = observed.snapshot.records.find(
|
|
46
|
+
(event) => event.id === record.id,
|
|
47
|
+
);
|
|
48
|
+
if (canonicalJson(prior) !== canonicalJson(record))
|
|
49
|
+
throw new Error("Conflicting attempt record bytes");
|
|
50
|
+
return observed;
|
|
51
|
+
}
|
|
52
|
+
if ((observed.head || "") !== expectedHead)
|
|
53
|
+
throw new Error("Stale attempt head; reobserve before writing");
|
|
54
|
+
if (record.node === "attempt") {
|
|
55
|
+
if (record.predecessor !== (observed.attempt || ""))
|
|
56
|
+
throw new Error("Recovery must succeed the current attempt");
|
|
57
|
+
} else if (record.attempt !== observed.attempt)
|
|
58
|
+
throw new Error("A superseded attempt cannot append new results");
|
|
59
|
+
const snapshot = journalSnapshot(intent, [
|
|
60
|
+
...observed.snapshot.records,
|
|
61
|
+
record,
|
|
62
|
+
]);
|
|
63
|
+
let failure;
|
|
64
|
+
try {
|
|
65
|
+
await provider.append({
|
|
66
|
+
intent,
|
|
67
|
+
snapshot,
|
|
68
|
+
expectedCommit: observed.commit,
|
|
69
|
+
});
|
|
70
|
+
} catch (error) {
|
|
71
|
+
failure = error;
|
|
72
|
+
}
|
|
73
|
+
// A lost response can follow a committed append. Reconcile the immutable
|
|
74
|
+
// record in live history; never retry the old parent with force=true.
|
|
75
|
+
const result = await read();
|
|
76
|
+
if (
|
|
77
|
+
!result.snapshot.records.some(
|
|
78
|
+
(event) => canonicalJson(event) === canonicalJson(record),
|
|
79
|
+
)
|
|
80
|
+
)
|
|
81
|
+
throw (
|
|
82
|
+
failure || new Error("Attempt append missing from provider readback")
|
|
83
|
+
);
|
|
84
|
+
return result;
|
|
85
|
+
}
|
|
86
|
+
return { read, append };
|
|
87
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { object, text, choice } from "../../consumer/contract/shape.js";
|
|
3
|
+
import { recordDigest } from "../../release/discussion/envelope.js";
|
|
4
|
+
|
|
5
|
+
export const MATERIAL_KINDS = [
|
|
6
|
+
"artifact",
|
|
7
|
+
"checkpoint",
|
|
8
|
+
"receipt",
|
|
9
|
+
"passport",
|
|
10
|
+
"provider-readback",
|
|
11
|
+
];
|
|
12
|
+
|
|
13
|
+
export function materialReference(value, attempt, repository = "") {
|
|
14
|
+
object(
|
|
15
|
+
value,
|
|
16
|
+
["id", "kind", "digest", "bytes", "url", "generation", "producerAttempt"],
|
|
17
|
+
[],
|
|
18
|
+
"material",
|
|
19
|
+
);
|
|
20
|
+
text(value.id, "material.id", /^[a-z][a-z0-9._/-]*$/u);
|
|
21
|
+
choice(value.kind, MATERIAL_KINDS, "material.kind");
|
|
22
|
+
text(value.digest, "material.digest", /^sha256:[0-9a-f]{64}$/u);
|
|
23
|
+
if (!Number.isSafeInteger(value.bytes) || value.bytes < 1)
|
|
24
|
+
throw new Error("Invalid material byte size");
|
|
25
|
+
const url = new URL(value.url);
|
|
26
|
+
if (
|
|
27
|
+
url.protocol !== "https:" ||
|
|
28
|
+
url.username ||
|
|
29
|
+
url.password ||
|
|
30
|
+
url.search ||
|
|
31
|
+
url.hash ||
|
|
32
|
+
!["github.com", "api.github.com"].includes(url.hostname)
|
|
33
|
+
)
|
|
34
|
+
throw new Error(
|
|
35
|
+
"Material must use a permanent GitHub URL without credentials or signed queries",
|
|
36
|
+
);
|
|
37
|
+
if (
|
|
38
|
+
repository &&
|
|
39
|
+
!(url.hostname === "github.com"
|
|
40
|
+
? url.pathname.startsWith(`/${repository}/releases/download/`) ||
|
|
41
|
+
url.pathname.startsWith(`/${repository}/actions/runs/`)
|
|
42
|
+
: url.pathname.startsWith(`/repos/${repository}/actions/artifacts/`) ||
|
|
43
|
+
url.pathname.startsWith(`/repos/${repository}/releases/assets/`))
|
|
44
|
+
)
|
|
45
|
+
throw new Error("Material belongs to another consumer repository");
|
|
46
|
+
if (
|
|
47
|
+
value.generation !== attempt.generation ||
|
|
48
|
+
value.producerAttempt !== attempt.id
|
|
49
|
+
)
|
|
50
|
+
throw new Error(
|
|
51
|
+
"Material is not qualified for this exact attempt and generation",
|
|
52
|
+
);
|
|
53
|
+
return { ...value };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function verifyMaterialBytes(reference, bytes) {
|
|
57
|
+
const digest = `sha256:${createHash("sha256").update(bytes).digest("hex")}`;
|
|
58
|
+
if (bytes.length !== reference.bytes || digest !== reference.digest)
|
|
59
|
+
throw new Error(`Material integrity mismatch: ${reference.id}`);
|
|
60
|
+
return { id: reference.id, digest, bytes: bytes.length };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function indexMaterials(index, references, attempt) {
|
|
64
|
+
for (const reference of references) {
|
|
65
|
+
materialReference(reference, attempt);
|
|
66
|
+
const old = index[reference.id];
|
|
67
|
+
if (old && recordDigest(old) !== recordDigest(reference))
|
|
68
|
+
throw new Error(`Immutable material identity conflict: ${reference.id}`);
|
|
69
|
+
index[reference.id] = { ...reference };
|
|
70
|
+
}
|
|
71
|
+
return index;
|
|
72
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import { readBusinessAttempt } from "./reader.js";
|
|
3
|
+
export { readBusinessAttempt } from "./reader.js";
|
|
4
|
+
|
|
5
|
+
// Captured, provider-authenticated JSON only. No live ref lookup or effects.
|
|
6
|
+
if (typeof require !== "undefined" && require.main === module) {
|
|
7
|
+
const state = readBusinessAttempt(JSON.parse(fs.readFileSync(0, "utf8")));
|
|
8
|
+
const { status, reason, attempt, generation, missing, recovery } = state;
|
|
9
|
+
process.stdout.write(
|
|
10
|
+
`${JSON.stringify({ status, reason, attempt, generation, missing, recovery })}\n`,
|
|
11
|
+
);
|
|
12
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import {
|
|
2
|
+
canonicalJson,
|
|
3
|
+
encodeRecord,
|
|
4
|
+
recordDigest,
|
|
5
|
+
} from "../../release/discussion/envelope.js";
|
|
6
|
+
import { readReleaseDiscussion } from "../../release/discussion/reader.js";
|
|
7
|
+
import { pipelineIntent } from "./identity.js";
|
|
8
|
+
import { validateAttemptRecord } from "./records.js";
|
|
9
|
+
import { indexMaterials } from "./materials.js";
|
|
10
|
+
|
|
11
|
+
export const MAX_ATTEMPT_RECORDS = 10_000;
|
|
12
|
+
const MAX_HISTORY_BYTES = 32 * 1024 * 1024;
|
|
13
|
+
|
|
14
|
+
function validateIntent(intent) {
|
|
15
|
+
const source = intent.source;
|
|
16
|
+
if (source?.kind !== "pipeline") throw new Error("Not a pipeline intent");
|
|
17
|
+
const expected = pipelineIntent({
|
|
18
|
+
...source,
|
|
19
|
+
repository: intent.repository,
|
|
20
|
+
phases: intent.expectedNodes,
|
|
21
|
+
runtime: intent.runtime,
|
|
22
|
+
});
|
|
23
|
+
if (recordDigest(expected) !== recordDigest(intent))
|
|
24
|
+
throw new Error("Pipeline intent identity mismatch");
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function foldAttempt(events, intent) {
|
|
28
|
+
events.sort((a, b) => a.sequence - b.sequence);
|
|
29
|
+
const roots = events.filter((record) => record.node === "attempt");
|
|
30
|
+
if (roots.length !== 1 || roots[0] !== events[0])
|
|
31
|
+
throw new Error("Attempt requires exactly one initial root");
|
|
32
|
+
const phases = Object.create(null),
|
|
33
|
+
materials = Object.create(null),
|
|
34
|
+
keys = new Set(),
|
|
35
|
+
runs = new Map();
|
|
36
|
+
let previous = null;
|
|
37
|
+
for (const record of events) {
|
|
38
|
+
const value = record.payload;
|
|
39
|
+
if (
|
|
40
|
+
value.identity.id !== roots[0].payload.identity.id ||
|
|
41
|
+
value.generation.id !== roots[0].payload.generation.id
|
|
42
|
+
)
|
|
43
|
+
throw new Error("Attempt changed its immutable generation");
|
|
44
|
+
if (
|
|
45
|
+
record.sequence !== (previous ? previous.sequence + 1 : 0) ||
|
|
46
|
+
value.previous !== (previous?.id || "")
|
|
47
|
+
)
|
|
48
|
+
throw new Error(
|
|
49
|
+
"Attempt record chain has a gap, fork or missing predecessor",
|
|
50
|
+
);
|
|
51
|
+
if (keys.has(value.eventKey))
|
|
52
|
+
throw new Error("Conflicting duplicate event key");
|
|
53
|
+
keys.add(value.eventKey);
|
|
54
|
+
if (record.node !== "attempt") {
|
|
55
|
+
const before = intent.expectedNodes.slice(
|
|
56
|
+
0,
|
|
57
|
+
intent.expectedNodes.indexOf(record.node),
|
|
58
|
+
);
|
|
59
|
+
if (before.some((phase) => phases[phase]?.payload.state !== "success"))
|
|
60
|
+
throw new Error("Phase predecessor has not succeeded");
|
|
61
|
+
const old = phases[record.node];
|
|
62
|
+
if (
|
|
63
|
+
old &&
|
|
64
|
+
["success", "failure", "cancelled", "superseded"].includes(
|
|
65
|
+
old.payload.state,
|
|
66
|
+
)
|
|
67
|
+
)
|
|
68
|
+
throw new Error(
|
|
69
|
+
"Terminal phase history is immutable; recover in a successor attempt",
|
|
70
|
+
);
|
|
71
|
+
phases[record.node] = record;
|
|
72
|
+
}
|
|
73
|
+
indexMaterials(materials, value.materials, value.identity);
|
|
74
|
+
runs.set(record.writer, value.writer);
|
|
75
|
+
previous = record;
|
|
76
|
+
}
|
|
77
|
+
return {
|
|
78
|
+
identity: roots[0].payload.identity,
|
|
79
|
+
generation: roots[0].payload.generation,
|
|
80
|
+
head: previous.id,
|
|
81
|
+
events,
|
|
82
|
+
phases,
|
|
83
|
+
materials,
|
|
84
|
+
runs: [...runs.values()],
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// This projection is evidence navigation, never a Warrant or publication authorization.
|
|
89
|
+
export function readBusinessAttempt({ intent, records }) {
|
|
90
|
+
validateIntent(intent);
|
|
91
|
+
if (!Array.isArray(records) || records.length > MAX_ATTEMPT_RECORDS)
|
|
92
|
+
throw new Error("Attempt record count exceeds its bound");
|
|
93
|
+
if (Buffer.byteLength(canonicalJson(records)) > MAX_HISTORY_BYTES)
|
|
94
|
+
throw new Error("Attempt history byte bound exceeded");
|
|
95
|
+
const unique = new Map(),
|
|
96
|
+
grouped = new Map();
|
|
97
|
+
for (const record of records) {
|
|
98
|
+
validateAttemptRecord(record, intent);
|
|
99
|
+
unique.set(record.id, record);
|
|
100
|
+
}
|
|
101
|
+
for (const record of unique.values()) {
|
|
102
|
+
const events = grouped.get(record.attempt) || [];
|
|
103
|
+
events.push(record);
|
|
104
|
+
grouped.set(record.attempt, events);
|
|
105
|
+
}
|
|
106
|
+
const lineage = readReleaseDiscussion({
|
|
107
|
+
body: encodeRecord(intent),
|
|
108
|
+
records: [...unique.values()],
|
|
109
|
+
});
|
|
110
|
+
const history = lineage.attempts.map((id) =>
|
|
111
|
+
foldAttempt(grouped.get(id), intent),
|
|
112
|
+
);
|
|
113
|
+
const current = history.at(-1);
|
|
114
|
+
if (!current)
|
|
115
|
+
return {
|
|
116
|
+
intent,
|
|
117
|
+
status: "pending",
|
|
118
|
+
reason: "not-admitted",
|
|
119
|
+
attempt: "",
|
|
120
|
+
missing: intent.expectedNodes,
|
|
121
|
+
history,
|
|
122
|
+
recovery: null,
|
|
123
|
+
};
|
|
124
|
+
const missing = intent.expectedNodes.filter(
|
|
125
|
+
(phase) => current.phases[phase]?.payload.state !== "success",
|
|
126
|
+
);
|
|
127
|
+
const states = Object.values(current.phases).map((record) => record.payload);
|
|
128
|
+
const stopped = states.find((value) =>
|
|
129
|
+
["failure", "cancelled", "superseded"].includes(value.state),
|
|
130
|
+
);
|
|
131
|
+
const waiting = states.find((value) => value.state === "waiting");
|
|
132
|
+
const status =
|
|
133
|
+
stopped?.state ||
|
|
134
|
+
(missing.length ? (waiting ? "waiting" : "running") : "complete");
|
|
135
|
+
return {
|
|
136
|
+
intent,
|
|
137
|
+
attempt: current.identity.id,
|
|
138
|
+
generation: current.generation.id,
|
|
139
|
+
status,
|
|
140
|
+
reason: stopped?.reason || waiting?.reason || status,
|
|
141
|
+
head: current.head,
|
|
142
|
+
missing,
|
|
143
|
+
phases: current.phases,
|
|
144
|
+
materials: current.materials,
|
|
145
|
+
runs: current.runs,
|
|
146
|
+
history,
|
|
147
|
+
recovery: status === "complete" ? null : { attempt: current.identity.id },
|
|
148
|
+
};
|
|
149
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createProgress,
|
|
3
|
+
recordDigest,
|
|
4
|
+
encodeRecord,
|
|
5
|
+
} from "../../release/discussion/envelope.js";
|
|
6
|
+
import { object, text, choice, unique } from "../../consumer/contract/shape.js";
|
|
7
|
+
import { providerWriter, validateAttempt } from "./identity.js";
|
|
8
|
+
import { materialReference } from "./materials.js";
|
|
9
|
+
|
|
10
|
+
export const ATTEMPT_EVENT = "buildchain.business-attempt-event/v1";
|
|
11
|
+
export const STATES = [
|
|
12
|
+
"running",
|
|
13
|
+
"waiting",
|
|
14
|
+
"success",
|
|
15
|
+
"failure",
|
|
16
|
+
"cancelled",
|
|
17
|
+
"superseded",
|
|
18
|
+
];
|
|
19
|
+
const ENVELOPE_STATUS = {
|
|
20
|
+
running: "running",
|
|
21
|
+
waiting: "running",
|
|
22
|
+
success: "success",
|
|
23
|
+
failure: "failure",
|
|
24
|
+
cancelled: "cancelled",
|
|
25
|
+
superseded: "cancelled",
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export function attemptRecord({
|
|
29
|
+
intent,
|
|
30
|
+
attempt,
|
|
31
|
+
generation,
|
|
32
|
+
writer,
|
|
33
|
+
runtime,
|
|
34
|
+
previous = null,
|
|
35
|
+
eventKey,
|
|
36
|
+
phase,
|
|
37
|
+
state,
|
|
38
|
+
reason = "",
|
|
39
|
+
materials = [],
|
|
40
|
+
}) {
|
|
41
|
+
validateAttempt(attempt, intent, generation);
|
|
42
|
+
const writerId = providerWriter(writer, intent.repository);
|
|
43
|
+
choice(state, STATES, "state");
|
|
44
|
+
text(eventKey, "eventKey");
|
|
45
|
+
if (
|
|
46
|
+
eventKey.length > 240 ||
|
|
47
|
+
typeof reason !== "string" ||
|
|
48
|
+
reason.length > 2000
|
|
49
|
+
)
|
|
50
|
+
throw new Error("Attempt event text exceeds its bound");
|
|
51
|
+
if (!previous && (phase !== "attempt" || state !== "running"))
|
|
52
|
+
throw new Error("Open an attempt root before phase progress");
|
|
53
|
+
if (
|
|
54
|
+
previous &&
|
|
55
|
+
(previous.attempt !== attempt.id ||
|
|
56
|
+
previous.payload.generation.id !== generation.id)
|
|
57
|
+
)
|
|
58
|
+
throw new Error(
|
|
59
|
+
"Record predecessor belongs to another attempt or generation",
|
|
60
|
+
);
|
|
61
|
+
if (!Array.isArray(materials) || materials.length > 100)
|
|
62
|
+
throw new Error("Attempt event material bound exceeded");
|
|
63
|
+
unique(
|
|
64
|
+
materials.map((item) => item.id),
|
|
65
|
+
"materials",
|
|
66
|
+
);
|
|
67
|
+
materials.forEach((item) =>
|
|
68
|
+
materialReference(item, attempt, intent.repository),
|
|
69
|
+
);
|
|
70
|
+
return createProgress({
|
|
71
|
+
intent,
|
|
72
|
+
attempt: attempt.id,
|
|
73
|
+
predecessor: attempt.predecessor,
|
|
74
|
+
writer: writerId,
|
|
75
|
+
runtime,
|
|
76
|
+
node: phase,
|
|
77
|
+
status: ENVELOPE_STATUS[state],
|
|
78
|
+
sequence: previous ? previous.sequence + 1 : 0,
|
|
79
|
+
payload: {
|
|
80
|
+
schema: ATTEMPT_EVENT,
|
|
81
|
+
identity: attempt,
|
|
82
|
+
generation,
|
|
83
|
+
writer: { ...writer },
|
|
84
|
+
previous: previous?.id || "",
|
|
85
|
+
eventKey,
|
|
86
|
+
state,
|
|
87
|
+
reason,
|
|
88
|
+
materials: structuredClone(materials),
|
|
89
|
+
},
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function validateAttemptRecord(record, intent) {
|
|
94
|
+
const value = record.payload;
|
|
95
|
+
object(
|
|
96
|
+
value,
|
|
97
|
+
[
|
|
98
|
+
"schema",
|
|
99
|
+
"identity",
|
|
100
|
+
"generation",
|
|
101
|
+
"writer",
|
|
102
|
+
"previous",
|
|
103
|
+
"eventKey",
|
|
104
|
+
"state",
|
|
105
|
+
"reason",
|
|
106
|
+
"materials",
|
|
107
|
+
],
|
|
108
|
+
[],
|
|
109
|
+
"attemptEvent",
|
|
110
|
+
);
|
|
111
|
+
if (value.schema !== ATTEMPT_EVENT)
|
|
112
|
+
throw new Error("Attempt event requires its historical runtime reader");
|
|
113
|
+
validateAttempt(value.identity, intent, value.generation);
|
|
114
|
+
const { id, ...content } = record;
|
|
115
|
+
if (
|
|
116
|
+
id !== recordDigest(content) ||
|
|
117
|
+
record.attempt !== value.identity.id ||
|
|
118
|
+
record.intent !== intent.id ||
|
|
119
|
+
record.predecessor !== value.identity.predecessor
|
|
120
|
+
)
|
|
121
|
+
throw new Error("Attempt record identity mismatch");
|
|
122
|
+
if (providerWriter(value.writer, intent.repository) !== record.writer)
|
|
123
|
+
throw new Error("Attempt provider writer mismatch");
|
|
124
|
+
choice(value.state, STATES, "attemptEvent.state");
|
|
125
|
+
if (record.status !== ENVELOPE_STATUS[value.state])
|
|
126
|
+
throw new Error("Attempt record status mismatch");
|
|
127
|
+
if (value.previous)
|
|
128
|
+
text(value.previous, "previous", /^sha256:[0-9a-f]{64}$/u);
|
|
129
|
+
const expected = attemptRecord({
|
|
130
|
+
intent,
|
|
131
|
+
attempt: value.identity,
|
|
132
|
+
generation: value.generation,
|
|
133
|
+
writer: value.writer,
|
|
134
|
+
runtime: record.runtime,
|
|
135
|
+
previous: value.previous
|
|
136
|
+
? {
|
|
137
|
+
id: value.previous,
|
|
138
|
+
attempt: record.attempt,
|
|
139
|
+
payload: value,
|
|
140
|
+
sequence: record.sequence - 1,
|
|
141
|
+
}
|
|
142
|
+
: null,
|
|
143
|
+
eventKey: value.eventKey,
|
|
144
|
+
phase: record.node,
|
|
145
|
+
state: value.state,
|
|
146
|
+
reason: value.reason,
|
|
147
|
+
materials: value.materials,
|
|
148
|
+
});
|
|
149
|
+
if (recordDigest(expected) !== recordDigest(record))
|
|
150
|
+
throw new Error("Invalid attempt record envelope");
|
|
151
|
+
encodeRecord(record);
|
|
152
|
+
return record;
|
|
153
|
+
}
|