codecartographer-pi 0.24.1 → 0.26.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.codecarto/broadside/SKILL.md +20 -1
- package/.codecarto/workflow/scaffold-version.yaml +1 -1
- package/README.md +5 -4
- package/agent-skill/codecartographer/references/broadside.md +5 -1
- package/dist/core/broadside/client.d.ts +56 -0
- package/dist/core/broadside/client.js +200 -0
- package/dist/core/broadside/collect.d.ts +68 -0
- package/dist/core/broadside/collect.js +676 -0
- package/dist/core/broadside/constants.d.ts +51 -0
- package/dist/core/broadside/constants.js +74 -0
- package/dist/core/broadside/lenses.d.ts +31 -0
- package/dist/core/broadside/lenses.js +312 -0
- package/dist/core/broadside/models.d.ts +46 -0
- package/dist/core/broadside/models.js +321 -0
- package/dist/core/broadside/render.d.ts +20 -0
- package/dist/core/broadside/render.js +285 -0
- package/dist/core/broadside/repo.d.ts +58 -0
- package/dist/core/broadside/repo.js +592 -0
- package/dist/core/broadside/requests.d.ts +23 -0
- package/dist/core/broadside/requests.js +71 -0
- package/dist/core/broadside/results.d.ts +36 -0
- package/dist/core/broadside/results.js +163 -0
- package/dist/core/broadside/schemas.d.ts +2 -0
- package/dist/core/broadside/schemas.js +342 -0
- package/dist/core/broadside/state.d.ts +99 -0
- package/dist/core/broadside/state.js +384 -0
- package/dist/core/broadside/submit.d.ts +30 -0
- package/dist/core/broadside/submit.js +350 -0
- package/dist/core/broadside/types.d.ts +491 -0
- package/dist/core/broadside/types.js +107 -0
- package/dist/core/{broadside-verify.d.ts → broadside/verify.d.ts} +23 -2
- package/dist/core/{broadside-verify.js → broadside/verify.js} +43 -5
- package/dist/core/broadside.d.ts +14 -890
- package/dist/core/broadside.js +25 -3564
- package/dist/core/completion.js +91 -72
- package/dist/core/dashboard-writer.js +9 -1
- package/dist/core/index.d.ts +0 -1
- package/dist/core/index.js +0 -1
- package/dist/core/library.d.ts +24 -1
- package/dist/core/library.js +46 -15
- package/dist/core/orchestrator-config.js +22 -8
- package/dist/core/status.d.ts +42 -23
- package/dist/core/status.js +163 -137
- package/dist/core/workspace.d.ts +2 -0
- package/dist/core/workspace.js +49 -25
- package/dist/core/yaml.js +9 -3
- package/dist/extensions/codecarto/auto-runner.d.ts +7 -0
- package/dist/extensions/codecarto/auto-runner.js +54 -23
- package/dist/extensions/codecarto/broadside-flags.d.ts +3 -1
- package/dist/extensions/codecarto/broadside-flags.js +13 -0
- package/dist/extensions/codecarto/index.js +13 -7
- package/dist/extensions/codecarto/phase-compaction.js +6 -2
- package/dist/mcp-server/server.d.ts +1 -0
- package/dist/mcp-server/server.js +28 -5
- package/package.json +1 -1
package/dist/core/completion.js
CHANGED
|
@@ -280,6 +280,91 @@ function mentionsAnyId(text, ids) {
|
|
|
280
280
|
}
|
|
281
281
|
return false;
|
|
282
282
|
}
|
|
283
|
+
/**
|
|
284
|
+
* The handoff gates that depend on the state they are judged against —
|
|
285
|
+
* carry_forward targets against the pipeline order, D1 against the open
|
|
286
|
+
* questions and derives_from links, D3 against question kinds. Run once
|
|
287
|
+
* before the lock as the cheap refusal and again on the locked read: a
|
|
288
|
+
* status change between the two (a concurrent completion, an amendment, a
|
|
289
|
+
* rollback) used to be applied over as if the first verdict still held
|
|
290
|
+
* (#360; the same shape as #337 for amendments). Returns the warnings the
|
|
291
|
+
* version-gated D3 produces on an older scaffold.
|
|
292
|
+
*/
|
|
293
|
+
function judgeHandoffAgainstState(state, handoff, validation) {
|
|
294
|
+
const warnings = [];
|
|
295
|
+
if (handoff) {
|
|
296
|
+
const activePhases = new Set(state.pipeline.phase_order);
|
|
297
|
+
const sourceIndex = state.pipeline.phase_order.indexOf(validation.phaseId);
|
|
298
|
+
for (const entry of handoff.carry_forward) {
|
|
299
|
+
const targetIndex = entry.target_phase ? state.pipeline.phase_order.indexOf(entry.target_phase) : -1;
|
|
300
|
+
if (!entry.target_phase || !activePhases.has(entry.target_phase) || targetIndex <= sourceIndex) {
|
|
301
|
+
throw new Error(`Invalid handoff: carry_forward target_phase ${entry.target_phase ?? "(missing)"} is not a downstream active pipeline phase; use post_pipeline for work after the pipeline`);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
for (const entry of handoff.post_pipeline) {
|
|
305
|
+
if (!entry.id?.trim())
|
|
306
|
+
throw new Error("Invalid handoff: post_pipeline entries require a canonical id");
|
|
307
|
+
}
|
|
308
|
+
// Closure integrity, gating (#122, #186). Both checks are deterministic
|
|
309
|
+
// reads of ids the model wrote itself, so neither can wedge an --auto run
|
|
310
|
+
// on a heuristic; both sit here, before the lock, alongside the
|
|
311
|
+
// target_phase check, so a refusal mutates nothing.
|
|
312
|
+
const questionsById = new Map();
|
|
313
|
+
const derivesFromById = new Map();
|
|
314
|
+
for (const phaseState of Object.values(state.status.phases)) {
|
|
315
|
+
for (const entry of phaseState.open_questions ?? []) {
|
|
316
|
+
if (entry.id)
|
|
317
|
+
questionsById.set(entry.id, entry);
|
|
318
|
+
}
|
|
319
|
+
for (const entry of phaseState.carry_forward ?? []) {
|
|
320
|
+
if (entry.id && entry.derives_from)
|
|
321
|
+
derivesFromById.set(entry.id, entry.derives_from);
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
const closingQuestionIds = new Set(handoff.open_question_closures.map((closure) => closure.id).filter(Boolean));
|
|
325
|
+
// D1: a routed item that declares `derives_from` is one candidate answer
|
|
326
|
+
// to that question. Closing it while the question stands is exactly the
|
|
327
|
+
// contradiction #122 reported — the routed candidate shipped as settled
|
|
328
|
+
// while the question that said "source alone cannot determine which" was
|
|
329
|
+
// still open. A derives_from naming an id that no longer exists is fine:
|
|
330
|
+
// the question was already resolved.
|
|
331
|
+
for (const closureId of handoff.carry_forward_closures) {
|
|
332
|
+
const questionId = derivesFromById.get(closureId);
|
|
333
|
+
if (!questionId || !questionsById.has(questionId))
|
|
334
|
+
continue;
|
|
335
|
+
if (closingQuestionIds.has(questionId))
|
|
336
|
+
continue;
|
|
337
|
+
throw new Error(`Refusing to complete ${validation.phaseId}: the handoff closes carry_forward ${closureId}, which derives_from open question ${questionId} — and ${questionId} is still unresolved and is not in this handoff's open_question_closures. `
|
|
338
|
+
+ `A routed item is one candidate answer to the question it came from; closing it does not settle the question. `
|
|
339
|
+
+ `Either close ${questionId} in this same handoff with the evidence that settles it, or leave ${closureId} routed and give the finding an unsettled action ("verify at runtime") instead.`);
|
|
340
|
+
}
|
|
341
|
+
// D3: a `needs-runtime-test` question closes on runtime evidence, not on
|
|
342
|
+
// another source read. Requiring the evidence string to be non-empty is
|
|
343
|
+
// the whole gate — judging what it says stays prose guidance.
|
|
344
|
+
//
|
|
345
|
+
// Unlike D1, this one can fire on a handoff that uses none of the new
|
|
346
|
+
// fields: a bare-string closure was the only shape before this release.
|
|
347
|
+
// Gating it unconditionally would apply a rule to workspaces whose own
|
|
348
|
+
// templates never state it, so it is version-gated exactly like Stage
|
|
349
|
+
// 2's pairing check — refuse on a scaffold that documents the rule, warn
|
|
350
|
+
// on one that predates it.
|
|
351
|
+
const evidenceGateActive = closureEvidenceGateActive(state.scaffoldVersion);
|
|
352
|
+
for (const closure of handoff.open_question_closures) {
|
|
353
|
+
if (questionsById.get(closure.id)?.kind !== "needs-runtime-test")
|
|
354
|
+
continue;
|
|
355
|
+
if (closure.evidence?.trim())
|
|
356
|
+
continue;
|
|
357
|
+
const detail = `open_question_closures closes ${closure.id}, whose kind is needs-runtime-test, without evidence. `
|
|
358
|
+
+ `A runtime question closes on runtime evidence — a spike report or an observation against the running system — not on a source read. `
|
|
359
|
+
+ `Write the closure as an object: { id: ${closure.id}, evidence: <where that evidence lives> }. If you do not have it, leave the question open.`;
|
|
360
|
+
if (evidenceGateActive)
|
|
361
|
+
throw new Error(`Refusing to complete ${validation.phaseId}: ${detail}`);
|
|
362
|
+
warnings.push(`${detail} Warning only: this workspace's scaffold predates the requirement — refresh it `
|
|
363
|
+
+ `(codecarto_refresh_scaffold on MCP, /codecarto-refresh-scaffold on Pi) to make this gating.`);
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
return warnings;
|
|
367
|
+
}
|
|
283
368
|
/**
|
|
284
369
|
* Turn each PARTIAL validation row into a `needs-maintainer-decision`
|
|
285
370
|
* question on the phase, unless the row's criterion or evidence cell names an
|
|
@@ -354,78 +439,10 @@ export async function completeValidatedPhase(cwd, validation, sourceLabel) {
|
|
|
354
439
|
+ `plus closeout_summary and optional closeout_content. Then re-run completion.`);
|
|
355
440
|
}
|
|
356
441
|
}
|
|
442
|
+
// The cheap refusal, before the lock; the verdict that counts is taken
|
|
443
|
+
// again on the locked read below.
|
|
444
|
+
let stateWarnings = judgeHandoffAgainstState(initialState, handoff, validation);
|
|
357
445
|
const warnings = [];
|
|
358
|
-
if (handoff) {
|
|
359
|
-
const activePhases = new Set(initialState.pipeline.phase_order);
|
|
360
|
-
const sourceIndex = initialState.pipeline.phase_order.indexOf(validation.phaseId);
|
|
361
|
-
for (const entry of handoff.carry_forward) {
|
|
362
|
-
const targetIndex = entry.target_phase ? initialState.pipeline.phase_order.indexOf(entry.target_phase) : -1;
|
|
363
|
-
if (!entry.target_phase || !activePhases.has(entry.target_phase) || targetIndex <= sourceIndex) {
|
|
364
|
-
throw new Error(`Invalid handoff: carry_forward target_phase ${entry.target_phase ?? "(missing)"} is not a downstream active pipeline phase; use post_pipeline for work after the pipeline`);
|
|
365
|
-
}
|
|
366
|
-
}
|
|
367
|
-
for (const entry of handoff.post_pipeline) {
|
|
368
|
-
if (!entry.id?.trim())
|
|
369
|
-
throw new Error("Invalid handoff: post_pipeline entries require a canonical id");
|
|
370
|
-
}
|
|
371
|
-
// Closure integrity, gating (#122, #186). Both checks are deterministic
|
|
372
|
-
// reads of ids the model wrote itself, so neither can wedge an --auto run
|
|
373
|
-
// on a heuristic; both sit here, before the lock, alongside the
|
|
374
|
-
// target_phase check, so a refusal mutates nothing.
|
|
375
|
-
const questionsById = new Map();
|
|
376
|
-
const derivesFromById = new Map();
|
|
377
|
-
for (const phaseState of Object.values(initialState.status.phases)) {
|
|
378
|
-
for (const entry of phaseState.open_questions ?? []) {
|
|
379
|
-
if (entry.id)
|
|
380
|
-
questionsById.set(entry.id, entry);
|
|
381
|
-
}
|
|
382
|
-
for (const entry of phaseState.carry_forward ?? []) {
|
|
383
|
-
if (entry.id && entry.derives_from)
|
|
384
|
-
derivesFromById.set(entry.id, entry.derives_from);
|
|
385
|
-
}
|
|
386
|
-
}
|
|
387
|
-
const closingQuestionIds = new Set(handoff.open_question_closures.map((closure) => closure.id).filter(Boolean));
|
|
388
|
-
// D1: a routed item that declares `derives_from` is one candidate answer
|
|
389
|
-
// to that question. Closing it while the question stands is exactly the
|
|
390
|
-
// contradiction #122 reported — the routed candidate shipped as settled
|
|
391
|
-
// while the question that said "source alone cannot determine which" was
|
|
392
|
-
// still open. A derives_from naming an id that no longer exists is fine:
|
|
393
|
-
// the question was already resolved.
|
|
394
|
-
for (const closureId of handoff.carry_forward_closures) {
|
|
395
|
-
const questionId = derivesFromById.get(closureId);
|
|
396
|
-
if (!questionId || !questionsById.has(questionId))
|
|
397
|
-
continue;
|
|
398
|
-
if (closingQuestionIds.has(questionId))
|
|
399
|
-
continue;
|
|
400
|
-
throw new Error(`Refusing to complete ${validation.phaseId}: the handoff closes carry_forward ${closureId}, which derives_from open question ${questionId} — and ${questionId} is still unresolved and is not in this handoff's open_question_closures. `
|
|
401
|
-
+ `A routed item is one candidate answer to the question it came from; closing it does not settle the question. `
|
|
402
|
-
+ `Either close ${questionId} in this same handoff with the evidence that settles it, or leave ${closureId} routed and give the finding an unsettled action ("verify at runtime") instead.`);
|
|
403
|
-
}
|
|
404
|
-
// D3: a `needs-runtime-test` question closes on runtime evidence, not on
|
|
405
|
-
// another source read. Requiring the evidence string to be non-empty is
|
|
406
|
-
// the whole gate — judging what it says stays prose guidance.
|
|
407
|
-
//
|
|
408
|
-
// Unlike D1, this one can fire on a handoff that uses none of the new
|
|
409
|
-
// fields: a bare-string closure was the only shape before this release.
|
|
410
|
-
// Gating it unconditionally would apply a rule to workspaces whose own
|
|
411
|
-
// templates never state it, so it is version-gated exactly like Stage
|
|
412
|
-
// 2's pairing check — refuse on a scaffold that documents the rule, warn
|
|
413
|
-
// on one that predates it.
|
|
414
|
-
const evidenceGateActive = closureEvidenceGateActive(initialState.scaffoldVersion);
|
|
415
|
-
for (const closure of handoff.open_question_closures) {
|
|
416
|
-
if (questionsById.get(closure.id)?.kind !== "needs-runtime-test")
|
|
417
|
-
continue;
|
|
418
|
-
if (closure.evidence?.trim())
|
|
419
|
-
continue;
|
|
420
|
-
const detail = `open_question_closures closes ${closure.id}, whose kind is needs-runtime-test, without evidence. `
|
|
421
|
-
+ `A runtime question closes on runtime evidence — a spike report or an observation against the running system — not on a source read. `
|
|
422
|
-
+ `Write the closure as an object: { id: ${closure.id}, evidence: <where that evidence lives> }. If you do not have it, leave the question open.`;
|
|
423
|
-
if (evidenceGateActive)
|
|
424
|
-
throw new Error(`Refusing to complete ${validation.phaseId}: ${detail}`);
|
|
425
|
-
warnings.push(`${detail} Warning only: this workspace's scaffold predates the requirement — refresh it `
|
|
426
|
-
+ `(codecarto_refresh_scaffold on MCP, /codecarto-refresh-scaffold on Pi) to make this gating.`);
|
|
427
|
-
}
|
|
428
|
-
}
|
|
429
446
|
// Closure integrity (#122, warning only): a handoff can close a carry-forward
|
|
430
447
|
// or open question the report never addressed — "closed in the handoff,
|
|
431
448
|
// resolved nowhere." The id of every claimed closure should appear somewhere
|
|
@@ -444,6 +461,8 @@ export async function completeValidatedPhase(cwd, validation, sourceLabel) {
|
|
|
444
461
|
let closeoutPath;
|
|
445
462
|
let orchestratorCheckpoint;
|
|
446
463
|
const updatedState = await updateStatusAtomically(cwd, async (lockedState) => {
|
|
464
|
+
// The verdict that counts is the one on the state about to be written.
|
|
465
|
+
stateWarnings = judgeHandoffAgainstState(lockedState, handoff, validation);
|
|
447
466
|
const phase = resolvePhase(lockedState, validation.phaseId);
|
|
448
467
|
if (!phase?.primary_output)
|
|
449
468
|
throw new Error(`Phase ${validation.phaseId} is missing primary_output.`);
|
|
@@ -510,6 +529,6 @@ export async function completeValidatedPhase(cwd, validation, sourceLabel) {
|
|
|
510
529
|
updatedState,
|
|
511
530
|
closeoutNotice: closeoutPath ? `Closeout: ${closeoutPath}` : undefined,
|
|
512
531
|
orchestratorCheckpoint,
|
|
513
|
-
warnings,
|
|
532
|
+
warnings: [...stateWarnings, ...warnings],
|
|
514
533
|
};
|
|
515
534
|
}
|
|
@@ -9,7 +9,15 @@
|
|
|
9
9
|
// a core primitive like everything else they share.
|
|
10
10
|
import { readdir, readFile } from "node:fs/promises";
|
|
11
11
|
import { join } from "node:path";
|
|
12
|
-
|
|
12
|
+
// From the modules that define them, not the barrel: `core/index.ts`
|
|
13
|
+
// re-exports this file, and a module that imports the barrel that exports it
|
|
14
|
+
// is a cycle that only resolves because every binding is read at call time
|
|
15
|
+
// (#371).
|
|
16
|
+
import { DASHBOARD_RELATIVE_PATH, NARRATION_CACHE_RELATIVE_PATH, renderDashboard } from "./dashboard.js";
|
|
17
|
+
import { loadUsage } from "./usage.js";
|
|
18
|
+
import { atomicWriteFile, pathExists } from "./utils.js";
|
|
19
|
+
import { getWorkspaceState } from "./workspace.js";
|
|
20
|
+
import { parseSimpleYaml } from "./yaml.js";
|
|
13
21
|
const CLOSEOUT_FILENAME_RE = /^(\d{4}-\d{2}-\d{2})-(.+)\.md$/;
|
|
14
22
|
/**
|
|
15
23
|
* Render and atomically replace `.codecarto/dashboard.html`.
|
package/dist/core/index.d.ts
CHANGED
package/dist/core/index.js
CHANGED
package/dist/core/library.d.ts
CHANGED
|
@@ -271,8 +271,31 @@ export interface ListEntriesFilter {
|
|
|
271
271
|
slug?: string;
|
|
272
272
|
source_repo?: string;
|
|
273
273
|
}
|
|
274
|
+
/** How `listEntries` found the index it answered from (#357). */
|
|
275
|
+
export type LibraryIndexState = "indexed" | "missing" | "unparseable";
|
|
274
276
|
export declare function listEntries(libraryRoot: string, filter?: ListEntriesFilter): Promise<LibraryIndexEntry[]>;
|
|
275
|
-
|
|
277
|
+
/**
|
|
278
|
+
* The entries plus where they came from. A list is read-only: when
|
|
279
|
+
* `index.yaml` is missing or does not parse, the entries are built from the
|
|
280
|
+
* entry directories in memory and `indexState` says so, so a caller can ask
|
|
281
|
+
* for a reindex — the list itself never writes the index, which is the
|
|
282
|
+
* publisher's under the publish lock (#357; it used to rewrite a corrupt
|
|
283
|
+
* index unlocked, racing a publish for the same file).
|
|
284
|
+
*/
|
|
285
|
+
export declare function listEntriesWithIndexState(libraryRoot: string, filter?: ListEntriesFilter): Promise<{
|
|
286
|
+
entries: LibraryIndexEntry[];
|
|
287
|
+
indexState: LibraryIndexState;
|
|
288
|
+
}>;
|
|
289
|
+
/**
|
|
290
|
+
* Rebuild `index.yaml` and `INDEX.md` from the entry directories. Every
|
|
291
|
+
* writer of the index takes the publish lock: a reindex that scanned the
|
|
292
|
+
* directories before a publish's rename and wrote after the publish's own
|
|
293
|
+
* reindex used to drop the new version from the index until the next
|
|
294
|
+
* reindex (#357). `holdingLock` is for the publisher, which already has it.
|
|
295
|
+
*/
|
|
296
|
+
export declare function reindex(libraryRoot: string, opts?: {
|
|
297
|
+
holdingLock?: boolean;
|
|
298
|
+
}): Promise<ReindexResult>;
|
|
276
299
|
/**
|
|
277
300
|
* Read-only check over the given entries: does every version of each entry
|
|
278
301
|
* record the same repository as its newest version? Comparison goes through
|
package/dist/core/library.js
CHANGED
|
@@ -443,7 +443,7 @@ export async function publishEntry(libraryRoot, spec, input, opts = {}) {
|
|
|
443
443
|
const metadata = buildMetadata({ ...input, provenance }, latestVersion);
|
|
444
444
|
await atomicWriteYaml(join(latestVersionDir, METADATA_FILE), metadata);
|
|
445
445
|
if (!opts.skipReindex)
|
|
446
|
-
await reindex(libraryRoot);
|
|
446
|
+
await reindex(libraryRoot, { holdingLock: true });
|
|
447
447
|
return {
|
|
448
448
|
slug: input.slug,
|
|
449
449
|
namespace,
|
|
@@ -479,7 +479,7 @@ export async function publishEntry(libraryRoot, spec, input, opts = {}) {
|
|
|
479
479
|
}
|
|
480
480
|
await writeLatestPointer(entryDir, `v${nextVersion}`);
|
|
481
481
|
if (!opts.skipReindex)
|
|
482
|
-
await reindex(libraryRoot);
|
|
482
|
+
await reindex(libraryRoot, { holdingLock: true });
|
|
483
483
|
return {
|
|
484
484
|
slug: input.slug,
|
|
485
485
|
namespace,
|
|
@@ -653,25 +653,38 @@ function isReasoning(v) {
|
|
|
653
653
|
return v === "high" || v === "medium" || v === "low" || v === "default" || v === "unknown";
|
|
654
654
|
}
|
|
655
655
|
export async function listEntries(libraryRoot, filter = {}) {
|
|
656
|
+
return (await listEntriesWithIndexState(libraryRoot, filter)).entries;
|
|
657
|
+
}
|
|
658
|
+
/**
|
|
659
|
+
* The entries plus where they came from. A list is read-only: when
|
|
660
|
+
* `index.yaml` is missing or does not parse, the entries are built from the
|
|
661
|
+
* entry directories in memory and `indexState` says so, so a caller can ask
|
|
662
|
+
* for a reindex — the list itself never writes the index, which is the
|
|
663
|
+
* publisher's under the publish lock (#357; it used to rewrite a corrupt
|
|
664
|
+
* index unlocked, racing a publish for the same file).
|
|
665
|
+
*/
|
|
666
|
+
export async function listEntriesWithIndexState(libraryRoot, filter = {}) {
|
|
656
667
|
const marker = await readMarker(libraryRoot);
|
|
657
668
|
if (!marker)
|
|
658
|
-
return [];
|
|
659
|
-
// Prefer the index if it's present; fall back to a fresh reindex if not.
|
|
669
|
+
return { entries: [], indexState: "missing" };
|
|
660
670
|
const indexPath = join(libraryRoot, LIBRARY_INDEX_FILE);
|
|
661
671
|
let index;
|
|
672
|
+
let indexState = "indexed";
|
|
662
673
|
if (await pathExists(indexPath)) {
|
|
663
674
|
try {
|
|
664
675
|
const raw = await readFile(indexPath, "utf8");
|
|
665
676
|
index = normalizeIndex(parseSimpleYaml(raw), marker);
|
|
666
677
|
}
|
|
667
678
|
catch {
|
|
668
|
-
index = await
|
|
679
|
+
index = await scanLibrary(libraryRoot, marker);
|
|
680
|
+
indexState = "unparseable";
|
|
669
681
|
}
|
|
670
682
|
}
|
|
671
683
|
else {
|
|
672
|
-
index = await
|
|
684
|
+
index = await scanLibrary(libraryRoot, marker);
|
|
685
|
+
indexState = "missing";
|
|
673
686
|
}
|
|
674
|
-
|
|
687
|
+
const entries = index.entries.filter((e) => {
|
|
675
688
|
if (filter.namespace !== undefined && e.namespace !== filter.namespace)
|
|
676
689
|
return false;
|
|
677
690
|
if (filter.slug !== undefined && e.slug !== filter.slug)
|
|
@@ -685,13 +698,37 @@ export async function listEntries(libraryRoot, filter = {}) {
|
|
|
685
698
|
return false;
|
|
686
699
|
return true;
|
|
687
700
|
});
|
|
701
|
+
return { entries, indexState };
|
|
688
702
|
}
|
|
689
703
|
// ─── Reindex ────────────────────────────────────────────────────────────────
|
|
690
|
-
|
|
704
|
+
/**
|
|
705
|
+
* Rebuild `index.yaml` and `INDEX.md` from the entry directories. Every
|
|
706
|
+
* writer of the index takes the publish lock: a reindex that scanned the
|
|
707
|
+
* directories before a publish's rename and wrote after the publish's own
|
|
708
|
+
* reindex used to drop the new version from the index until the next
|
|
709
|
+
* reindex (#357). `holdingLock` is for the publisher, which already has it.
|
|
710
|
+
*/
|
|
711
|
+
export async function reindex(libraryRoot, opts = {}) {
|
|
691
712
|
const marker = await readMarker(libraryRoot);
|
|
692
713
|
if (!marker) {
|
|
693
714
|
throw new Error(`Not a CodeCartographer library: ${LIBRARY_MARKER_FILE} missing at ${libraryRoot}`);
|
|
694
715
|
}
|
|
716
|
+
const lock = opts.holdingLock ? null : await acquireLock(join(libraryRoot, PUBLISH_LOCK_FILE));
|
|
717
|
+
try {
|
|
718
|
+
const index = await scanLibrary(libraryRoot, marker);
|
|
719
|
+
await atomicWriteYaml(join(libraryRoot, LIBRARY_INDEX_FILE), index);
|
|
720
|
+
await writeIndexMarkdown(libraryRoot, index, marker);
|
|
721
|
+
// Reported, not written. The index files above are ABI, so the conflict
|
|
722
|
+
// list travels on the return value only (see ReindexResult).
|
|
723
|
+
const provenance_conflicts = await detectProvenanceConflicts(libraryRoot, index.entries);
|
|
724
|
+
return { ...index, provenance_conflicts };
|
|
725
|
+
}
|
|
726
|
+
finally {
|
|
727
|
+
await lock?.release();
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
/** The index as the entry directories would have it — a read, nothing written. */
|
|
731
|
+
async function scanLibrary(libraryRoot, marker) {
|
|
695
732
|
const entries = [];
|
|
696
733
|
const namespacesSeen = new Set();
|
|
697
734
|
const entriesRoot = join(libraryRoot, ENTRIES_DIR);
|
|
@@ -737,7 +774,7 @@ export async function reindex(libraryRoot) {
|
|
|
737
774
|
return nsA < nsB ? -1 : 1;
|
|
738
775
|
return a.slug < b.slug ? -1 : a.slug > b.slug ? 1 : 0;
|
|
739
776
|
});
|
|
740
|
-
|
|
777
|
+
return {
|
|
741
778
|
schema_version: INDEX_SCHEMA_VERSION,
|
|
742
779
|
library_name: marker.name,
|
|
743
780
|
generated_at: new Date().toISOString(),
|
|
@@ -745,12 +782,6 @@ export async function reindex(libraryRoot) {
|
|
|
745
782
|
namespaces: [...namespacesSeen].sort(),
|
|
746
783
|
entries,
|
|
747
784
|
};
|
|
748
|
-
await atomicWriteYaml(join(libraryRoot, LIBRARY_INDEX_FILE), index);
|
|
749
|
-
await writeIndexMarkdown(libraryRoot, index, marker);
|
|
750
|
-
// Reported, not written. The index files above are ABI, so the conflict
|
|
751
|
-
// list travels on the return value only (see ReindexResult).
|
|
752
|
-
const provenance_conflicts = await detectProvenanceConflicts(libraryRoot, entries);
|
|
753
|
-
return { ...index, provenance_conflicts };
|
|
754
785
|
}
|
|
755
786
|
async function buildIndexEntry(libraryRoot, namespace, slug) {
|
|
756
787
|
const entryDir = entryRoot(libraryRoot, namespace, slug);
|
|
@@ -21,8 +21,9 @@
|
|
|
21
21
|
// launch directory.
|
|
22
22
|
import { homedir } from "node:os";
|
|
23
23
|
import { dirname, isAbsolute, join, resolve } from "node:path";
|
|
24
|
-
import { mkdir, readFile
|
|
25
|
-
import { expandTilde, pathExists } from "./utils.js";
|
|
24
|
+
import { mkdir, readFile } from "node:fs/promises";
|
|
25
|
+
import { atomicWriteFile, expandTilde, pathExists } from "./utils.js";
|
|
26
|
+
import { acquireLock } from "./status.js";
|
|
26
27
|
import { loadYamlFile, parseSimpleYaml, stringifySimpleYaml } from "./yaml.js";
|
|
27
28
|
export const CONFIG_RELATIVE_PATH = "workflow/config.yaml";
|
|
28
29
|
export const USER_CONFIG_DIR = join(homedir(), ".codecarto");
|
|
@@ -187,6 +188,24 @@ function cloneDefault() {
|
|
|
187
188
|
* never overwritten: the caller is told to fix it first.
|
|
188
189
|
*/
|
|
189
190
|
export async function writeLibraryConfig(configPath, libraryPath, namespace = null) {
|
|
191
|
+
// dirname() honors the platform separator; the previous hand-rolled
|
|
192
|
+
// `includes("/")` check treated every Windows path as a bare filename
|
|
193
|
+
// and left mkdir a no-op before the writeFile ENOENT'd (#128).
|
|
194
|
+
const dir = dirname(configPath);
|
|
195
|
+
await mkdir(dir, { recursive: true });
|
|
196
|
+
// A read-modify-write of a file every workspace on the machine reads:
|
|
197
|
+
// under a lock beside it, and landed atomically, so two library-inits
|
|
198
|
+
// (two hosts, or MCP and Pi) cannot lose each other's change and a crash
|
|
199
|
+
// mid-write cannot truncate it (#361).
|
|
200
|
+
const lock = await acquireLock(`${configPath}.lock`);
|
|
201
|
+
try {
|
|
202
|
+
await writeLibraryConfigLocked(configPath, libraryPath, namespace);
|
|
203
|
+
}
|
|
204
|
+
finally {
|
|
205
|
+
await lock.release();
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
async function writeLibraryConfigLocked(configPath, libraryPath, namespace) {
|
|
190
209
|
let existing = {};
|
|
191
210
|
if (await pathExists(configPath)) {
|
|
192
211
|
const raw = await readFile(configPath, "utf8");
|
|
@@ -213,10 +232,5 @@ export async function writeLibraryConfig(configPath, libraryPath, namespace = nu
|
|
|
213
232
|
if (namespace)
|
|
214
233
|
library.namespace = namespace;
|
|
215
234
|
const updated = { ...existing, library };
|
|
216
|
-
|
|
217
|
-
// `includes("/")` check treated every Windows path as a bare filename
|
|
218
|
-
// and left mkdir a no-op before the writeFile ENOENT'd (#128).
|
|
219
|
-
const dir = dirname(configPath);
|
|
220
|
-
await mkdir(dir, { recursive: true });
|
|
221
|
-
await writeFile(configPath, `${stringifySimpleYaml(updated)}\n`, "utf8");
|
|
235
|
+
await atomicWriteFile(configPath, `${stringifySimpleYaml(updated)}\n`);
|
|
222
236
|
}
|
package/dist/core/status.d.ts
CHANGED
|
@@ -1,13 +1,12 @@
|
|
|
1
1
|
import type { ClosureEntry, NormalizedStatus, OpenQuestionEntry, PostPipelineEntry, PhaseHandoff, PipelineFile, ProposedConventionEntry, StatusFile, StatusPhase } from "./types.ts";
|
|
2
2
|
export declare const LOCK_RETRY_MS = 125;
|
|
3
3
|
export declare const LOCK_TIMEOUT_MS = 5000;
|
|
4
|
-
export declare const STALE_LOCK_MS = 60000;
|
|
5
4
|
/**
|
|
6
|
-
* How
|
|
7
|
-
*
|
|
8
|
-
*
|
|
5
|
+
* How long a lock ticket may go unrefreshed before its owner is presumed
|
|
6
|
+
* hung. Owners refresh every quarter of this while they wait or hold, so a
|
|
7
|
+
* live holder is never broken by age; a dead one is removed at once.
|
|
9
8
|
*/
|
|
10
|
-
export declare const
|
|
9
|
+
export declare const STALE_LOCK_MS = 60000;
|
|
11
10
|
export declare function assertSafePhaseId(phaseId: string): void;
|
|
12
11
|
/**
|
|
13
12
|
* A YAML scalar as text: strings as written, numbers and booleans spelled
|
|
@@ -58,34 +57,54 @@ export declare function parseHandoff(value: unknown): PhaseHandoff;
|
|
|
58
57
|
export declare function ensureProposedConventionArray(value: unknown): ProposedConventionEntry[];
|
|
59
58
|
export declare function loadHandoffFile(phaseId: string, workspaceDir: string): Promise<PhaseHandoff | null>;
|
|
60
59
|
export declare function applyHandoff(status: NormalizedStatus, handoff: PhaseHandoff): NormalizedStatus;
|
|
61
|
-
/** What {@link acquireLock} hands back: a release that only ever removes its own
|
|
60
|
+
/** What {@link acquireLock} hands back: a release that only ever removes its own ticket. */
|
|
62
61
|
export interface LockHandle {
|
|
63
62
|
release: () => Promise<void>;
|
|
64
63
|
/**
|
|
65
|
-
* Set when acquiring meant
|
|
66
|
-
*
|
|
64
|
+
* Set when acquiring meant removing a ticket whose holder was dead or had
|
|
65
|
+
* stopped refreshing it for {@link STALE_LOCK_MS}: the previous holder as
|
|
66
|
+
* its ticket recorded it, for callers that log.
|
|
67
67
|
*/
|
|
68
68
|
brokeStale?: {
|
|
69
69
|
pid: number | null;
|
|
70
70
|
since: string | null;
|
|
71
71
|
};
|
|
72
72
|
}
|
|
73
|
+
export interface AcquireLockOptions {
|
|
74
|
+
/** How long to wait for the lock; default {@link LOCK_TIMEOUT_MS}. */
|
|
75
|
+
timeoutMs?: number;
|
|
76
|
+
/**
|
|
77
|
+
* How long a ticket may go unrefreshed before its holder is presumed
|
|
78
|
+
* hung; default {@link STALE_LOCK_MS}. A dead holder is removed at once.
|
|
79
|
+
*/
|
|
80
|
+
staleMs?: number;
|
|
81
|
+
}
|
|
73
82
|
/**
|
|
74
|
-
* Take the
|
|
75
|
-
*
|
|
83
|
+
* Take the lock named by `lockPath`, waiting up to `timeoutMs`.
|
|
84
|
+
*
|
|
85
|
+
* The lock is a queue of **tickets**: files beside `lockPath` named
|
|
86
|
+
* `<lock>.t.<order>-<pid>-<token>`, one per waiter, each written by its
|
|
87
|
+
* owner alone. The holder is the owner of the first ticket in name order
|
|
88
|
+
* whose process is alive and whose ticket has been refreshed within
|
|
89
|
+
* `staleMs`; every owner refreshes its ticket on a timer while it waits and
|
|
90
|
+
* while it holds, so a live holder is never broken however long it holds
|
|
91
|
+
* (#355 — a publish across a full reindex used to lose its lock at 60 s).
|
|
92
|
+
* A ticket whose owner is dead, or has not refreshed it in `staleMs`, is
|
|
93
|
+
* removed by whoever notices — by its own unique name, so two waiters
|
|
94
|
+
* removing the same dead ticket remove the same inode and nothing else.
|
|
95
|
+
* No shared path is ever removed and re-created, which is the race every
|
|
96
|
+
* `rm`-then-recreate stale break carries (#342, #344, #355).
|
|
76
97
|
*
|
|
77
|
-
*
|
|
78
|
-
*
|
|
79
|
-
*
|
|
80
|
-
* the
|
|
81
|
-
*
|
|
98
|
+
* Ordering follows Lamport's bakery: a waiter announces it is *choosing*
|
|
99
|
+
* (`<lock>.c.<token>`), takes a number one larger than any ticket it can
|
|
100
|
+
* see, writes its ticket, and withdraws the marker; nobody concludes it
|
|
101
|
+
* holds the lock while a marker it does not own exists — so a waiter that
|
|
102
|
+
* took its number but has not yet written its ticket cannot be overtaken.
|
|
103
|
+
* Two tickets with the same number (chosen at the same moment) break the
|
|
104
|
+
* tie on the rest of the name, which every observer sorts the same way.
|
|
82
105
|
*
|
|
83
|
-
*
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
* `rm` landed after the first waiter had re-created the file, so both held
|
|
87
|
-
* the lock (#342). A file can only be created while the path is free, and
|
|
88
|
-
* only a removal-lock holder removes, so what a holder verified is what it
|
|
89
|
-
* removes.
|
|
106
|
+
* A plain `lockPath` file left by a pre-#355 process is honoured while it is
|
|
107
|
+
* younger than `staleMs` and removed once it is not, so an upgrade under a
|
|
108
|
+
* live older process does not let two writers in.
|
|
90
109
|
*/
|
|
91
|
-
export declare function acquireLock(lockPath: string): Promise<LockHandle>;
|
|
110
|
+
export declare function acquireLock(lockPath: string, options?: AcquireLockOptions): Promise<LockHandle>;
|