@akagilnc/pi-workflow-roles 0.1.1883 → 0.1.1918

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.
@@ -38,6 +38,7 @@ import {
38
38
  } from "./package-contracts/terminating-tools.ts";
39
39
  import { PACKAGED_ROLE_REGISTRY } from "./packaged-role-registry.ts";
40
40
 
41
+
41
42
  /** Declared refresh bound for the same viewing surface (seconds). */
42
43
  export const DEFAULT_REFRESH_BOUNDARY_SECONDS = 30;
43
44
 
@@ -328,6 +329,8 @@ type InvocationInfo = {
328
329
  model?: string;
329
330
  provider?: string;
330
331
  thinking?: string;
332
+ ticketNumber?: number;
333
+ correlationId?: string;
331
334
  };
332
335
 
333
336
  async function readInvocation(runDir: string): Promise<InvocationInfo | undefined> {
@@ -348,6 +351,16 @@ async function readInvocation(runDir: string): Promise<InvocationInfo | undefine
348
351
  info.model = rawModel;
349
352
  }
350
353
  }
354
+ if (
355
+ typeof parsed.ticketNumber === "number" &&
356
+ Number.isInteger(parsed.ticketNumber) &&
357
+ parsed.ticketNumber >= 1
358
+ ) {
359
+ info.ticketNumber = parsed.ticketNumber;
360
+ }
361
+ if (typeof parsed.correlationId === "string" && parsed.correlationId.trim() !== "") {
362
+ info.correlationId = parsed.correlationId;
363
+ }
351
364
  return info;
352
365
  } catch (error) {
353
366
  // Only genuine absence activates the invocation fallback. Malformed JSON and
@@ -408,9 +421,8 @@ async function maxMtimeMs(paths: readonly string[]): Promise<number> {
408
421
  return max;
409
422
  }
410
423
 
411
- async function parseRun(ledgerDir: string, issueNumber: number, runId: string): Promise<ParsedRun> {
412
- const runDir = join(ledgerDir, "issues", String(issueNumber), "runs", runId);
413
- const ledgerCoord = ["issues", String(issueNumber), "runs", runId].join("/");
424
+ async function parseRunDirectory(runDir: string, ledgerCoord: string): Promise<ParsedRun> {
425
+ const runId = basename(runDir);
414
426
  const evidenceTarget = await realpathOrLexicalIfMissing(runDir);
415
427
  const evidenceHref = pathToFileURL(evidenceTarget).href;
416
428
  const sessionDir = join(runDir, "session");
@@ -496,6 +508,12 @@ async function parseRun(ledgerDir: string, issueNumber: number, runId: string):
496
508
  };
497
509
  }
498
510
 
511
+ async function parseRun(ledgerDir: string, issueNumber: number, runId: string): Promise<ParsedRun> {
512
+ const runDir = join(ledgerDir, "issues", String(issueNumber), "runs", runId);
513
+ const ledgerCoord = ["issues", String(issueNumber), "runs", runId].join("/");
514
+ return parseRunDirectory(runDir, ledgerCoord);
515
+ }
516
+
499
517
  async function listRunIds(ledgerDir: string, issueNumber: number): Promise<string[]> {
500
518
  const runsDir = join(ledgerDir, "issues", String(issueNumber), "runs");
501
519
  try {
@@ -686,20 +704,142 @@ ${stationBlocks || "<p data-empty=\"true\">no runs</p>"}
686
704
  /**
687
705
  * Load one ticket's runs via the S1 tracer path (read-only ledger scan).
688
706
  * Factory board reuses this — no parallel receipt parser.
707
+ *
708
+ * Flat-run attribution (#176 option A): read typed `ticketNumber` from each
709
+ * `runs/<run>@<role>/invocation.json`. Legacy `issues/<N>/runs` remains a
710
+ * read-only compatibility entrance. Unbound flat runs never join a ticket.
711
+ */
712
+
713
+ /** One flat run's typed binding projection from invocation.json. */
714
+ export type FlatRunTicketBinding = {
715
+ readonly runDir: string;
716
+ readonly runFolder: string;
717
+ readonly ticketNumber?: number;
718
+ readonly role?: string;
719
+ readonly correlationId?: string;
720
+ };
721
+
722
+ /**
723
+ * Book-level flat-run index built once per lane/render and reused per ticket.
724
+ * Pure derived view of flat runs' invocation.json files — not a parallel ledger.
725
+ */
726
+ export type TicketTrajectoryBookIndex = {
727
+ /** ticket number → flat runs bound via invocation.json ticketNumber. */
728
+ readonly runsByTicket: ReadonlyMap<number, readonly FlatRunTicketBinding[]>;
729
+ /** Flat runs with no typed ticketNumber (unknown/unbound seam). */
730
+ readonly unboundRuns: readonly FlatRunTicketBinding[];
731
+ };
732
+
733
+ async function listFlatRunDirectories(ledgerDir: string): Promise<string[]> {
734
+ const runsDir = join(resolve(ledgerDir), "runs");
735
+ try {
736
+ const entries = await readdir(runsDir, { withFileTypes: true });
737
+ return entries
738
+ .filter((entry) => entry.isDirectory())
739
+ .map((entry) => join(runsDir, entry.name))
740
+ .sort();
741
+ } catch (error) {
742
+ if (isMissingPathError(error)) return [];
743
+ throw error;
744
+ }
745
+ }
746
+
747
+ export async function buildTicketTrajectoryBookIndex(
748
+ ledgerDir: string,
749
+ ): Promise<TicketTrajectoryBookIndex> {
750
+ const root = resolve(ledgerDir);
751
+ const runsByTicket = new Map<number, FlatRunTicketBinding[]>();
752
+ const unboundRuns: FlatRunTicketBinding[] = [];
753
+ for (const runDir of await listFlatRunDirectories(root)) {
754
+ const invocation = await readInvocation(runDir);
755
+ const binding: FlatRunTicketBinding = {
756
+ runDir,
757
+ runFolder: basename(runDir),
758
+ ...(invocation?.ticketNumber !== undefined
759
+ ? { ticketNumber: invocation.ticketNumber }
760
+ : {}),
761
+ ...(invocation?.role !== undefined ? { role: invocation.role } : {}),
762
+ ...(invocation?.correlationId !== undefined
763
+ ? { correlationId: invocation.correlationId }
764
+ : {}),
765
+ };
766
+ if (binding.ticketNumber !== undefined) {
767
+ const list = runsByTicket.get(binding.ticketNumber) ?? [];
768
+ list.push(binding);
769
+ runsByTicket.set(binding.ticketNumber, list);
770
+ } else {
771
+ unboundRuns.push(binding);
772
+ }
773
+ }
774
+ return { runsByTicket, unboundRuns };
775
+ }
776
+
777
+ export type UnboundTrajectoryRun = {
778
+ readonly run: TicketTrajectoryRun;
779
+ readonly role: string;
780
+ readonly observedAt: string;
781
+ readonly correlationId?: string;
782
+ };
783
+
784
+ /**
785
+ * Load unbound flat runs for the board unknown seam.
786
+ * Never joins any ticket. Built from the book index (once).
689
787
  */
788
+ export async function loadUnboundTrajectoryRuns(
789
+ ledgerDir: string,
790
+ bookIndex?: TicketTrajectoryBookIndex,
791
+ ): Promise<readonly UnboundTrajectoryRun[]> {
792
+ const root = resolve(ledgerDir);
793
+ const index = bookIndex ?? (await buildTicketTrajectoryBookIndex(root));
794
+ const out: UnboundTrajectoryRun[] = [];
795
+ for (const binding of index.unboundRuns) {
796
+ const ledgerCoord = ["runs", binding.runFolder].join("/");
797
+ const run = await parseRunDirectory(binding.runDir, ledgerCoord);
798
+ out.push({
799
+ run,
800
+ role: binding.role ?? run.station,
801
+ observedAt: run.startedAt ?? run.lastActivityAt ?? "",
802
+ ...(binding.correlationId === undefined
803
+ ? {}
804
+ : { correlationId: binding.correlationId }),
805
+ });
806
+ }
807
+ return out;
808
+ }
809
+
690
810
  export async function loadTicketTrajectoryRuns(
691
811
  ledgerDir: string,
692
812
  issueNumber: number,
813
+ bookIndex?: TicketTrajectoryBookIndex,
693
814
  ): Promise<TicketTrajectoryRun[]> {
694
815
  if (!Number.isInteger(issueNumber) || issueNumber < 1) {
695
816
  throw new Error("issueNumber must be a positive integer");
696
817
  }
697
818
  const root = resolve(ledgerDir);
698
- const runIds = await listRunIds(root, issueNumber);
819
+ const index = bookIndex ?? (await buildTicketTrajectoryBookIndex(root));
820
+
699
821
  const runs: ParsedRun[] = [];
822
+ const seenRunDirs = new Set<string>();
823
+
824
+ // Legacy hand-dispatch path: issues/<N>/runs (read-only compatibility).
825
+ const runIds = await listRunIds(root, issueNumber);
700
826
  for (const runId of runIds) {
701
- runs.push(await parseRun(root, issueNumber, runId));
827
+ const parsed = await parseRun(root, issueNumber, runId);
828
+ const legacyDir = join(root, "issues", String(issueNumber), "runs", runId);
829
+ seenRunDirs.add(resolve(legacyDir));
830
+ runs.push(parsed);
702
831
  }
832
+
833
+ // Flat runs bound by typed invocation.json ticketNumber.
834
+ const flatBindings = index.runsByTicket.get(issueNumber) ?? [];
835
+ for (const binding of flatBindings) {
836
+ const resolvedRunDir = resolve(binding.runDir);
837
+ if (seenRunDirs.has(resolvedRunDir)) continue;
838
+ seenRunDirs.add(resolvedRunDir);
839
+ const ledgerCoord = ["runs", binding.runFolder].join("/");
840
+ runs.push(await parseRunDirectory(binding.runDir, ledgerCoord));
841
+ }
842
+
703
843
  return runs;
704
844
  }
705
845
 
@@ -0,0 +1,99 @@
1
+ /**
2
+ * Shared durable typed-provider HTTP observation owner.
3
+ * Published with both public-cli and navigator-attendance entries.
4
+ */
5
+ import { readFile, unlink, writeFile } from "node:fs/promises";
6
+ import { join } from "node:path";
7
+
8
+ const TYPED_HTTP_FILE = "typed-provider-http.json";
9
+
10
+ export type TypedProviderHttpObservation = {
11
+ readonly httpStatus: number;
12
+ readonly provider: string;
13
+ };
14
+
15
+ function typedProviderHttpPath(runDirectory: string): string {
16
+ return join(runDirectory, TYPED_HTTP_FILE);
17
+ }
18
+
19
+ /**
20
+ * Clear any prior attempt's typed provider HTTP observation.
21
+ * Each initial/resume dispatch must start without inherited 429 evidence so
22
+ * only the current attempt can qualify v1 resume.
23
+ */
24
+ export async function clearTypedProviderHttpObservation(
25
+ runDirectory: string,
26
+ ): Promise<void> {
27
+ try {
28
+ await unlink(typedProviderHttpPath(runDirectory));
29
+ } catch (error) {
30
+ if (
31
+ error instanceof Error &&
32
+ "code" in error &&
33
+ (error as { code?: unknown }).code === "ENOENT"
34
+ ) {
35
+ return;
36
+ }
37
+ throw error;
38
+ }
39
+ }
40
+
41
+ /**
42
+ * Record a typed provider HTTP status observation for the admitted run.
43
+ * Only non-success HTTP statuses are persisted (authorized error evidence).
44
+ * A 2xx response clears any prior observation so success cannot leave stale
45
+ * error evidence. Never inspects diagnostic prose.
46
+ */
47
+ export async function recordTypedProviderHttpStatus(
48
+ runDirectory: string,
49
+ observation: { readonly httpStatus: number; readonly provider: string },
50
+ ): Promise<void> {
51
+ if (observation.httpStatus >= 200 && observation.httpStatus < 300) {
52
+ await clearTypedProviderHttpObservation(runDirectory);
53
+ return;
54
+ }
55
+ const body: TypedProviderHttpObservation = {
56
+ httpStatus: observation.httpStatus,
57
+ provider: observation.provider,
58
+ };
59
+ await writeFile(
60
+ typedProviderHttpPath(runDirectory),
61
+ `${JSON.stringify(body)}\n`,
62
+ "utf8",
63
+ );
64
+ }
65
+
66
+ /**
67
+ * Read the latest durable typed provider HTTP observation, if any.
68
+ * Only ENOENT means absence (undefined). Read/parse/shape failures preserve the
69
+ * real cause for the existing controlled-failure chain — never laundered as absence.
70
+ */
71
+ export async function readLatestTypedProviderHttpObservation(
72
+ runDirectory: string,
73
+ ): Promise<TypedProviderHttpObservation | undefined> {
74
+ let text: string;
75
+ try {
76
+ text = await readFile(typedProviderHttpPath(runDirectory), "utf8");
77
+ } catch (error) {
78
+ if (
79
+ error instanceof Error &&
80
+ "code" in error &&
81
+ (error as { code?: unknown }).code === "ENOENT"
82
+ ) {
83
+ return undefined;
84
+ }
85
+ throw error;
86
+ }
87
+ const raw: unknown = JSON.parse(text);
88
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
89
+ throw new Error("typed provider HTTP observation must be a JSON object");
90
+ }
91
+ const record = raw as Record<string, unknown>;
92
+ if (typeof record.httpStatus !== "number") {
93
+ throw new Error("typed provider HTTP observation missing numeric httpStatus");
94
+ }
95
+ if (typeof record.provider !== "string" || record.provider.trim() === "") {
96
+ throw new Error("typed provider HTTP observation missing non-empty provider");
97
+ }
98
+ return { httpStatus: record.httpStatus, provider: record.provider };
99
+ }
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Single authority for upstream-error testimony (#307).
3
+ * Non-2xx HTTP or non-empty SDK diagnostics = upstream testimony.
4
+ * body/code/errno project only from a confirmed-remote node.
5
+ * Seams keep their own shape/cause-chain reading and wrap via these primitives.
6
+ */
7
+
8
+ /** True when value is a numeric HTTP status outside 2xx. */
9
+ export function isNonSuccessHttpStatus(status: unknown): status is number {
10
+ return typeof status === "number" && (status < 200 || status >= 300);
11
+ }
12
+
13
+ /**
14
+ * Upstream testimony is a directly observed HTTP response or SDK-structured
15
+ * remote error. Pi stopReason, selected provider/model names, silent stream
16
+ * death, errorMessage prose (e.g. "500: …"), and locally synthesized errors
17
+ * are not testimony.
18
+ */
19
+ export function hasUpstreamErrorTestimony(input: {
20
+ readonly httpStatus?: number;
21
+ readonly diagnostics?: unknown;
22
+ }): boolean {
23
+ if (isNonSuccessHttpStatus(input.httpStatus)) return true;
24
+ return Array.isArray(input.diagnostics) && input.diagnostics.length > 0;
25
+ }
26
+
27
+ /**
28
+ * Project body/code/errno only when the caller already confirmed remote testimony
29
+ * on the same node. Plain local Error.code is never provider testimony.
30
+ */
31
+ export function projectConfirmedRemotePayload(input: {
32
+ readonly body?: unknown;
33
+ readonly code?: unknown;
34
+ readonly errno?: unknown;
35
+ }): {
36
+ readonly body?: unknown;
37
+ readonly code?: unknown;
38
+ readonly errno?: unknown;
39
+ } {
40
+ return {
41
+ ...(input.body === undefined ? {} : { body: input.body }),
42
+ ...(input.code === undefined ? {} : { code: input.code }),
43
+ ...(input.errno === undefined ? {} : { errno: input.errno }),
44
+ };
45
+ }