@kungfu-tech/buildchain 4.1.3-alpha.0 → 4.1.3-alpha.1

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.
Files changed (34) hide show
  1. package/architecture/agent-change-map.md +6 -2
  2. package/architecture/internal-capabilities.json +9 -2
  3. package/architecture/maintainability-policy.json +3 -3
  4. package/architecture/release-topology.json +4 -1
  5. package/architecture/universal-workflow-capability-policy.json +5 -5
  6. package/contracts/release-discussion-v1.schema.json +3 -0
  7. package/dist/readers/release-discussion.cjs +4 -0
  8. package/dist/site/buildchain-contract.json +4 -4
  9. package/dist/site/buildchain-site.json +14 -9
  10. package/dist/site/kfd-claims.json +2 -2
  11. package/dist/site/kfd-upstream-aggregate.json +1 -1
  12. package/dist/site/manual-registry.json +1 -1
  13. package/dist/site/node-api-registry.json +3 -3
  14. package/dist/site/page-registry.json +9 -4
  15. package/dist/site/public-surface-audit.json +2 -2
  16. package/dist/site/publication-registry.json +4 -4
  17. package/dist/site/site-manifest.json +5 -5
  18. package/dist/site/workflow-registry.json +4 -4
  19. package/docs/node-api-reference.md +1 -1
  20. package/docs/release-discussions.md +44 -5
  21. package/package.json +1 -1
  22. package/packages/core/providers/github/discussions/materials.js +16 -5
  23. package/packages/core/providers/github/discussions/transport.js +16 -5
  24. package/packages/core/release/discussion/binary.js +11 -0
  25. package/packages/core/release/discussion/checkpoints.js +45 -4
  26. package/packages/core/release/discussion/envelope.js +1 -0
  27. package/packages/core/release/discussion/evidence.js +51 -0
  28. package/packages/core/release/discussion/presentation.js +92 -0
  29. package/packages/core/release/discussion/publication.js +3 -1
  30. package/packages/core/release/discussion/qualification.js +1 -0
  31. package/packages/core/release/discussion/reader.js +9 -0
  32. package/packages/core/release/discussion/session.js +2 -0
  33. package/packages/core/release/discussion/store.js +28 -31
  34. package/packages/core/release/discussion/threads.js +83 -0
@@ -10877,8 +10877,8 @@
10877
10877
  "workflowRegistryPath": "dist/site/workflow-registry.json",
10878
10878
  "pageRegistryPath": "dist/site/page-registry.json",
10879
10879
  "cliRegistryDigest": "e43fdb0289df88871f3f0febbe04dcb9311a252a2b1df9b3a83a2d10c9d4a70f",
10880
- "workflowRegistryDigest": "cb8d12101109a846a0aeba5c5bf82078df437118e3cccbe9ab0d173112a6f88a",
10881
- "pageRegistryDigest": "6783dccba5aeb2044b6e2f7c7d78a3e47997a3163f7328a77ebdd0f4fdb087d4"
10880
+ "workflowRegistryDigest": "3e137e0f54830a7be7976ac3a5d071fa05586d8011daab2eb149f40b4e535cf5",
10881
+ "pageRegistryDigest": "56222fb3665c9bcd626a301d809db80ac8f8cb42b2a15b98d32241708f26b398"
10882
10882
  },
10883
10883
  "comparison": {
10884
10884
  "missingCliRegistry": [],
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
3
  "contract": "kungfu-buildchain-publication-release-registry",
4
- "generatedAt": "2026-09-11T23:17:50.000Z",
5
- "publishedAt": "2026-09-11T23:17:50.000Z",
4
+ "generatedAt": "2026-09-12T06:35:54.000Z",
5
+ "publishedAt": "2026-09-12T06:35:54.000Z",
6
6
  "reproducible": true,
7
7
  "timestampPolicy": "ci-injected",
8
8
  "deterministicInputs": [
@@ -19,7 +19,7 @@
19
19
  "declared Buildchain surface manifest contract"
20
20
  ],
21
21
  "sourceDateEpoch": "0",
22
- "sourceRevision": "8e1dcc5aba732fd253dbb50dc910a3187c1e688a",
22
+ "sourceRevision": "b06002db5210f70c8da3ad79357ac7d5ab3d6497",
23
23
  "timestampPolicyDetails": {
24
24
  "contract": "kungfu-buildchain-surface-timestamp-policy",
25
25
  "timestampFields": [
@@ -32,7 +32,7 @@
32
32
  },
33
33
  "package": {
34
34
  "name": "@kungfu-tech/buildchain",
35
- "version": "4.1.3-alpha.0",
35
+ "version": "4.1.3-alpha.1",
36
36
  "versionSource": "package.json#version"
37
37
  },
38
38
  "sourceKind": "package-site-bundle",
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
3
  "contract": "kungfu-buildchain-site-manifest",
4
- "generatedAt": "2026-09-11T23:17:50.000Z",
5
- "publishedAt": "2026-09-11T23:17:50.000Z",
4
+ "generatedAt": "2026-09-12T06:35:54.000Z",
5
+ "publishedAt": "2026-09-12T06:35:54.000Z",
6
6
  "reproducible": true,
7
7
  "timestampPolicy": "ci-injected",
8
8
  "deterministicInputs": [
@@ -19,7 +19,7 @@
19
19
  "declared Buildchain surface manifest contract"
20
20
  ],
21
21
  "sourceDateEpoch": "0",
22
- "sourceRevision": "8e1dcc5aba732fd253dbb50dc910a3187c1e688a",
22
+ "sourceRevision": "b06002db5210f70c8da3ad79357ac7d5ab3d6497",
23
23
  "timestampPolicyDetails": {
24
24
  "contract": "kungfu-buildchain-surface-timestamp-policy",
25
25
  "timestampFields": [
@@ -37,7 +37,7 @@
37
37
  },
38
38
  "package": {
39
39
  "name": "@kungfu-tech/buildchain",
40
- "version": "4.1.3-alpha.0",
40
+ "version": "4.1.3-alpha.1",
41
41
  "versionSource": "package.json#version"
42
42
  },
43
43
  "entrypoint": "buildchain-site.json",
@@ -253,7 +253,7 @@
253
253
  "path": "docs/node-api-reference.md",
254
254
  "plane": "use",
255
255
  "exists": true,
256
- "digest": "sha256:b37478c7c5f6a4c09a89bb7f4dfe70c3ebcfa4cae21f25e09d16f499bf526afb"
256
+ "digest": "sha256:0fc24661217d58302858cca49a5f11d49c894fc720f6735e11da2dba50300c91"
257
257
  },
258
258
  {
259
259
  "id": "build-facts",
@@ -1046,7 +1046,7 @@
1046
1046
  "status": "preview",
1047
1047
  "owner": "buildchain-workflows",
1048
1048
  "maturity": "preview",
1049
- "introducedVersion": "4.1.3-alpha.0",
1049
+ "introducedVersion": "4.1.3-alpha.1",
1050
1050
  "compatibilityPromise": "current-contract-only",
1051
1051
  "deprecationReplacement": "",
1052
1052
  "sunsetCondition": "explicit-contract-change-review",
@@ -2479,7 +2479,7 @@
2479
2479
  "status": "preview",
2480
2480
  "owner": "buildchain-workflows",
2481
2481
  "maturity": "preview",
2482
- "introducedVersion": "4.1.3-alpha.0",
2482
+ "introducedVersion": "4.1.3-alpha.1",
2483
2483
  "compatibilityPromise": "current-contract-only",
2484
2484
  "deprecationReplacement": "",
2485
2485
  "sunsetCondition": "explicit-contract-change-review",
@@ -2509,7 +2509,7 @@
2509
2509
  "status": "preview",
2510
2510
  "owner": "buildchain-workflows",
2511
2511
  "maturity": "preview",
2512
- "introducedVersion": "4.1.3-alpha.0",
2512
+ "introducedVersion": "4.1.3-alpha.1",
2513
2513
  "compatibilityPromise": "current-contract-only",
2514
2514
  "deprecationReplacement": "",
2515
2515
  "sunsetCondition": "explicit-contract-change-review",
@@ -2539,7 +2539,7 @@
2539
2539
  "status": "preview",
2540
2540
  "owner": "buildchain-workflows",
2541
2541
  "maturity": "preview",
2542
- "introducedVersion": "4.1.3-alpha.0",
2542
+ "introducedVersion": "4.1.3-alpha.1",
2543
2543
  "compatibilityPromise": "current-contract-only",
2544
2544
  "deprecationReplacement": "",
2545
2545
  "sunsetCondition": "explicit-contract-change-review",
@@ -2714,5 +2714,5 @@ Target: `./packages/core/release/discussion/reader.js`. Public symbols: 2.
2714
2714
 
2715
2715
  | Symbol | Kind and signature | Parameters | Return | Errors | Side effects | Example | Source |
2716
2716
  | --- | --- | --- | --- | --- | --- | --- | --- |
2717
- | `discussionStatus` | function: function discussionStatus(state) | state | unknown | Errors from called operations may propagate; no narrower throw contract is declared in source. | none-detected-by-static-source-scan | `import { discussionStatus } from "@kungfu-tech/buildchain/release-discussion-reader";` | `packages/core/release/discussion/reader.js:151` |
2717
+ | `discussionStatus` | function: function discussionStatus(state) | state | unknown | Errors from called operations may propagate; no narrower throw contract is declared in source. | none-detected-by-static-source-scan | `import { discussionStatus } from "@kungfu-tech/buildchain/release-discussion-reader";` | `packages/core/release/discussion/reader.js:155` |
2718
2718
  | `readReleaseDiscussion` | function: function readReleaseDiscussion({ body, records }) | { body, records } | unknown | May throw an Error on rejected input or failed operations; follow the linked source contract. | none-detected-by-static-source-scan | `import { readReleaseDiscussion } from "@kungfu-tech/buildchain/release-discussion-reader";` | `packages/core/release/discussion/reader.js:11` |
@@ -52,14 +52,16 @@ and [Discussions API guide](https://docs.github.com/en/graphql/guides/using-the-
52
52
 
53
53
  ## Records and concurrency
54
54
 
55
- The body is not a mutable global status document. Workflows append bounded JSON
56
- envelopes in comments. Each record contains an intent ID, workflow attempt,
55
+ The body is not a mutable global status document. New intents declare `organization: attempt-threads/v1`. Each attempt owns exactly
56
+ one top-level root comment; node events, checkpoints and diagnostics are appended
57
+ as replies using that root comment ID. Workflows append bounded JSON envelopes
58
+ with readable context and evidence links. Each record contains an intent ID, workflow attempt,
57
59
  predecessor, semantic node, sequence, status, payload schema, and the selected
58
60
  runtime's exact repository and revision. These are provenance; downstream nodes
59
61
  do not compare the runtime revision with the workflow entry revision.
60
62
 
61
63
  Node owners serialize their own sequences. Different node owners append
62
- independent comments. Recovery explicitly selects the predecessor in the same
64
+ independent replies under the same attempt root. Recovery explicitly selects the predecessor in the same
63
65
  Discussion. A late result from an older attempt remains history; it cannot
64
66
  complete or overwrite the recovering attempt. Success requires every declared
65
67
  node, including nodes whose records have not appeared. Reusing completed work
@@ -71,12 +73,47 @@ Initialization scans complete repository category pages, reuses the matching
71
73
  intent, and refuses duplicate owners. A mutation whose response was lost is read
72
74
  back before another write. If its outcome remains unknown, execution stops with
73
75
  an explicit diagnosis. `clientMutationId` is not used as an idempotency guarantee.
76
+ The attempt owner creates the root before dispatching independent node writers.
77
+ Root creation is serialized and response-loss recovery reads it back. Different
78
+ roots for one attempt, nested roots and events attached to the wrong attempt are
79
+ rejected. Root and reply connections are both completely paginated; individual
80
+ page and total byte bounds fail closed. Community conversation is ignored.
81
+
74
82
  Exact IDs carry the transaction through execution; search indexing is not an
75
83
  execution dependency. Pagination limits fail closed instead of silently hiding
76
84
  records. Only records from the original workflow writer are authoritative;
77
85
  ordinary community replies are excluded and edited transaction records are
78
86
  reported as integrity failures.
79
87
 
88
+ ## Human-readable information hub
89
+
90
+ The Discussion body declares the release intent and expected nodes. Each root
91
+ links its workflow execution, selected runtime and predecessor attempt. Replies
92
+ name the semantic node, distinguish checkpoint sequence from execution status,
93
+ and expose bounded details. An independent binary workflow links its own run but
94
+ replies to the release attempt it observed before starting; a late completion
95
+ cannot migrate into a successor attempt.
96
+
97
+ Checkpoint replies include downloadable JSON attachments with file names, media
98
+ types, byte counts and SHA-256 digests. On node failures, the same thread receives
99
+ JSON diagnostics and a text `.log` containing the recorded event timeline and
100
+ bounded failure classification. Raw exceptions, environment variables and HTTP
101
+ credentials are not copied into diagnostics. Complete execution logs remain
102
+ linked through their Actions runs. Qualification also exercises JSON/log downloads.
103
+
104
+ Automation uploads these files through the documented GitHub Release asset API
105
+ and embeds the provider's download links in the owning comment. This does not
106
+ use GitHub's browser-only drag-and-drop upload implementation. The existing draft
107
+ material archive is private to users with the required repository access; a
108
+ public Discussion does not make draft attachments publicly downloadable. Download
109
+ links require that permission and files remain outside the source Git database.
110
+
111
+ Root comments and event replies are immutable execution facts. The latest state
112
+ is computed from records, not from an overwritten status summary. Historical flat
113
+ intents remain readable as historical data; all new writes use an attempt root
114
+ and replies, and new intents enforce that organization. No historical Discussion
115
+ body or comment is rewritten to change its layout.
116
+
80
117
  ## Recovery bytes and historical readers
81
118
 
82
119
  Large sealed inputs and provider checkpoints are retained as immutable,
@@ -111,8 +148,10 @@ same Discussion. It neither rewrites X's records nor changes the release intent.
111
148
  ## Ownership
112
149
 
113
150
  `actions/release/transaction/{open,record,inspect}` are reusable Node adapters.
114
- `packages/core/release/discussion` owns envelopes, projections, sessions and
115
- recovery contracts. `packages/core/providers/github/discussions` owns GitHub
151
+ `packages/core/release/discussion` owns envelopes, pure state projections, sessions, thread organization,
152
+ presentation, declared evidence and recovery contracts. `threads.js` checks
153
+ provider placement; `presentation.js` renders contextual Markdown; `evidence.js`
154
+ builds bounded attachment descriptors and diagnostic reports. `packages/core/providers/github/discussions` owns GitHub
116
155
  transport and immutable material IO. The existing public promotion workflow
117
156
  owns job boundaries; the Rust publication state machine still owns publication
118
157
  decisions and provider-effect ordering.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kungfu-tech/buildchain",
3
- "version": "4.1.3-alpha.0",
3
+ "version": "4.1.3-alpha.1",
4
4
  "private": false,
5
5
  "description": "Buildchain Release Passport, release governance, CLI toolkit, and site facts.",
6
6
  "repository": "https://github.com/kungfu-systems/buildchain",
@@ -72,11 +72,13 @@ export function discussionMaterials({ octokit, repository, intentId }) {
72
72
  );
73
73
  return bytes;
74
74
  }
75
- async function put(bytes) {
75
+ async function put(bytes, descriptor = {}) {
76
+ const extension =
77
+ descriptor.name?.match(/\.([a-zA-Z0-9]{1,8})$/u)?.[1] || "";
76
78
  if (bytes.length > 256 * 1024 * 1024)
77
79
  throw new Error("Transaction material exceeds the supported file bound");
78
80
  const digest = materialDigest(bytes),
79
- name = digest.replace(":", "-");
81
+ name = digest.replace(":", "-") + (extension ? `.${extension}` : "");
80
82
  const release = await archive();
81
83
  const find = async () => {
82
84
  const assets = await octokit.paginate(repos.listReleaseAssets, {
@@ -101,7 +103,8 @@ export function discussionMaterials({ octokit, repository, intentId }) {
101
103
  name,
102
104
  data: bytes,
103
105
  headers: {
104
- "content-type": "application/octet-stream",
106
+ "content-type":
107
+ descriptor.mediaType || "application/octet-stream",
105
108
  },
106
109
  })
107
110
  ).data;
@@ -113,7 +116,15 @@ export function discussionMaterials({ octokit, repository, intentId }) {
113
116
  });
114
117
  }
115
118
  }
116
- const handle = { id: asset.id, digest, size: bytes.length };
119
+ const handle = {
120
+ id: asset.id,
121
+ digest,
122
+ size: bytes.length,
123
+ ...(asset.browser_download_url
124
+ ? { downloadUrl: asset.browser_download_url }
125
+ : {}),
126
+ ...(release.html_url ? { archiveUrl: release.html_url } : {}),
127
+ };
117
128
  await read(handle);
118
129
  return handle;
119
130
  }
@@ -140,7 +151,7 @@ export function discussionMaterials({ octokit, repository, intentId }) {
140
151
  }
141
152
  }
142
153
  return {
143
- put: (bytes) => diagnose("put", () => put(bytes)),
154
+ put: (bytes, descriptor) => diagnose("put", () => put(bytes, descriptor)),
144
155
  read: (handle) => diagnose("read", () => read(handle)),
145
156
  };
146
157
  }
@@ -1,7 +1,7 @@
1
1
  const fields =
2
2
  "id number url body author { __typename login ... on Node { id } } lastEditedAt repository { nameWithOwner }";
3
3
  const commentFields =
4
- "id url body author { __typename login ... on Node { id } } lastEditedAt";
4
+ "id url body author { __typename login ... on Node { id } } lastEditedAt replyTo { id }";
5
5
 
6
6
  export function discussionTransport(graphql) {
7
7
  async function request(query, variables) {
@@ -66,13 +66,22 @@ export function discussionTransport(graphql) {
66
66
  }
67
67
  async function comments(id, after = null) {
68
68
  const result = await request(
69
- `query($id:ID!,$after:String){node(id:$id){... on Discussion{comments(first:100,after:$after){nodes{${commentFields}} pageInfo{hasNextPage endCursor}}}}}`,
69
+ `query($id:ID!,$after:String){node(id:$id){... on Discussion{comments(first:100,after:$after){nodes{${commentFields} replies{totalCount}} pageInfo{hasNextPage endCursor}}}}}`,
70
70
  { id, after },
71
71
  );
72
72
  if (!result.node?.comments)
73
73
  throw new Error("Release Discussion comments are unavailable");
74
74
  return result.node.comments;
75
75
  }
76
+ async function replies(id, after = null) {
77
+ const result = await request(
78
+ `query($id:ID!,$after:String){node(id:$id){... on DiscussionComment{replies(first:100,after:$after){nodes{${commentFields}} pageInfo{hasNextPage endCursor}}}}}`,
79
+ { id, after },
80
+ );
81
+ if (!result.node?.replies)
82
+ throw new Error("Release Discussion replies are unavailable");
83
+ return result.node.replies;
84
+ }
76
85
  async function create({ repositoryId, categoryId, title, body }) {
77
86
  const result = await request(
78
87
  `mutation($input:CreateDiscussionInput!){createDiscussion(input:$input){discussion{${fields}}}}`,
@@ -80,14 +89,16 @@ export function discussionTransport(graphql) {
80
89
  );
81
90
  return result.createDiscussion.discussion;
82
91
  }
83
- async function append(id, body) {
92
+ async function append(id, body, replyToId = null) {
84
93
  const result = await request(
85
94
  `mutation($input:AddDiscussionCommentInput!){addDiscussionComment(input:$input){comment{${commentFields}}}}`,
86
- { input: { discussionId: id, body } },
95
+ {
96
+ input: { discussionId: id, body, ...(replyToId ? { replyToId } : {}) },
97
+ },
87
98
  );
88
99
  return result.addDiscussionComment.comment;
89
100
  }
90
- return { repository, list, get, comments, create, append };
101
+ return { repository, list, get, comments, replies, create, append };
91
102
  }
92
103
 
93
104
  export async function collectDiscussionPages(readPage, limit = 100) {
@@ -1,3 +1,4 @@
1
+ import { releaseCheckpoints } from "./checkpoints.js";
1
2
  import { discussionTransport } from "../../providers/github/discussions/transport.js";
2
3
  import { releaseDiscussionStore } from "./store.js";
3
4
  import { createProgress, decodeRecord } from "./envelope.js";
@@ -46,6 +47,12 @@ export async function observeBinaryDistribution(
46
47
  const attemptRecord = state.records.find(
47
48
  (record) => record.attempt === state.attempt,
48
49
  );
50
+ Object.assign(session, {
51
+ attempt: state.attempt,
52
+ predecessor: attemptRecord.predecessor,
53
+ runtime,
54
+ writer,
55
+ });
49
56
  const sequence =
50
57
  Math.max(
51
58
  -1,
@@ -82,6 +89,10 @@ export async function observeBinaryDistribution(
82
89
  await record("failure", 1, {
83
90
  code: String(error.code || "execution-failed"),
84
91
  });
92
+ await releaseCheckpoints({ session, store, octokit }).diagnostics(
93
+ "binary-distribution",
94
+ error.code,
95
+ );
85
96
  } catch (recordError) {
86
97
  error.discussionRecordingError = recordError.message;
87
98
  }
@@ -1,3 +1,8 @@
1
+ import {
2
+ retainAttachment,
3
+ diagnosticReport,
4
+ diagnosticLog,
5
+ } from "./evidence.js";
1
6
  import fs from "node:fs";
2
7
  import path from "node:path";
3
8
  import { createProgress, recordDigest } from "./envelope.js";
@@ -10,7 +15,11 @@ export function releaseCheckpoints({ session, store, octokit }) {
10
15
  repository: session.intent.repository,
11
16
  intentId: session.intent.id,
12
17
  });
13
- async function checkpoint(node, value) {
18
+ async function checkpoint(
19
+ node,
20
+ value,
21
+ { attachments = [], label = value.schema || "Recovery checkpoint" } = {},
22
+ ) {
14
23
  const observed = await store.read(session);
15
24
  const records = observed.records.filter(
16
25
  (record) =>
@@ -21,20 +30,29 @@ export function releaseCheckpoints({ session, store, octokit }) {
21
30
  const root = recordDigest(value);
22
31
  const duplicate = records.find((record) => record.payload.root === root);
23
32
  if (duplicate) return duplicate;
24
- const handle = await materials.put(Buffer.from(JSON.stringify(value)));
33
+ const sequence =
34
+ Math.max(-1, ...records.map((record) => record.sequence)) + 1;
35
+ const handle = await retainAttachment(materials, {
36
+ name: `${node}-checkpoint-${sequence}.json`,
37
+ mediaType: "application/json",
38
+ bytes: Buffer.from(JSON.stringify(value, null, 2) + "\n"),
39
+ });
25
40
  const record = createProgress({
26
41
  intent: session.intent,
27
42
  runtime: session.runtime,
28
43
  attempt: session.attempt,
44
+ writer: session.writer || session.attempt,
29
45
  predecessor: session.predecessor,
30
46
  kind: "checkpoint",
31
47
  node,
32
48
  status: "running",
33
- sequence: Math.max(-1, ...records.map((record) => record.sequence)) + 1,
49
+ sequence,
34
50
  payload: {
35
51
  schema: "buildchain.release-checkpoint/v1",
36
52
  root,
37
53
  material: handle,
54
+ label,
55
+ attachments: [handle, ...attachments],
38
56
  },
39
57
  });
40
58
  await store.append(session, record);
@@ -69,7 +87,30 @@ export function releaseCheckpoints({ session, store, octokit }) {
69
87
  throw new Error("Unsupported retained publication reader manifest");
70
88
  return materials.read(manifest.reader);
71
89
  }
72
- return { checkpoint, readCheckpoint, materials, publicationReader };
90
+ async function diagnostics(node, code) {
91
+ const report = diagnosticReport(
92
+ session,
93
+ await store.read(session),
94
+ node,
95
+ code,
96
+ );
97
+ const log = await retainAttachment(materials, {
98
+ name: `${node}-diagnostics.log`,
99
+ mediaType: "text/plain",
100
+ bytes: diagnosticLog(report),
101
+ });
102
+ return checkpoint(node, report, {
103
+ attachments: [log],
104
+ label: `Execution diagnostics: ${report.code}`,
105
+ });
106
+ }
107
+ return {
108
+ checkpoint,
109
+ readCheckpoint,
110
+ materials,
111
+ publicationReader,
112
+ diagnostics,
113
+ };
73
114
  }
74
115
 
75
116
  function assertNoSymlink(file, base) {
@@ -94,6 +94,7 @@ export function createIntent({
94
94
  id: recordDigest(identity),
95
95
  ...identity,
96
96
  expectedNodes,
97
+ organization: "attempt-threads/v1",
97
98
  runtime,
98
99
  source,
99
100
  };
@@ -0,0 +1,51 @@
1
+ // Evidence is declared transaction data, never an implicit upload of workspace
2
+ // files, environment variables, raw exceptions or authenticated HTTP responses.
3
+ export async function retainAttachment(materials, { name, mediaType, bytes }) {
4
+ if (
5
+ !/^[a-zA-Z0-9][a-zA-Z0-9._-]{0,159}$/u.test(name) ||
6
+ !["application/json", "text/plain", "application/octet-stream"].includes(
7
+ mediaType,
8
+ )
9
+ )
10
+ throw new Error("Invalid transaction attachment descriptor");
11
+ const handle = await materials.put(bytes, { name, mediaType });
12
+ if (!handle.downloadUrl)
13
+ throw new Error("Retained attachment is missing its provider download URL");
14
+ return { name, mediaType, ...handle };
15
+ }
16
+
17
+ export function diagnosticReport(session, state, node, code) {
18
+ return {
19
+ schema: "buildchain.release-diagnostics/v1",
20
+ intent: session.intent.id,
21
+ attempt: session.attempt,
22
+ node,
23
+ code: String(code || "execution-failed")
24
+ .replace(/[^a-zA-Z0-9._-]/gu, "-")
25
+ .slice(0, 160),
26
+ events: state.records
27
+ .filter((record) => record.attempt === session.attempt)
28
+ .map(({ id, node, kind, status, sequence, writer }) => ({
29
+ id,
30
+ node,
31
+ kind,
32
+ status,
33
+ sequence,
34
+ writer,
35
+ })),
36
+ };
37
+ }
38
+
39
+ export function diagnosticLog(report) {
40
+ return Buffer.from(
41
+ [
42
+ `Release intent ${report.intent}`,
43
+ `Attempt ${report.attempt}: ${report.node} ${report.code}`,
44
+ ...report.events.map(
45
+ (event) =>
46
+ `${event.node} ${event.kind} ${event.sequence} ${event.status} writer=${event.writer} record=${event.id}`,
47
+ ),
48
+ "",
49
+ ].join("\n"),
50
+ );
51
+ }
@@ -0,0 +1,92 @@
1
+ import { isAttemptRoot } from "./threads.js";
2
+
3
+ const text = (value) =>
4
+ String(value ?? "")
5
+ .replace(
6
+ /[&<>\[\]`*_\\|]/gu,
7
+ (character) => `&#${character.charCodeAt(0)};`,
8
+ )
9
+ .replace(/[\r\n]/gu, " ");
10
+ const code = (value) => `\`${String(value).replace(/[`\r\n]/gu, " ")}\``;
11
+
12
+ function runLink(repository, writer) {
13
+ const match = /^(\d+):(\d+)(?::.*)?$/u.exec(writer);
14
+ return match
15
+ ? `[${text(writer)}](https://github.com/${repository}/actions/runs/${match[1]}/attempts/${match[2]})`
16
+ : code(writer);
17
+ }
18
+
19
+ function runtimeLink(runtime) {
20
+ return `[${runtime.repository}@${runtime.sha.slice(0, 12)}](https://github.com/${runtime.repository}/commit/${runtime.sha})`;
21
+ }
22
+
23
+ export function renderIntent(intent) {
24
+ return [
25
+ `## ${intent.source?.qualification ? "Qualification" : "Release"}: ${text(intent.key)}`,
26
+ `Repository: ${code(intent.repository)}`,
27
+ "Each top-level transaction comment owns one attempt. Its replies contain node results, recovery checkpoints and downloadable evidence. Recovery attempts link to their predecessors.",
28
+ `Expected nodes: ${intent.expectedNodes.map(code).join(" → ")}`,
29
+ `Intent: ${code(intent.id)}`,
30
+ ].join("\n\n");
31
+ }
32
+
33
+ function attachmentRows(record, repository) {
34
+ return (record.payload?.attachments || []).map((attachment) => {
35
+ const url = new URL(attachment.downloadUrl);
36
+ if (
37
+ url.protocol !== "https:" ||
38
+ url.hostname !== "github.com" ||
39
+ !url.pathname.startsWith(`/${repository}/releases/download/`) ||
40
+ url.search ||
41
+ url.hash
42
+ )
43
+ throw new Error(
44
+ "Attachment download must belong to the consumer repository",
45
+ );
46
+ return `| [${text(attachment.name)}](${url.href}) | ${text(attachment.mediaType)} | ${attachment.size} | ${code(attachment.digest)} |`;
47
+ });
48
+ }
49
+
50
+ export function renderEvent(record, intent, state) {
51
+ if (isAttemptRoot(record)) {
52
+ const predecessor = state.roots.get(record.predecessor);
53
+ return [
54
+ `## Attempt ${text(record.attempt)}`,
55
+ `Execution: ${runLink(intent.repository, record.writer)} \nRuntime: ${runtimeLink(record.runtime)}`,
56
+ record.predecessor
57
+ ? `Resumes: ${predecessor?.url ? `[${text(record.predecessor)}](${predecessor.url})` : code(record.predecessor)}`
58
+ : "Initial execution of this release intent.",
59
+ `Nodes: ${intent.expectedNodes.map(code).join(" → ")}`,
60
+ "Results and evidence are appended below. This root preserves the original execution context.",
61
+ ].join("\n\n");
62
+ }
63
+ const label =
64
+ record.kind === "checkpoint"
65
+ ? `checkpoint ${record.sequence}`
66
+ : record.status;
67
+ const lines = [
68
+ `### ${text(record.node)} · ${label}`,
69
+ `Attempt: ${code(record.attempt)} · Writer: ${runLink(intent.repository, record.writer)}`,
70
+ `Runtime: ${runtimeLink(record.runtime)}`,
71
+ ];
72
+ if (record.payload?.label) lines.push(text(record.payload.label));
73
+ if (record.payload?.code)
74
+ lines.push(`Failure code: ${code(record.payload.code)}`);
75
+ const attachments = attachmentRows(record, intent.repository);
76
+ if (attachments.length)
77
+ lines.push(
78
+ "| Attachment | Type | Bytes | SHA-256 |\n| --- | --- | ---: | --- |\n" +
79
+ attachments.join("\n"),
80
+ "Retained files use the transaction material archive. Access requires repository permission to read draft release assets.",
81
+ );
82
+ const { attachments: _attachments, ...details } = record.payload || {};
83
+ if (Object.keys(details).length) {
84
+ const json = JSON.stringify(details, null, 2);
85
+ // HTML escaping keeps payload strings from closing the presentation container.
86
+ lines.push(
87
+ `<details><summary>Record details</summary><pre>${json.slice(0, 6000).replace(/[&<>]/gu, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;" })[c])}${json.length > 6000 ? "\n… See the retained JSON attachment for the complete material." : ""}</pre></details>`,
88
+ );
89
+ }
90
+ lines.push(`Record: ${code(record.id)}`);
91
+ return lines.join("\n\n");
92
+ }
@@ -40,8 +40,10 @@ export async function publishWithDiscussion(
40
40
  observe = () => {},
41
41
  },
42
42
  ) {
43
+ let retained;
43
44
  const journal = await openReleaseSession({
44
45
  graphql: octokit.graphql,
46
+ onFailure: (node, error) => retained.diagnostics(node, error.code),
45
47
  repository: request.repository,
46
48
  key: request.version,
47
49
  source: { version: request.version },
@@ -54,7 +56,7 @@ export async function publishWithDiscussion(
54
56
  request["publish-transaction-override"],
55
57
  ),
56
58
  });
57
- const retained = releaseCheckpoints({
59
+ retained = releaseCheckpoints({
58
60
  session: journal.session,
59
61
  store: journal.store,
60
62
  octokit,
@@ -79,6 +79,7 @@ export async function executeReleaseDiscussion(request, _admission, context) {
79
79
  store: journal.store,
80
80
  octokit: context.octokit,
81
81
  });
82
+ await retained.diagnostics("transport", "qualification-probe");
82
83
  const probe = await retained.checkpoint("transport", {
83
84
  schema: "buildchain.material-qualification/v1",
84
85
  value: payload.key,
@@ -23,6 +23,10 @@ export function readReleaseDiscussion({ body, records }) {
23
23
  new Set(intent.expectedNodes).size !== intent.expectedNodes.length
24
24
  )
25
25
  throw new Error("Missing or duplicate expected nodes");
26
+ if (intent.organization && intent.organization !== "attempt-threads/v1")
27
+ throw new Error(
28
+ "Discussion organization requires its historical runtime reader",
29
+ );
26
30
  const attempts = groupAttempts(validateRecords(intent, records));
27
31
  if (!attempts.size)
28
32
  return {
@@ -156,6 +160,11 @@ export function discussionStatus(state) {
156
160
  status: state.status,
157
161
  attempt: state.attempt || "",
158
162
  attempts: state.attempts,
163
+ threads: [...(state.roots || new Map())].map(([attempt, comment]) => ({
164
+ attempt,
165
+ commentId: comment.id,
166
+ url: comment.url,
167
+ })),
159
168
  missingNodes: state.missingNodes,
160
169
  handoff: state.handoff || null,
161
170
  nodes: Object.fromEntries(
@@ -39,6 +39,7 @@ export async function openReleaseSession({
39
39
  dryRun = false,
40
40
  recover = false,
41
41
  expectedNodes = RELEASE_NODES,
42
+ onFailure = async () => {},
42
43
  }) {
43
44
  const intent = createIntent({
44
45
  repository,
@@ -101,6 +102,7 @@ export async function openReleaseSession({
101
102
  { code: String(error.code || "execution-failed") },
102
103
  1,
103
104
  );
105
+ await onFailure(node, error);
104
106
  } catch (recordError) {
105
107
  error.discussionRecordingError = recordError.message;
106
108
  }