@fro.bot/wiki-write-core 0.1.2 → 0.1.4

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.
@@ -1,6 +1,7 @@
1
+ import { Buffer } from 'node:buffer';
1
2
  import { execFile } from 'node:child_process';
2
3
  import { appendFile, readdir, readFile } from 'node:fs/promises';
3
- import { basename } from 'node:path';
4
+ import { basename, join } from 'node:path';
4
5
  import process from 'node:process';
5
6
  import { promisify } from 'node:util';
6
7
  import { parse, stringify } from 'yaml';
@@ -8,6 +9,7 @@ import { assertBranchWritable } from "./branch-safety.js";
8
9
  import { verifyCorrectionSurvival } from "./corrections-survival.js";
9
10
  import { CorrectionStoreError, readCorrections } from "./corrections.js";
10
11
  import { bootstrapDataBranch as defaultBootstrapDataBranch, } from "./data-branch-bootstrap.js";
12
+ import { findIndexEntryLines } from "./line-scanners.js";
11
13
  import { assertReposFile } from "./schemas.js";
12
14
  import { computeRepoSlug } from "./wiki-slug.js";
13
15
  import { collectWikilinks } from "./wiki-utils.js";
@@ -19,6 +21,8 @@ const WIKI_ROOT = 'knowledge/wiki';
19
21
  const INDEX_PATH = 'knowledge/index.md';
20
22
  const LOG_PATH = 'knowledge/log.md';
21
23
  const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
24
+ const COMMIT_SHA_PATTERN = /^[0-9a-f]{40}(?:[0-9a-f]{24})?$/u;
25
+ const HANDOFF_BASE_REMEDIATION = 'The writer could not tie the handoff to the data commit the agent job synced. Re-run the workflow so the agent job runs again.';
22
26
  const execFileAsync = promisify(execFile);
23
27
  export class WikiIngestError extends Error {
24
28
  code;
@@ -32,8 +36,14 @@ export class WikiIngestError extends Error {
32
36
  this.findings = params.findings ?? [];
33
37
  }
34
38
  }
39
+ /** The two wiki files every writer shares. Writers rebuild both from their own sync; a handoff never supplies them. */
40
+ export function isSharedWikiFile(path) {
41
+ return path === INDEX_PATH || path === LOG_PATH;
42
+ }
35
43
  export function buildWikiIngestChanges(params) {
36
- if (params.pages.length === 0) {
44
+ if (params.pages.length === 0 &&
45
+ (params.handoffDeletedPaths ?? []).length === 0 &&
46
+ (params.agentLogEntries ?? '').trim() === '') {
37
47
  throw new WikiIngestError({
38
48
  code: 'INVALID_PAYLOAD',
39
49
  message: 'wiki ingest requires at least one page update',
@@ -51,6 +61,10 @@ export function buildWikiIngestChanges(params) {
51
61
  nextFiles[page.path] = normalized;
52
62
  files[page.path] = normalized;
53
63
  }
64
+ for (const path of params.handoffDeletedPaths ?? []) {
65
+ if (nextFiles[path] === undefined && !deletedPaths.includes(path))
66
+ deletedPaths.push(path);
67
+ }
54
68
  rewriteInboundWikilinks(nextFiles, migrations);
55
69
  for (const [path, content] of Object.entries(nextFiles)) {
56
70
  if (params.existingFiles[path] !== content && !deletedPaths.includes(path)) {
@@ -93,7 +107,7 @@ export function buildWikiIngestChanges(params) {
93
107
  }
94
108
  const parsedPages = collectWikiPages(nextFiles);
95
109
  const index = buildIndexDocument(nextFiles[INDEX_PATH], parsedPages);
96
- const log = appendLogEntry(nextFiles[LOG_PATH], params);
110
+ const log = appendLogEntry(appendAgentLogEntries(nextFiles[LOG_PATH], params.agentLogEntries), params);
97
111
  files[INDEX_PATH] = index;
98
112
  files[LOG_PATH] = log;
99
113
  return { files, deletedPaths, findings: correctionFindings };
@@ -129,6 +143,9 @@ function collectWikiFrontmatter(files) {
129
143
  * when it is omitted), another writer changed it, so the write fails with `CONFLICT_EXHAUSTED` and
130
144
  * nothing is written. A ref race (`updateRef` 409, or 422 not-a-fast-forward) re-reads the head and
131
145
  * replays the same files on it, up to `maxRetries` attempts, under the same guard.
146
+ *
147
+ * With `handoff`, the handed-off paths are checked first on every attempt against the agent's data
148
+ * commit (`HANDOFF_PAGE_CHANGED`), so a page conflict is never reported as a ref race.
132
149
  */
133
150
  export async function commitWikiChanges(params) {
134
151
  const owner = params.owner ?? DEFAULT_OWNER;
@@ -144,6 +161,12 @@ export async function commitWikiChanges(params) {
144
161
  }
145
162
  const octokit = params.octokit ?? (await createOctokitFromEnv());
146
163
  rejectProtectedWikiBranchName(branch);
164
+ // Resolved outside the retry loop's try, ahead of any bootstrap or write: an unusable agent commit is
165
+ // terminal, and the loop's 404 handler must not mistake it for a missing data branch.
166
+ const handoffBaseTreeSha = params.handoff === undefined
167
+ ? undefined
168
+ : await resolveHandoffBaseTree(octokit, owner, repo, branch, params.handoff.agentCommitSha);
169
+ const handoffPaths = (params.handoff?.paths ?? []).filter(path => !isSharedWikiFile(path));
147
170
  const shouldBootstrapDataBranch = branch === DEFAULT_BRANCH;
148
171
  const bootstrap = params.bootstrapDataBranch ?? defaultBootstrapDataBranch;
149
172
  const bootstrapDataBranch = async () => {
@@ -167,23 +190,37 @@ export async function commitWikiChanges(params) {
167
190
  params.baseCommitSha === undefined
168
191
  ? commit.data.tree.sha
169
192
  : await readCommitTreeSha(octokit, owner, repo, params.baseCommitSha);
193
+ const location = `${owner}/${repo}@${branch}`;
194
+ // Handed-off pages first, against the commit the agent read, so a page conflict surfaces as such
195
+ // even when the head also moved under the shared files.
196
+ if (handoffBaseTreeSha !== undefined && handoffPaths.length > 0 && commit.data.tree.sha !== handoffBaseTreeSha) {
197
+ await assertWriteSetUnchanged(octokit, owner, repo, {
198
+ baseTreeSha: handoffBaseTreeSha,
199
+ headTreeSha: commit.data.tree.sha,
200
+ paths: handoffPaths,
201
+ location,
202
+ code: 'HANDOFF_PAGE_CHANGED',
203
+ });
204
+ }
170
205
  const guardBaseTreeSha = baseTreeSha;
171
206
  if (commit.data.tree.sha !== guardBaseTreeSha) {
172
207
  await assertWriteSetUnchanged(octokit, owner, repo, {
173
208
  baseTreeSha: guardBaseTreeSha,
174
209
  headTreeSha: commit.data.tree.sha,
175
210
  paths: [...Object.keys(params.files), ...(params.deletedPaths ?? [])],
176
- location: `${owner}/${repo}@${branch}`,
211
+ location,
212
+ code: 'CONFLICT_EXHAUSTED',
177
213
  });
178
214
  }
179
215
  const tree = [];
216
+ // Read before the first write: a deletion of a path that cannot be confirmed must not be skipped.
217
+ const presentPaths = params.deletedPaths === undefined || params.deletedPaths.length === 0
218
+ ? new Set()
219
+ : await getPresentPathsInTree(octokit, owner, repo, commit.data.tree.sha);
180
220
  for (const [path, content] of Object.entries(params.files)) {
181
221
  const blob = await octokit.rest.git.createBlob({ owner, repo, content, encoding: 'utf-8' });
182
222
  tree.push({ path, mode: '100644', type: 'blob', sha: blob.data.sha });
183
223
  }
184
- const presentPaths = params.deletedPaths === undefined || params.deletedPaths.length === 0
185
- ? new Set()
186
- : await getPresentPathsInTree(octokit, owner, repo, commit.data.tree.sha);
187
224
  for (const path of params.deletedPaths ?? []) {
188
225
  if (params.files[path] !== undefined || !presentPaths.has(path)) {
189
226
  continue;
@@ -256,40 +293,110 @@ async function assertWritableWikiBranch(octokit, owner, repo, branch) {
256
293
  });
257
294
  }
258
295
  async function getPresentPathsInTree(octokit, owner, repo, treeSha) {
259
- const response = await octokit.rest.git.getTree({ owner, repo, tree_sha: treeSha, recursive: 'true' });
260
- if (response.data.truncated === true)
261
- return new Set();
262
- const tree = response.data.tree;
263
- if (!Array.isArray(tree))
264
- return new Set();
265
- return new Set(tree.flatMap(entry => (isRecord(entry) && typeof entry.path === 'string' ? [entry.path] : [])));
296
+ const entries = await readTreeEntries(octokit, owner, repo, treeSha);
297
+ if (entries === undefined)
298
+ throw truncatedTreeRefusal('a deletion cannot be checked against a truncated tree');
299
+ return new Set(entries.keys());
300
+ }
301
+ /** A truncated tree listing cannot be trusted either way, so the write fails closed. */
302
+ function truncatedTreeRefusal(reason) {
303
+ return new WikiIngestError({
304
+ code: 'CONFLICT_EXHAUSTED',
305
+ message: `wiki ingest refused: ${reason}`,
306
+ remediation: 'The data branch tree listing was truncated. Retry the workflow; if it persists, the tree has outgrown the recursive listing.',
307
+ });
266
308
  }
267
309
  async function readCommitTreeSha(octokit, owner, repo, sha) {
268
310
  const commit = await octokit.rest.git.getCommit({ owner, repo, commit_sha: sha });
269
311
  return commit.data.tree.sha;
270
312
  }
271
313
  /**
272
- * Fail closed unless every path in the write set has the same tree entry (sha, mode, type; absence
273
- * counts) on the new head as in the tree the files were derived from. A replay onto a head where
274
- * another writer touched one of them would silently overwrite that writer's change.
314
+ * Resolves the agent job's data commit to its tree, after checking it is a full commit SHA and an
315
+ * ancestor of (or equal to) the branch head. Compare's `behind_by` is 0 and its merge base is the
316
+ * commit itself exactly when that holds.
317
+ */
318
+ async function resolveHandoffBaseTree(octokit, owner, repo, branch, agentCommitSha) {
319
+ if (!COMMIT_SHA_PATTERN.test(agentCommitSha)) {
320
+ throw handoffBaseInvalid('the agent job data commit is missing or is not a commit SHA');
321
+ }
322
+ try {
323
+ const head = await octokit.rest.git.getRef({ owner, repo, ref: `heads/${branch}` });
324
+ const comparison = await octokit.rest.repos.compareCommitsWithBasehead({
325
+ owner,
326
+ repo,
327
+ basehead: `${agentCommitSha}...${head.data.object.sha}`,
328
+ });
329
+ if (comparison.data.behind_by !== 0 || comparison.data.merge_base_commit.sha !== agentCommitSha) {
330
+ throw handoffBaseInvalid(`the agent job data commit is not an ancestor of ${owner}/${repo}@${branch}`);
331
+ }
332
+ return await readCommitTreeSha(octokit, owner, repo, agentCommitSha);
333
+ }
334
+ catch (error) {
335
+ if (error instanceof WikiIngestError)
336
+ throw error;
337
+ if (isApiErrorStatus(error, 404) || isApiErrorStatus(error, 422)) {
338
+ throw handoffBaseInvalid(`the agent job data commit does not resolve on ${owner}/${repo}@${branch}`);
339
+ }
340
+ throw error;
341
+ }
342
+ }
343
+ /**
344
+ * Refuses, before any content is validated, a handoff whose pages another writer has changed since the
345
+ * agent job synced. Validating a stale page against the current wiki can fail for an unrelated reason
346
+ * (a link to a page the other writer deleted), which would hide the conflict. The commit repeats this
347
+ * check on every attempt, which still catches a change that lands after this one.
348
+ */
349
+ async function assertHandoffPagesCurrent(octokit, owner, repo, branch, handoff) {
350
+ const baseTreeSha = await resolveHandoffBaseTree(octokit, owner, repo, branch, handoff.agentCommitSha);
351
+ const paths = handoff.paths.filter(path => !isSharedWikiFile(path));
352
+ if (paths.length === 0)
353
+ return;
354
+ const head = await octokit.rest.git.getRef({ owner, repo, ref: `heads/${branch}` });
355
+ const headTreeSha = await readCommitTreeSha(octokit, owner, repo, head.data.object.sha);
356
+ if (headTreeSha === baseTreeSha)
357
+ return;
358
+ await assertWriteSetUnchanged(octokit, owner, repo, {
359
+ baseTreeSha,
360
+ headTreeSha,
361
+ paths,
362
+ location: `${owner}/${repo}@${branch}`,
363
+ code: 'HANDOFF_PAGE_CHANGED',
364
+ });
365
+ }
366
+ /**
367
+ * Fail closed unless every path has the same tree entry (sha, mode, type; absence counts) on the new
368
+ * head as in the tree the files were derived from. A replay onto a head where another writer touched
369
+ * one of them would silently overwrite that writer's change. `code` names the refusal; a truncated
370
+ * tree read always refuses with `CONFLICT_EXHAUSTED`, because it says nothing about the pages.
275
371
  */
276
372
  async function assertWriteSetUnchanged(octokit, owner, repo, params) {
277
- const refuse = (reason) => {
373
+ const refuse = (code, reason) => {
278
374
  throw new WikiIngestError({
279
- code: 'CONFLICT_EXHAUSTED',
280
- message: `wiki ingest did not replay onto ${params.location}: ${reason}`,
281
- remediation: 'Another writer updated the data branch concurrently. Retry the workflow so it rebuilds on the new head.',
375
+ code,
376
+ message: code === 'HANDOFF_PAGE_CHANGED'
377
+ ? `wiki ingest refused to write to ${params.location}: ${reason}`
378
+ : `wiki ingest did not replay onto ${params.location}: ${reason}`,
379
+ remediation: code === 'HANDOFF_PAGE_CHANGED'
380
+ ? 'Another writer changed a page after the agent job synced. Re-run the agent job so it starts from the current data branch.'
381
+ : 'Another writer updated the data branch concurrently. Retry the workflow so it rebuilds on the new head.',
282
382
  });
283
383
  };
284
384
  const base = await readTreeEntries(octokit, owner, repo, params.baseTreeSha);
285
385
  const head = await readTreeEntries(octokit, owner, repo, params.headTreeSha);
286
386
  if (base === undefined || head === undefined)
287
- return refuse('a tree read was truncated, so the write set cannot be verified');
288
- if (params.paths.some(path => base.get(path) !== head.get(path))) {
289
- return refuse('another writer changed a path in the write set');
387
+ return refuse('CONFLICT_EXHAUSTED', 'a tree read was truncated, so the write set cannot be verified');
388
+ if (params.paths.some(path => !sameTreeEntry(base.get(path), head.get(path)))) {
389
+ return refuse(params.code, params.code === 'HANDOFF_PAGE_CHANGED'
390
+ ? 'a handed-off page changed after the agent job synced'
391
+ : 'another writer changed a path in the write set');
290
392
  }
291
393
  }
292
- /** Path to `mode type sha`, for every non-directory entry. `undefined` when the read is truncated. */
394
+ function sameTreeEntry(left, right) {
395
+ if (left === undefined || right === undefined)
396
+ return left === right;
397
+ return left.mode === right.mode && left.type === right.type && left.sha === right.sha;
398
+ }
399
+ /** Path to its entry, for every non-directory entry. `undefined` when the read is truncated. */
293
400
  async function readTreeEntries(octokit, owner, repo, treeSha) {
294
401
  const response = await octokit.rest.git.getTree({ owner, repo, tree_sha: treeSha, recursive: 'true' });
295
402
  if (response.data.truncated === true)
@@ -301,7 +408,7 @@ async function readTreeEntries(octokit, owner, repo, treeSha) {
301
408
  for (const entry of tree) {
302
409
  if (!isRecord(entry) || typeof entry.path !== 'string' || entry.type === 'tree')
303
410
  continue;
304
- entries.set(entry.path, `${String(entry.mode)} ${String(entry.type)} ${String(entry.sha)}`);
411
+ entries.set(entry.path, { mode: String(entry.mode), type: String(entry.type), sha: String(entry.sha) });
305
412
  }
306
413
  return entries;
307
414
  }
@@ -524,15 +631,18 @@ function parseIndexEntryLines(index) {
524
631
  const entries = new Map();
525
632
  // Match the full line: "- [[slug]] — description"
526
633
  // Description may contain anything except a newline (we keep the entire trailing text).
527
- const pattern = /^- \[\[([^\]|]+)\]\]\s*—\s*(?:\S.*|[\t\v\f \u00A0\u1680\u2000-\u200A\u202F\u205F\u3000\uFEFF])$/gmu;
528
- for (const match of index.matchAll(pattern)) {
529
- const [line, slug] = match;
530
- if (line === undefined || slug === undefined)
531
- continue;
634
+ for (const { slug, line } of findIndexEntryLines(index)) {
532
635
  entries.set(slug.trim(), line);
533
636
  }
534
637
  return entries;
535
638
  }
639
+ /** Appends raw log entries (already in log format) to `existingLog`, keeping its text byte-for-byte. */
640
+ function appendAgentLogEntries(existingLog, entries) {
641
+ if (entries === undefined || entries.trim() === '')
642
+ return existingLog;
643
+ const base = existingLog === undefined || existingLog === '' ? LOG_HEADER : normalizeText(existingLog);
644
+ return normalizeText(`${base}${entries.startsWith('\n') ? entries : `\n${entries}`}`);
645
+ }
536
646
  /**
537
647
  * Append one entry to `knowledge/log.md`, keeping the existing text byte-for-byte. The log is
538
648
  * append-only; callers must never rewrite history. Exported for the operator rename path.
@@ -935,7 +1045,7 @@ async function getChangedWikiPaths() {
935
1045
  `${WIKI_ROOT}/entities`,
936
1046
  `${WIKI_ROOT}/comparisons`,
937
1047
  ]);
938
- return parsePorcelainPaths(stdout).filter(path => path !== INDEX_PATH && path !== LOG_PATH && path.endsWith('.md'));
1048
+ return parsePorcelainPaths(stdout).filter(path => !isSharedWikiFile(path) && path.endsWith('.md'));
939
1049
  }
940
1050
  /**
941
1051
  * Parse paths from `git status --porcelain=v1` output.
@@ -1031,6 +1141,114 @@ async function emitPagesChanged(n) {
1031
1141
  await appendFile(outputPath, `pages_changed=${n}\n`);
1032
1142
  }
1033
1143
  }
1144
+ function envValue(name) {
1145
+ const value = process.env[name];
1146
+ return value === undefined || value === '' ? undefined : value;
1147
+ }
1148
+ function handoffBaseInvalid(message) {
1149
+ return new WikiIngestError({
1150
+ code: 'HANDOFF_BASE_INVALID',
1151
+ message: `wiki ingest refused: ${message}`,
1152
+ remediation: HANDOFF_BASE_REMEDIATION,
1153
+ });
1154
+ }
1155
+ /**
1156
+ * LF line endings and exactly one final newline; an empty log stays empty. Unlike `normalizeText`, which
1157
+ * only adds a missing final newline, this also converts CRLF and drops trailing blank lines.
1158
+ */
1159
+ function toLfWithSingleFinalNewline(value) {
1160
+ const trimmed = value.replaceAll('\r\n', '\n').trimEnd();
1161
+ return trimmed === '' ? '' : `${trimmed}\n`;
1162
+ }
1163
+ /**
1164
+ * The log text an agent appended: what its `knowledge/log.md` adds after `knowledge/log.md` at the
1165
+ * commit it started from. `undefined` when the agent changed anything before that point, or the base
1166
+ * log is empty, so there is nothing safe to take. Both inputs are normalized first (LF, one final newline).
1167
+ */
1168
+ export function deriveAgentLogEntries(baseLog, handoffLog) {
1169
+ const base = toLfWithSingleFinalNewline(baseLog);
1170
+ const handoff = toLfWithSingleFinalNewline(handoffLog);
1171
+ if (base === '' || !handoff.startsWith(base))
1172
+ return undefined;
1173
+ return handoff.slice(base.length);
1174
+ }
1175
+ async function readHandoffManifest(handoffDir) {
1176
+ const raw = await readFile(join(handoffDir, 'manifest.json'), 'utf8');
1177
+ let parsed;
1178
+ try {
1179
+ parsed = JSON.parse(raw);
1180
+ }
1181
+ catch {
1182
+ throw new WikiIngestError({
1183
+ code: 'INVALID_PAYLOAD',
1184
+ message: 'wiki handoff manifest.json is not valid JSON',
1185
+ remediation: 'Rebuild the handoff artifact with scripts/wiki-handoff-build.ts.',
1186
+ });
1187
+ }
1188
+ const isStringArray = (value) => Array.isArray(value) && value.every(entry => typeof entry === 'string');
1189
+ if (!isRecord(parsed) || !isStringArray(parsed.changed) || !isStringArray(parsed.deleted)) {
1190
+ throw new WikiIngestError({
1191
+ code: 'INVALID_PAYLOAD',
1192
+ message: 'wiki handoff manifest.json must hold string arrays "changed" and "deleted"',
1193
+ remediation: 'Rebuild the handoff artifact with scripts/wiki-handoff-build.ts.',
1194
+ });
1195
+ }
1196
+ return { changed: parsed.changed, deleted: parsed.deleted };
1197
+ }
1198
+ /** The text of `path` in the tree of `commitSha`, or `undefined` when the commit has no such file. */
1199
+ async function readFileAtCommit(octokit, owner, repo, commitSha, path) {
1200
+ const entries = await readTreeEntries(octokit, owner, repo, await readCommitTreeSha(octokit, owner, repo, commitSha));
1201
+ if (entries === undefined)
1202
+ throw truncatedTreeRefusal(`${path} cannot be read from a truncated tree`);
1203
+ const entry = entries.get(path);
1204
+ if (entry === undefined || entry.type !== 'blob')
1205
+ return undefined;
1206
+ const blob = await octokit.rest.git.getBlob({ owner, repo, file_sha: entry.sha });
1207
+ return Buffer.from(blob.data.content, 'base64').toString('utf8');
1208
+ }
1209
+ /**
1210
+ * Reads the handoff the trusted job applied. `undefined` outside a handoff run (neither
1211
+ * `WIKI_HANDOFF_DIR` nor `WIKI_HANDOFF_BASE_SHA` set). Inside one, the agent job's data commit must be a
1212
+ * full commit SHA, the handed-off pages must be unchanged on the branch since that commit (checked here,
1213
+ * ahead of any validation of their content), and the log entries the agent appended are derived against that commit's `log.md`.
1214
+ */
1215
+ async function loadHandoff(dependencies, target) {
1216
+ const handoffDir = envValue('WIKI_HANDOFF_DIR');
1217
+ const agentCommitSha = envValue('WIKI_HANDOFF_BASE_SHA');
1218
+ if (handoffDir === undefined && agentCommitSha === undefined)
1219
+ return undefined;
1220
+ if (handoffDir === undefined || agentCommitSha === undefined || !COMMIT_SHA_PATTERN.test(agentCommitSha)) {
1221
+ throw handoffBaseInvalid('the agent job data commit is missing or is not a commit SHA');
1222
+ }
1223
+ const octokit = dependencies.octokit ?? (await createOctokitFromEnv());
1224
+ const manifest = await readHandoffManifest(handoffDir);
1225
+ const pagePaths = [...manifest.changed, ...manifest.deleted];
1226
+ const deletedPagePaths = manifest.deleted.filter(path => !isSharedWikiFile(path));
1227
+ await assertHandoffPagesCurrent(octokit, target.owner ?? DEFAULT_OWNER, target.repo ?? DEFAULT_REPO, target.branch ?? DEFAULT_BRANCH, { agentCommitSha, paths: pagePaths });
1228
+ if (!manifest.changed.includes(LOG_PATH)) {
1229
+ return { octokit, agentCommitSha, agentLogEntries: undefined, pagePaths, deletedPagePaths, warnings: [] };
1230
+ }
1231
+ const handoffLog = await readFile(join(handoffDir, 'files', LOG_PATH), 'utf8');
1232
+ let baseLog;
1233
+ try {
1234
+ baseLog = await readFileAtCommit(octokit, target.owner ?? DEFAULT_OWNER, target.repo ?? DEFAULT_REPO, agentCommitSha, LOG_PATH);
1235
+ }
1236
+ catch (error) {
1237
+ // Only a commit or file that does not exist is a bad base; any other failure is not about the base.
1238
+ if (isApiErrorStatus(error, 404))
1239
+ throw handoffBaseInvalid(`the agent job data commit does not resolve`);
1240
+ throw error;
1241
+ }
1242
+ if (baseLog === undefined)
1243
+ throw handoffBaseInvalid(`${LOG_PATH} does not exist at the agent job data commit`);
1244
+ const agentLogEntries = deriveAgentLogEntries(baseLog, handoffLog);
1245
+ const warnings = agentLogEntries === undefined ? [{ code: 'agent-log-entries-dropped', reason: 'not-append-only' }] : [];
1246
+ return { octokit, agentCommitSha, agentLogEntries, pagePaths, deletedPagePaths, warnings };
1247
+ }
1248
+ function writeWarnings(warnings) {
1249
+ for (const warning of warnings)
1250
+ process.stderr.write(`wiki-ingest:warning:${JSON.stringify(warning)}\n`);
1251
+ }
1034
1252
  export async function runWikiIngestCli(dependencies = {}) {
1035
1253
  const payloadPath = process.argv[2] ?? process.env.WIKI_INGEST_INPUT;
1036
1254
  const readCorrectionsImpl = dependencies.readCorrections ?? readCorrections;
@@ -1062,9 +1280,12 @@ export async function runWikiIngestCli(dependencies = {}) {
1062
1280
  let message;
1063
1281
  let identityWarnings = [];
1064
1282
  let committedPagePaths;
1283
+ let handoff;
1065
1284
  try {
1066
1285
  if (payloadPath !== undefined && payloadPath !== '') {
1067
1286
  const payload = parsePayload(await readFile(payloadPath, 'utf8'));
1287
+ handoff = await loadHandoff(dependencies, payload);
1288
+ writeWarnings(handoff?.warnings ?? []);
1068
1289
  const tracked = await loadTrackedRepoNodeIds();
1069
1290
  identityWarnings = tracked.warnings;
1070
1291
  built = buildWikiIngestChanges({
@@ -1079,6 +1300,8 @@ export async function runWikiIngestCli(dependencies = {}) {
1079
1300
  fallbackNodeId: payload.node_id,
1080
1301
  trackedMetadataAvailable: tracked.metadataAvailable,
1081
1302
  corrections: correctionRead.corrections,
1303
+ agentLogEntries: handoff?.agentLogEntries,
1304
+ handoffDeletedPaths: handoff?.deletedPagePaths,
1082
1305
  });
1083
1306
  owner = payload.owner;
1084
1307
  repo = payload.repo;
@@ -1088,7 +1311,17 @@ export async function runWikiIngestCli(dependencies = {}) {
1088
1311
  }
1089
1312
  else {
1090
1313
  const changedPaths = await getChangedWikiPathsImpl();
1091
- if (changedPaths.length === 0) {
1314
+ handoff = await loadHandoff(dependencies, {
1315
+ owner: process.env.WIKI_OWNER,
1316
+ repo: process.env.WIKI_REPO,
1317
+ branch: process.env.WIKI_BRANCH,
1318
+ });
1319
+ writeWarnings(handoff?.warnings ?? []);
1320
+ // `git status` drops deletions, so an agent's deleted pages come from the manifest. A handoff
1321
+ // whose only change is appended log entries still commits the log and the rebuilt index.
1322
+ if (changedPaths.length === 0 &&
1323
+ (handoff?.deletedPagePaths ?? []).length === 0 &&
1324
+ (handoff?.agentLogEntries ?? '').trim() === '') {
1092
1325
  await emitPagesChanged(0);
1093
1326
  process.stdout.write(`${JSON.stringify({ committed: false, attempts: 1, pagesChanged: 0 })}\n`);
1094
1327
  return;
@@ -1110,6 +1343,8 @@ export async function runWikiIngestCli(dependencies = {}) {
1110
1343
  targetNodeId: process.env.REPO_NODE_ID,
1111
1344
  trackedMetadataAvailable: tracked.metadataAvailable,
1112
1345
  corrections: correctionRead.corrections,
1346
+ agentLogEntries: handoff?.agentLogEntries,
1347
+ handoffDeletedPaths: handoff?.deletedPagePaths,
1113
1348
  });
1114
1349
  owner = process.env.WIKI_OWNER;
1115
1350
  repo = process.env.WIKI_REPO;
@@ -1128,9 +1363,7 @@ export async function runWikiIngestCli(dependencies = {}) {
1128
1363
  }
1129
1364
  throw error;
1130
1365
  }
1131
- for (const warning of identityWarnings) {
1132
- process.stderr.write(`wiki-ingest:warning:${JSON.stringify(warning)}\n`);
1133
- }
1366
+ writeWarnings(identityWarnings);
1134
1367
  for (const finding of built.findings) {
1135
1368
  process.stderr.write(`wiki-ingest:finding:${JSON.stringify(finding)}\n`);
1136
1369
  }
@@ -1142,6 +1375,9 @@ export async function runWikiIngestCli(dependencies = {}) {
1142
1375
  files: built.files,
1143
1376
  deletedPaths: built.deletedPaths,
1144
1377
  ...(baseCommitSha === undefined ? {} : { baseCommitSha }),
1378
+ ...(handoff === undefined
1379
+ ? {}
1380
+ : { octokit: handoff.octokit, handoff: { agentCommitSha: handoff.agentCommitSha, paths: handoff.pagePaths } }),
1145
1381
  });
1146
1382
  const pagesChanged = countWikiPages(committedPagePaths);
1147
1383
  await emitPagesChanged(pagesChanged);
@@ -200,9 +200,12 @@ export function truncateToBytes(value, maxBytes) {
200
200
  if (contentBudget <= 0) {
201
201
  return '';
202
202
  }
203
- const truncated = Buffer.from(value)
204
- .subarray(0, contentBudget)
205
- .toString('utf8')
206
- .replaceAll(/\uFFFD+$/gu, '');
203
+ const decoded = Buffer.from(value).subarray(0, contentBudget).toString('utf8');
204
+ // Drop the replacement characters a cut multi-byte sequence decodes to. A trailing-run regex rescans a long run
205
+ // from every position in it.
206
+ let keep = decoded.length;
207
+ while (keep > 0 && decoded.charCodeAt(keep - 1) === 0xff_fd)
208
+ keep -= 1;
209
+ const truncated = decoded.slice(0, keep);
207
210
  return truncated === '' ? '' : `${truncated}${ellipsis}`;
208
211
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fro.bot/wiki-write-core",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "description": "Shared validation and commit primitives for Fro Bot wiki writes",
5
5
  "homepage": "https://github.com/fro-bot/.github/tree/main/packages/wiki-write-core#readme",
6
6
  "bugs": {