@shipfox/client-logs 0.2.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.
Files changed (80) hide show
  1. package/.storybook/main.ts +1 -0
  2. package/.storybook/preview.css +9 -0
  3. package/.storybook/preview.tsx +56 -0
  4. package/.swcrc +42 -0
  5. package/.turbo/turbo-build.log +2 -0
  6. package/.turbo/turbo-type$colon$emit.log +1 -0
  7. package/.turbo/turbo-type.log +1 -0
  8. package/CHANGELOG.md +93 -0
  9. package/LICENSE +21 -0
  10. package/dist/components/agent-session-rows.d.ts +8 -0
  11. package/dist/components/agent-session-rows.d.ts.map +1 -0
  12. package/dist/components/agent-session-rows.js +374 -0
  13. package/dist/components/agent-session-rows.js.map +1 -0
  14. package/dist/components/index.d.ts +5 -0
  15. package/dist/components/index.d.ts.map +1 -0
  16. package/dist/components/index.js +6 -0
  17. package/dist/components/index.js.map +1 -0
  18. package/dist/components/log-group.d.ts +11 -0
  19. package/dist/components/log-group.d.ts.map +1 -0
  20. package/dist/components/log-group.js +57 -0
  21. package/dist/components/log-group.js.map +1 -0
  22. package/dist/components/log-view.d.ts +20 -0
  23. package/dist/components/log-view.d.ts.map +1 -0
  24. package/dist/components/log-view.js +243 -0
  25. package/dist/components/log-view.js.map +1 -0
  26. package/dist/components/output-log-row.d.ts +9 -0
  27. package/dist/components/output-log-row.d.ts.map +1 -0
  28. package/dist/components/output-log-row.js +24 -0
  29. package/dist/components/output-log-row.js.map +1 -0
  30. package/dist/components/system-markers.d.ts +21 -0
  31. package/dist/components/system-markers.d.ts.map +1 -0
  32. package/dist/components/system-markers.js +113 -0
  33. package/dist/components/system-markers.js.map +1 -0
  34. package/dist/core/log-read.d.ts +33 -0
  35. package/dist/core/log-read.d.ts.map +1 -0
  36. package/dist/core/log-read.js +49 -0
  37. package/dist/core/log-read.js.map +1 -0
  38. package/dist/core/log-tree.d.ts +85 -0
  39. package/dist/core/log-tree.d.ts.map +1 -0
  40. package/dist/core/log-tree.js +139 -0
  41. package/dist/core/log-tree.js.map +1 -0
  42. package/dist/env.d.js +2 -0
  43. package/dist/env.d.js.map +1 -0
  44. package/dist/hooks/api/step-logs.d.ts +24 -0
  45. package/dist/hooks/api/step-logs.d.ts.map +1 -0
  46. package/dist/hooks/api/step-logs.js +129 -0
  47. package/dist/hooks/api/step-logs.js.map +1 -0
  48. package/dist/index.d.ts +5 -0
  49. package/dist/index.d.ts.map +1 -0
  50. package/dist/index.js +5 -0
  51. package/dist/index.js.map +1 -0
  52. package/dist/tsconfig.test.tsbuildinfo +1 -0
  53. package/package.json +84 -0
  54. package/src/components/agent-session-rows.tsx +341 -0
  55. package/src/components/index.ts +15 -0
  56. package/src/components/log-group.stories.tsx +204 -0
  57. package/src/components/log-group.tsx +54 -0
  58. package/src/components/log-view.stories.tsx +584 -0
  59. package/src/components/log-view.test.tsx +266 -0
  60. package/src/components/log-view.tsx +281 -0
  61. package/src/components/output-log-row.stories.tsx +76 -0
  62. package/src/components/output-log-row.tsx +35 -0
  63. package/src/components/system-markers.stories.tsx +46 -0
  64. package/src/components/system-markers.tsx +148 -0
  65. package/src/core/log-read.test.ts +219 -0
  66. package/src/core/log-read.ts +79 -0
  67. package/src/core/log-tree.test.ts +380 -0
  68. package/src/core/log-tree.ts +200 -0
  69. package/src/env.d.ts +7 -0
  70. package/src/hooks/api/step-logs-query.test.tsx +283 -0
  71. package/src/hooks/api/step-logs.test.ts +66 -0
  72. package/src/hooks/api/step-logs.ts +166 -0
  73. package/src/index.ts +18 -0
  74. package/test/setup.ts +3 -0
  75. package/tsconfig.build.json +9 -0
  76. package/tsconfig.build.tsbuildinfo +1 -0
  77. package/tsconfig.json +3 -0
  78. package/tsconfig.test.json +8 -0
  79. package/vercel.json +8 -0
  80. package/vitest.config.ts +70 -0
@@ -0,0 +1,113 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { Icon } from '@shipfox/react-ui/icon';
3
+ import { LogContent, LogRow } from '@shipfox/react-ui/log';
4
+ import { cn, formatBytes, formatDuration } from '@shipfox/react-ui/utils';
5
+ const toneText = {
6
+ default: 'text-foreground-neutral-muted',
7
+ warning: 'text-orange-600 dark:text-orange-400',
8
+ error: 'text-red-600 dark:text-red-400'
9
+ };
10
+ /**
11
+ * Shared timeline-marker row: a non-numbered line with a leading icon, a bold label, an
12
+ * optional plain-language detail clause, a dashed divider, and optional right-aligned
13
+ * monospace figures. The detail explains the consequence to the operator (the label
14
+ * names the event, the icon/tone carry severity), so the copy reads helpfully rather
15
+ * than mechanically.
16
+ */ function LogMarkerRow({ icon, tone, timestamp = null, detail, meta, terminalFailure = false, children }) {
17
+ return /*#__PURE__*/ _jsx(LogRow, {
18
+ lineNumber: null,
19
+ timestamp: timestamp,
20
+ tone: tone,
21
+ "data-log-terminal-failure": terminalFailure ? 'true' : undefined,
22
+ children: /*#__PURE__*/ _jsx(LogContent, {
23
+ className: cn('block', toneText[tone]),
24
+ children: /*#__PURE__*/ _jsxs("span", {
25
+ className: "inline-flex w-full items-center gap-8",
26
+ children: [
27
+ /*#__PURE__*/ _jsx(Icon, {
28
+ name: icon,
29
+ className: "size-14 flex-none",
30
+ "aria-hidden": "true"
31
+ }),
32
+ /*#__PURE__*/ _jsxs("span", {
33
+ className: "min-w-0",
34
+ children: [
35
+ /*#__PURE__*/ _jsx("span", {
36
+ className: "font-medium",
37
+ children: children
38
+ }),
39
+ detail != null && /*#__PURE__*/ _jsxs(_Fragment, {
40
+ children: [
41
+ ' · ',
42
+ /*#__PURE__*/ _jsx("span", {
43
+ className: "font-normal opacity-80",
44
+ children: detail
45
+ })
46
+ ]
47
+ }),
48
+ meta != null && /*#__PURE__*/ _jsxs(_Fragment, {
49
+ children: [
50
+ ' · ',
51
+ /*#__PURE__*/ _jsx("span", {
52
+ className: "font-code tabular-nums opacity-80",
53
+ children: meta
54
+ })
55
+ ]
56
+ })
57
+ ]
58
+ }),
59
+ /*#__PURE__*/ _jsx("span", {
60
+ "aria-hidden": "true",
61
+ className: "h-px flex-1 border-t border-dashed border-current opacity-30"
62
+ })
63
+ ]
64
+ })
65
+ })
66
+ });
67
+ }
68
+ /** Clean end of the log: line count + output bytes (+ overall duration). `total_bytes` is payload bytes. */ export function EndMarker({ record, lineCount, durationMs = null }) {
69
+ const meta = [
70
+ `${lineCount} ${lineCount === 1 ? 'line' : 'lines'}`,
71
+ formatBytes(record.total_bytes),
72
+ ...durationMs != null ? [
73
+ formatDuration(durationMs)
74
+ ] : []
75
+ ].join(' · ');
76
+ return /*#__PURE__*/ _jsx(LogMarkerRow, {
77
+ icon: "flagLine",
78
+ tone: "default",
79
+ timestamp: new Date(record.ts),
80
+ meta: meta,
81
+ children: "End of log"
82
+ });
83
+ }
84
+ /** The runner's local backlog shed bytes before upload, so some output never arrived. A warning. */ export function GapMarker({ record }) {
85
+ return /*#__PURE__*/ _jsx(LogMarkerRow, {
86
+ icon: "errorWarningLine",
87
+ tone: "warning",
88
+ timestamp: new Date(record.ts),
89
+ detail: `the runner fell behind and dropped ${formatBytes(record.dropped_bytes)}`,
90
+ children: "Output missing"
91
+ });
92
+ }
93
+ /** The job hit its shared log size limit; logging stopped but the step kept running. A warning. */ export function CappedMarker({ record }) {
94
+ return /*#__PURE__*/ _jsx(LogMarkerRow, {
95
+ icon: "forbidLine",
96
+ tone: "warning",
97
+ timestamp: new Date(record.ts),
98
+ detail: "later output isn't shown; the step kept running",
99
+ children: "Log size limit reached"
100
+ });
101
+ }
102
+ /** The runner disappeared and the stream was force-closed. A terminal failure (status taxonomy §9). */ export function RunnerLostMarker({ record }) {
103
+ return /*#__PURE__*/ _jsx(LogMarkerRow, {
104
+ icon: "closeCircleLine",
105
+ tone: "error",
106
+ timestamp: new Date(record.ts),
107
+ detail: "the log ends here and may be incomplete",
108
+ terminalFailure: true,
109
+ children: "Runner disconnected"
110
+ });
111
+ }
112
+
113
+ //# sourceMappingURL=system-markers.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/components/system-markers.tsx"],"sourcesContent":["import {Icon, type IconName} from '@shipfox/react-ui/icon';\nimport {LogContent, LogRow} from '@shipfox/react-ui/log';\nimport {cn, formatBytes, formatDuration} from '@shipfox/react-ui/utils';\nimport type {\n CappedLogRecord,\n EndLogRecord,\n GapLogRecord,\n RunnerLostLogRecord,\n} from '#core/log-tree.js';\n\ntype MarkerTone = 'default' | 'warning' | 'error';\n\nconst toneText: Record<MarkerTone, string> = {\n default: 'text-foreground-neutral-muted',\n warning: 'text-orange-600 dark:text-orange-400',\n error: 'text-red-600 dark:text-red-400',\n};\n\ninterface LogMarkerRowProps {\n icon: IconName;\n tone: MarkerTone;\n timestamp?: Date | null;\n /** Plain-language clause after the label: what it means / what to do. */\n detail?: string;\n /** Right-aligned `font-code` figures (bytes, line count, duration). */\n meta?: string;\n terminalFailure?: boolean;\n children: string;\n}\n\n/**\n * Shared timeline-marker row: a non-numbered line with a leading icon, a bold label, an\n * optional plain-language detail clause, a dashed divider, and optional right-aligned\n * monospace figures. The detail explains the consequence to the operator (the label\n * names the event, the icon/tone carry severity), so the copy reads helpfully rather\n * than mechanically.\n */\nfunction LogMarkerRow({\n icon,\n tone,\n timestamp = null,\n detail,\n meta,\n terminalFailure = false,\n children,\n}: LogMarkerRowProps) {\n return (\n <LogRow\n lineNumber={null}\n timestamp={timestamp}\n tone={tone}\n data-log-terminal-failure={terminalFailure ? 'true' : undefined}\n >\n <LogContent className={cn('block', toneText[tone])}>\n <span className=\"inline-flex w-full items-center gap-8\">\n <Icon name={icon} className=\"size-14 flex-none\" aria-hidden=\"true\" />\n {/* Label, detail, and figures share one text cluster joined by a literal\n \" · \". Flex `gap` is visual only and would copy with no separator, so the\n separators are real inline text. The dashed rule trails after the text\n (an aria-hidden flex filler) and contributes nothing to a selection. */}\n <span className=\"min-w-0\">\n <span className=\"font-medium\">{children}</span>\n {detail != null && (\n <>\n {' · '}\n <span className=\"font-normal opacity-80\">{detail}</span>\n </>\n )}\n {meta != null && (\n <>\n {' · '}\n <span className=\"font-code tabular-nums opacity-80\">{meta}</span>\n </>\n )}\n </span>\n <span\n aria-hidden=\"true\"\n className=\"h-px flex-1 border-t border-dashed border-current opacity-30\"\n />\n </span>\n </LogContent>\n </LogRow>\n );\n}\n\nexport interface EndMarkerProps {\n record: EndLogRecord;\n lineCount: number;\n durationMs?: number | null;\n}\n\n/** Clean end of the log: line count + output bytes (+ overall duration). `total_bytes` is payload bytes. */\nexport function EndMarker({record, lineCount, durationMs = null}: EndMarkerProps) {\n const meta = [\n `${lineCount} ${lineCount === 1 ? 'line' : 'lines'}`,\n formatBytes(record.total_bytes),\n ...(durationMs != null ? [formatDuration(durationMs)] : []),\n ].join(' · ');\n\n return (\n <LogMarkerRow icon=\"flagLine\" tone=\"default\" timestamp={new Date(record.ts)} meta={meta}>\n End of log\n </LogMarkerRow>\n );\n}\n\n/** The runner's local backlog shed bytes before upload, so some output never arrived. A warning. */\nexport function GapMarker({record}: {record: GapLogRecord}) {\n return (\n <LogMarkerRow\n icon=\"errorWarningLine\"\n tone=\"warning\"\n timestamp={new Date(record.ts)}\n detail={`the runner fell behind and dropped ${formatBytes(record.dropped_bytes)}`}\n >\n Output missing\n </LogMarkerRow>\n );\n}\n\n/** The job hit its shared log size limit; logging stopped but the step kept running. A warning. */\nexport function CappedMarker({record}: {record: CappedLogRecord}) {\n return (\n <LogMarkerRow\n icon=\"forbidLine\"\n tone=\"warning\"\n timestamp={new Date(record.ts)}\n detail=\"later output isn't shown; the step kept running\"\n >\n Log size limit reached\n </LogMarkerRow>\n );\n}\n\n/** The runner disappeared and the stream was force-closed. A terminal failure (status taxonomy §9). */\nexport function RunnerLostMarker({record}: {record: RunnerLostLogRecord}) {\n return (\n <LogMarkerRow\n icon=\"closeCircleLine\"\n tone=\"error\"\n timestamp={new Date(record.ts)}\n detail=\"the log ends here and may be incomplete\"\n terminalFailure\n >\n Runner disconnected\n </LogMarkerRow>\n );\n}\n"],"names":["Icon","LogContent","LogRow","cn","formatBytes","formatDuration","toneText","default","warning","error","LogMarkerRow","icon","tone","timestamp","detail","meta","terminalFailure","children","lineNumber","data-log-terminal-failure","undefined","className","span","name","aria-hidden","EndMarker","record","lineCount","durationMs","total_bytes","join","Date","ts","GapMarker","dropped_bytes","CappedMarker","RunnerLostMarker"],"mappings":";AAAA,SAAQA,IAAI,QAAsB,yBAAyB;AAC3D,SAAQC,UAAU,EAAEC,MAAM,QAAO,wBAAwB;AACzD,SAAQC,EAAE,EAAEC,WAAW,EAAEC,cAAc,QAAO,0BAA0B;AAUxE,MAAMC,WAAuC;IAC3CC,SAAS;IACTC,SAAS;IACTC,OAAO;AACT;AAcA;;;;;;CAMC,GACD,SAASC,aAAa,EACpBC,IAAI,EACJC,IAAI,EACJC,YAAY,IAAI,EAChBC,MAAM,EACNC,IAAI,EACJC,kBAAkB,KAAK,EACvBC,QAAQ,EACU;IAClB,qBACE,KAACf;QACCgB,YAAY;QACZL,WAAWA;QACXD,MAAMA;QACNO,6BAA2BH,kBAAkB,SAASI;kBAEtD,cAAA,KAACnB;YAAWoB,WAAWlB,GAAG,SAASG,QAAQ,CAACM,KAAK;sBAC/C,cAAA,MAACU;gBAAKD,WAAU;;kCACd,KAACrB;wBAAKuB,MAAMZ;wBAAMU,WAAU;wBAAoBG,eAAY;;kCAK5D,MAACF;wBAAKD,WAAU;;0CACd,KAACC;gCAAKD,WAAU;0CAAeJ;;4BAC9BH,UAAU,sBACT;;oCACG;kDACD,KAACQ;wCAAKD,WAAU;kDAA0BP;;;;4BAG7CC,QAAQ,sBACP;;oCACG;kDACD,KAACO;wCAAKD,WAAU;kDAAqCN;;;;;;kCAI3D,KAACO;wBACCE,eAAY;wBACZH,WAAU;;;;;;AAMtB;AAQA,0GAA0G,GAC1G,OAAO,SAASI,UAAU,EAACC,MAAM,EAAEC,SAAS,EAAEC,aAAa,IAAI,EAAiB;IAC9E,MAAMb,OAAO;QACX,GAAGY,UAAU,CAAC,EAAEA,cAAc,IAAI,SAAS,SAAS;QACpDvB,YAAYsB,OAAOG,WAAW;WAC1BD,cAAc,OAAO;YAACvB,eAAeuB;SAAY,GAAG,EAAE;KAC3D,CAACE,IAAI,CAAC;IAEP,qBACE,KAACpB;QAAaC,MAAK;QAAWC,MAAK;QAAUC,WAAW,IAAIkB,KAAKL,OAAOM,EAAE;QAAGjB,MAAMA;kBAAM;;AAI7F;AAEA,kGAAkG,GAClG,OAAO,SAASkB,UAAU,EAACP,MAAM,EAAyB;IACxD,qBACE,KAAChB;QACCC,MAAK;QACLC,MAAK;QACLC,WAAW,IAAIkB,KAAKL,OAAOM,EAAE;QAC7BlB,QAAQ,CAAC,mCAAmC,EAAEV,YAAYsB,OAAOQ,aAAa,GAAG;kBAClF;;AAIL;AAEA,iGAAiG,GACjG,OAAO,SAASC,aAAa,EAACT,MAAM,EAA4B;IAC9D,qBACE,KAAChB;QACCC,MAAK;QACLC,MAAK;QACLC,WAAW,IAAIkB,KAAKL,OAAOM,EAAE;QAC7BlB,QAAO;kBACR;;AAIL;AAEA,qGAAqG,GACrG,OAAO,SAASsB,iBAAiB,EAACV,MAAM,EAAgC;IACtE,qBACE,KAAChB;QACCC,MAAK;QACLC,MAAK;QACLC,WAAW,IAAIkB,KAAKL,OAAOM,EAAE;QAC7BlB,QAAO;QACPE,eAAe;kBAChB;;AAIL"}
@@ -0,0 +1,33 @@
1
+ import { type LogRecord, type ReadLogsResponseDto } from '@shipfox/api-logs-dto';
2
+ export declare const STEP_LOG_DRAIN_REFETCH_MS = 250;
3
+ export declare const STEP_LOG_LIVE_REFETCH_MS = 2000;
4
+ type InlineReadLogsResponse = Extract<ReadLogsResponseDto, {
5
+ mode: 'inline';
6
+ }>;
7
+ type PresignedReadLogsResponse = Extract<ReadLogsResponseDto, {
8
+ mode: 'presigned';
9
+ }>;
10
+ export interface StepLogSnapshot {
11
+ records: LogRecord[];
12
+ nextCursor: number;
13
+ source: 'inline' | 'presigned';
14
+ state: 'open' | 'closed' | 'compacted';
15
+ complete: boolean;
16
+ hasMore: boolean;
17
+ truncated: boolean;
18
+ totalBytes: number | null;
19
+ expiresAt: string | null;
20
+ }
21
+ export type ResolvedStepLogRead = {
22
+ mode: 'inline';
23
+ response: InlineReadLogsResponse;
24
+ } | {
25
+ mode: 'presigned';
26
+ response: PresignedReadLogsResponse;
27
+ ndjson: string;
28
+ };
29
+ export declare function parseLogNdjson(ndjson: string): LogRecord[];
30
+ export declare function mergeLogRead(previous: StepLogSnapshot | undefined, read: ResolvedStepLogRead): StepLogSnapshot;
31
+ export declare function stepLogRefetchInterval(snapshot: StepLogSnapshot | undefined, lastFetchErrored?: boolean): number | false;
32
+ export {};
33
+ //# sourceMappingURL=log-read.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"log-read.d.ts","sourceRoot":"","sources":["../../src/core/log-read.ts"],"names":[],"mappings":"AAAA,OAAO,EAAC,KAAK,SAAS,EAAsB,KAAK,mBAAmB,EAAC,MAAM,uBAAuB,CAAC;AAEnG,eAAO,MAAM,yBAAyB,MAAM,CAAC;AAC7C,eAAO,MAAM,wBAAwB,OAAQ,CAAC;AAI9C,KAAK,sBAAsB,GAAG,OAAO,CAAC,mBAAmB,EAAE;IAAC,IAAI,EAAE,QAAQ,CAAA;CAAC,CAAC,CAAC;AAC7E,KAAK,yBAAyB,GAAG,OAAO,CAAC,mBAAmB,EAAE;IAAC,IAAI,EAAE,WAAW,CAAA;CAAC,CAAC,CAAC;AAEnF,MAAM,WAAW,eAAe;IAC9B,OAAO,EAAE,SAAS,EAAE,CAAC;IACrB,UAAU,EAAE,MAAM,CAAC;IACnB,MAAM,EAAE,QAAQ,GAAG,WAAW,CAAC;IAC/B,KAAK,EAAE,MAAM,GAAG,QAAQ,GAAG,WAAW,CAAC;IACvC,QAAQ,EAAE,OAAO,CAAC;IAClB,OAAO,EAAE,OAAO,CAAC;IACjB,SAAS,EAAE,OAAO,CAAC;IACnB,UAAU,EAAE,MAAM,GAAG,IAAI,CAAC;IAC1B,SAAS,EAAE,MAAM,GAAG,IAAI,CAAC;CAC1B;AAED,MAAM,MAAM,mBAAmB,GAC3B;IAAC,IAAI,EAAE,QAAQ,CAAC;IAAC,QAAQ,EAAE,sBAAsB,CAAA;CAAC,GAClD;IAAC,IAAI,EAAE,WAAW,CAAC;IAAC,QAAQ,EAAE,yBAAyB,CAAC;IAAC,MAAM,EAAE,MAAM,CAAA;CAAC,CAAC;AAE7E,wBAAgB,cAAc,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,EAAE,CAK1D;AAED,wBAAgB,YAAY,CAC1B,QAAQ,EAAE,eAAe,GAAG,SAAS,EACrC,IAAI,EAAE,mBAAmB,GACxB,eAAe,CA6BjB;AAED,wBAAgB,sBAAsB,CACpC,QAAQ,EAAE,eAAe,GAAG,SAAS,EACrC,gBAAgB,UAAQ,GACvB,MAAM,GAAG,KAAK,CAQhB"}
@@ -0,0 +1,49 @@
1
+ import { parseLogRecordLine } from '@shipfox/api-logs-dto';
2
+ export const STEP_LOG_DRAIN_REFETCH_MS = 250;
3
+ export const STEP_LOG_LIVE_REFETCH_MS = 2_000;
4
+ const NDJSON_LINE_BREAK = /\r?\n/;
5
+ export function parseLogNdjson(ndjson) {
6
+ return ndjson.split(NDJSON_LINE_BREAK).filter((line)=>line.length > 0).map(parseLogRecordLine);
7
+ }
8
+ export function mergeLogRead(previous, read) {
9
+ if (read.mode === 'presigned') {
10
+ return {
11
+ records: parseLogNdjson(read.ndjson),
12
+ nextCursor: 0,
13
+ source: 'presigned',
14
+ state: 'compacted',
15
+ complete: true,
16
+ hasMore: false,
17
+ truncated: read.response.truncated,
18
+ totalBytes: read.response.total_bytes,
19
+ expiresAt: read.response.expires_at
20
+ };
21
+ }
22
+ const records = parseLogNdjson(read.response.ndjson);
23
+ const complete = read.response.state === 'closed' && !read.response.has_more;
24
+ return {
25
+ records: [
26
+ ...previous?.records ?? [],
27
+ ...records
28
+ ],
29
+ nextCursor: read.response.next_cursor,
30
+ source: 'inline',
31
+ state: read.response.state,
32
+ complete,
33
+ hasMore: read.response.has_more,
34
+ truncated: read.response.truncated,
35
+ totalBytes: null,
36
+ expiresAt: null
37
+ };
38
+ }
39
+ export function stepLogRefetchInterval(snapshot, lastFetchErrored = false) {
40
+ // A persistent failure (deleted step, 5xx, an unparseable record line) never advances
41
+ // the cursor, so without this guard the interval re-polls the same dead cursor forever.
42
+ // Stop once a fetch errors out (React Query's own retry absorbs transient blips first);
43
+ // refetchOnWindowFocus/Reconnect (gated on !complete) resume it.
44
+ if (lastFetchErrored) return false;
45
+ if (!snapshot || snapshot.complete) return false;
46
+ return snapshot.hasMore ? STEP_LOG_DRAIN_REFETCH_MS : STEP_LOG_LIVE_REFETCH_MS;
47
+ }
48
+
49
+ //# sourceMappingURL=log-read.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/core/log-read.ts"],"sourcesContent":["import {type LogRecord, parseLogRecordLine, type ReadLogsResponseDto} from '@shipfox/api-logs-dto';\n\nexport const STEP_LOG_DRAIN_REFETCH_MS = 250;\nexport const STEP_LOG_LIVE_REFETCH_MS = 2_000;\n\nconst NDJSON_LINE_BREAK = /\\r?\\n/;\n\ntype InlineReadLogsResponse = Extract<ReadLogsResponseDto, {mode: 'inline'}>;\ntype PresignedReadLogsResponse = Extract<ReadLogsResponseDto, {mode: 'presigned'}>;\n\nexport interface StepLogSnapshot {\n records: LogRecord[];\n nextCursor: number;\n source: 'inline' | 'presigned';\n state: 'open' | 'closed' | 'compacted';\n complete: boolean;\n hasMore: boolean;\n truncated: boolean;\n totalBytes: number | null;\n expiresAt: string | null;\n}\n\nexport type ResolvedStepLogRead =\n | {mode: 'inline'; response: InlineReadLogsResponse}\n | {mode: 'presigned'; response: PresignedReadLogsResponse; ndjson: string};\n\nexport function parseLogNdjson(ndjson: string): LogRecord[] {\n return ndjson\n .split(NDJSON_LINE_BREAK)\n .filter((line) => line.length > 0)\n .map(parseLogRecordLine);\n}\n\nexport function mergeLogRead(\n previous: StepLogSnapshot | undefined,\n read: ResolvedStepLogRead,\n): StepLogSnapshot {\n if (read.mode === 'presigned') {\n return {\n records: parseLogNdjson(read.ndjson),\n nextCursor: 0,\n source: 'presigned',\n state: 'compacted',\n complete: true,\n hasMore: false,\n truncated: read.response.truncated,\n totalBytes: read.response.total_bytes,\n expiresAt: read.response.expires_at,\n };\n }\n\n const records = parseLogNdjson(read.response.ndjson);\n const complete = read.response.state === 'closed' && !read.response.has_more;\n\n return {\n records: [...(previous?.records ?? []), ...records],\n nextCursor: read.response.next_cursor,\n source: 'inline',\n state: read.response.state,\n complete,\n hasMore: read.response.has_more,\n truncated: read.response.truncated,\n totalBytes: null,\n expiresAt: null,\n };\n}\n\nexport function stepLogRefetchInterval(\n snapshot: StepLogSnapshot | undefined,\n lastFetchErrored = false,\n): number | false {\n // A persistent failure (deleted step, 5xx, an unparseable record line) never advances\n // the cursor, so without this guard the interval re-polls the same dead cursor forever.\n // Stop once a fetch errors out (React Query's own retry absorbs transient blips first);\n // refetchOnWindowFocus/Reconnect (gated on !complete) resume it.\n if (lastFetchErrored) return false;\n if (!snapshot || snapshot.complete) return false;\n return snapshot.hasMore ? STEP_LOG_DRAIN_REFETCH_MS : STEP_LOG_LIVE_REFETCH_MS;\n}\n"],"names":["parseLogRecordLine","STEP_LOG_DRAIN_REFETCH_MS","STEP_LOG_LIVE_REFETCH_MS","NDJSON_LINE_BREAK","parseLogNdjson","ndjson","split","filter","line","length","map","mergeLogRead","previous","read","mode","records","nextCursor","source","state","complete","hasMore","truncated","response","totalBytes","total_bytes","expiresAt","expires_at","has_more","next_cursor","stepLogRefetchInterval","snapshot","lastFetchErrored"],"mappings":"AAAA,SAAwBA,kBAAkB,QAAiC,wBAAwB;AAEnG,OAAO,MAAMC,4BAA4B,IAAI;AAC7C,OAAO,MAAMC,2BAA2B,MAAM;AAE9C,MAAMC,oBAAoB;AAqB1B,OAAO,SAASC,eAAeC,MAAc;IAC3C,OAAOA,OACJC,KAAK,CAACH,mBACNI,MAAM,CAAC,CAACC,OAASA,KAAKC,MAAM,GAAG,GAC/BC,GAAG,CAACV;AACT;AAEA,OAAO,SAASW,aACdC,QAAqC,EACrCC,IAAyB;IAEzB,IAAIA,KAAKC,IAAI,KAAK,aAAa;QAC7B,OAAO;YACLC,SAASX,eAAeS,KAAKR,MAAM;YACnCW,YAAY;YACZC,QAAQ;YACRC,OAAO;YACPC,UAAU;YACVC,SAAS;YACTC,WAAWR,KAAKS,QAAQ,CAACD,SAAS;YAClCE,YAAYV,KAAKS,QAAQ,CAACE,WAAW;YACrCC,WAAWZ,KAAKS,QAAQ,CAACI,UAAU;QACrC;IACF;IAEA,MAAMX,UAAUX,eAAeS,KAAKS,QAAQ,CAACjB,MAAM;IACnD,MAAMc,WAAWN,KAAKS,QAAQ,CAACJ,KAAK,KAAK,YAAY,CAACL,KAAKS,QAAQ,CAACK,QAAQ;IAE5E,OAAO;QACLZ,SAAS;eAAKH,UAAUG,WAAW,EAAE;eAAMA;SAAQ;QACnDC,YAAYH,KAAKS,QAAQ,CAACM,WAAW;QACrCX,QAAQ;QACRC,OAAOL,KAAKS,QAAQ,CAACJ,KAAK;QAC1BC;QACAC,SAASP,KAAKS,QAAQ,CAACK,QAAQ;QAC/BN,WAAWR,KAAKS,QAAQ,CAACD,SAAS;QAClCE,YAAY;QACZE,WAAW;IACb;AACF;AAEA,OAAO,SAASI,uBACdC,QAAqC,EACrCC,mBAAmB,KAAK;IAExB,sFAAsF;IACtF,wFAAwF;IACxF,wFAAwF;IACxF,iEAAiE;IACjE,IAAIA,kBAAkB,OAAO;IAC7B,IAAI,CAACD,YAAYA,SAASX,QAAQ,EAAE,OAAO;IAC3C,OAAOW,SAASV,OAAO,GAAGnB,4BAA4BC;AACxD"}
@@ -0,0 +1,85 @@
1
+ import type { LogRecord } from '@shipfox/api-logs-dto';
2
+ /**
3
+ * Pure render transform for the step-log read stream. The runner emits a flat,
4
+ * ordered NDJSON record list; `group_start`/`group_end` form a tree that the
5
+ * reader reconstructs here before rendering. No React, no state — one function
6
+ * over the record array.
7
+ *
8
+ * records[] ──▶ buildLogTree ──▶ { nodes (forest), terminated, originTs, lineCount }
9
+ *
10
+ * Group closing matches `group_id` (not a blind top-of-stack pop) so a stream that
11
+ * drops a `group_start` under backlog/gap pressure but still delivers its
12
+ * `group_end` does not mis-nest everything after it.
13
+ */
14
+ export type OutputLogRecord = Extract<LogRecord, {
15
+ type: 'output';
16
+ }>;
17
+ export type GroupStartLogRecord = Extract<LogRecord, {
18
+ type: 'group_start';
19
+ }>;
20
+ export type EndLogRecord = Extract<LogRecord, {
21
+ type: 'end';
22
+ }>;
23
+ export type GapLogRecord = Extract<LogRecord, {
24
+ type: 'gap';
25
+ }>;
26
+ export type CappedLogRecord = Extract<LogRecord, {
27
+ type: 'capped';
28
+ }>;
29
+ export type RunnerLostLogRecord = Extract<LogRecord, {
30
+ type: 'runner_lost';
31
+ }>;
32
+ export type AgentSessionLogRecord = Extract<LogRecord, {
33
+ type: 'agent_session';
34
+ }>;
35
+ export type MarkerLogRecord = EndLogRecord | GapLogRecord | CappedLogRecord | RunnerLostLogRecord;
36
+ /**
37
+ * Stable, unique render key in creation order. A natural key is not enough: `group_id`
38
+ * and a marker's `(type, ts)` can both repeat among siblings once a consumer feeds a
39
+ * concatenated multi-step/retry stream (or two markers land in the same millisecond),
40
+ * and the append-only build order keeps `seq` stable across re-renders.
41
+ */
42
+ export interface LogNodeBase {
43
+ seq: number;
44
+ }
45
+ export interface OutputLogNode extends LogNodeBase {
46
+ kind: 'output';
47
+ lineNumber: number;
48
+ record: OutputLogRecord;
49
+ }
50
+ export interface MarkerLogNode extends LogNodeBase {
51
+ kind: 'marker';
52
+ record: MarkerLogRecord;
53
+ }
54
+ export interface GroupLogNode extends LogNodeBase {
55
+ kind: 'group';
56
+ record: GroupStartLogRecord;
57
+ /** False when no matching `group_end` arrived (still streaming, or truncated). */
58
+ closed: boolean;
59
+ /** `group_end` timestamp when closed by its matching end, else null. */
60
+ endTs: number | null;
61
+ /** Precomputed: subtree contains a `runner_lost` (a genuine failure). `stderr` is a channel, not an error, so it never sets this. */
62
+ hasError: boolean;
63
+ /** Precomputed output-line count in the subtree, for the collapsed summary. */
64
+ lineCount: number;
65
+ children: LogNode[];
66
+ }
67
+ export interface SessionLogNode extends LogNodeBase {
68
+ kind: 'session';
69
+ record: AgentSessionLogRecord;
70
+ }
71
+ export type LogNode = OutputLogNode | MarkerLogNode | GroupLogNode | SessionLogNode;
72
+ export interface LogTree {
73
+ nodes: LogNode[];
74
+ /** The stream is closed: the records contain an `end` or a `runner_lost`. */
75
+ terminated: boolean;
76
+ /** First record's timestamp; the baseline for relative timestamps. Null when empty. */
77
+ originTs: number | null;
78
+ /** Physical output lines (one per `output` record in v1); drives the end banner. */
79
+ lineCount: number;
80
+ }
81
+ /** Strips a single trailing line ending (CRLF or LF) so a line-framed record renders without a blank continuation. */
82
+ export declare function stripTrailingNewline(data: string): string;
83
+ export declare function assertNever(value: never): never;
84
+ export declare function buildLogTree(records: readonly LogRecord[]): LogTree;
85
+ //# sourceMappingURL=log-tree.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"log-tree.d.ts","sourceRoot":"","sources":["../../src/core/log-tree.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAC,SAAS,EAAC,MAAM,uBAAuB,CAAC;AAErD;;;;;;;;;;;GAWG;AAEH,MAAM,MAAM,eAAe,GAAG,OAAO,CAAC,SAAS,EAAE;IAAC,IAAI,EAAE,QAAQ,CAAA;CAAC,CAAC,CAAC;AACnE,MAAM,MAAM,mBAAmB,GAAG,OAAO,CAAC,SAAS,EAAE;IAAC,IAAI,EAAE,aAAa,CAAA;CAAC,CAAC,CAAC;AAC5E,MAAM,MAAM,YAAY,GAAG,OAAO,CAAC,SAAS,EAAE;IAAC,IAAI,EAAE,KAAK,CAAA;CAAC,CAAC,CAAC;AAC7D,MAAM,MAAM,YAAY,GAAG,OAAO,CAAC,SAAS,EAAE;IAAC,IAAI,EAAE,KAAK,CAAA;CAAC,CAAC,CAAC;AAC7D,MAAM,MAAM,eAAe,GAAG,OAAO,CAAC,SAAS,EAAE;IAAC,IAAI,EAAE,QAAQ,CAAA;CAAC,CAAC,CAAC;AACnE,MAAM,MAAM,mBAAmB,GAAG,OAAO,CAAC,SAAS,EAAE;IAAC,IAAI,EAAE,aAAa,CAAA;CAAC,CAAC,CAAC;AAC5E,MAAM,MAAM,qBAAqB,GAAG,OAAO,CAAC,SAAS,EAAE;IAAC,IAAI,EAAE,eAAe,CAAA;CAAC,CAAC,CAAC;AAChF,MAAM,MAAM,eAAe,GAAG,YAAY,GAAG,YAAY,GAAG,eAAe,GAAG,mBAAmB,CAAC;AAElG;;;;;GAKG;AACH,MAAM,WAAW,WAAW;IAC1B,GAAG,EAAE,MAAM,CAAC;CACb;AAED,MAAM,WAAW,aAAc,SAAQ,WAAW;IAChD,IAAI,EAAE,QAAQ,CAAC;IACf,UAAU,EAAE,MAAM,CAAC;IACnB,MAAM,EAAE,eAAe,CAAC;CACzB;AAED,MAAM,WAAW,aAAc,SAAQ,WAAW;IAChD,IAAI,EAAE,QAAQ,CAAC;IACf,MAAM,EAAE,eAAe,CAAC;CACzB;AAED,MAAM,WAAW,YAAa,SAAQ,WAAW;IAC/C,IAAI,EAAE,OAAO,CAAC;IACd,MAAM,EAAE,mBAAmB,CAAC;IAC5B,kFAAkF;IAClF,MAAM,EAAE,OAAO,CAAC;IAChB,wEAAwE;IACxE,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;IACrB,qIAAqI;IACrI,QAAQ,EAAE,OAAO,CAAC;IAClB,+EAA+E;IAC/E,SAAS,EAAE,MAAM,CAAC;IAClB,QAAQ,EAAE,OAAO,EAAE,CAAC;CACrB;AAED,MAAM,WAAW,cAAe,SAAQ,WAAW;IACjD,IAAI,EAAE,SAAS,CAAC;IAChB,MAAM,EAAE,qBAAqB,CAAC;CAC/B;AAED,MAAM,MAAM,OAAO,GAAG,aAAa,GAAG,aAAa,GAAG,YAAY,GAAG,cAAc,CAAC;AAEpF,MAAM,WAAW,OAAO;IACtB,KAAK,EAAE,OAAO,EAAE,CAAC;IACjB,6EAA6E;IAC7E,UAAU,EAAE,OAAO,CAAC;IACpB,uFAAuF;IACvF,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAC;IACxB,oFAAoF;IACpF,SAAS,EAAE,MAAM,CAAC;CACnB;AAID,sHAAsH;AACtH,wBAAgB,oBAAoB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAEzD;AAED,wBAAgB,WAAW,CAAC,KAAK,EAAE,KAAK,GAAG,KAAK,CAE/C;AAED,wBAAgB,YAAY,CAAC,OAAO,EAAE,SAAS,SAAS,EAAE,GAAG,OAAO,CAgHnE"}
@@ -0,0 +1,139 @@
1
+ const TRAILING_NEWLINE = /\r?\n$/;
2
+ /** Strips a single trailing line ending (CRLF or LF) so a line-framed record renders without a blank continuation. */ export function stripTrailingNewline(data) {
3
+ return data.replace(TRAILING_NEWLINE, '');
4
+ }
5
+ export function assertNever(value) {
6
+ throw new Error(`unexpected log record type: ${JSON.stringify(value)}`);
7
+ }
8
+ export function buildLogTree(records) {
9
+ const nodes = [];
10
+ const stack = [];
11
+ let seq = 0;
12
+ let lineNumber = 0;
13
+ let lineCount = 0;
14
+ let terminated = false;
15
+ let originTs = null;
16
+ const childrenOf = ()=>stack[stack.length - 1]?.children ?? nodes;
17
+ // Bubble a failure signal (a runner_lost only) to every currently-open ancestor group
18
+ // in one pass, so `hasError` is read in O(1) at render time instead of re-walking subtrees.
19
+ const markOpenGroupsError = ()=>{
20
+ for (const frame of stack)frame.hasError = true;
21
+ };
22
+ for (const record of records){
23
+ if (originTs === null) originTs = record.ts;
24
+ switch(record.type){
25
+ case 'output':
26
+ {
27
+ lineNumber += 1;
28
+ lineCount += 1;
29
+ for (const frame of stack)frame.lineCount += 1;
30
+ childrenOf().push({
31
+ kind: 'output',
32
+ seq: seq++,
33
+ lineNumber,
34
+ record
35
+ });
36
+ break;
37
+ }
38
+ case 'group_start':
39
+ {
40
+ // Reconcile the open stack to the declared parent before nesting. `parent_group_id`
41
+ // is the runner's stack top at emit time (null at the root), so any reader frame
42
+ // below that parent (or every open frame, when the parent is root) is a group whose
43
+ // own `group_end` was dropped under backlog pressure. Orphan-close those frames so a
44
+ // dropped end never mis-parents the groups that follow. A parent whose own start was
45
+ // dropped is not on the stack: it falls through to best-effort root placement.
46
+ const parentId = record.parent_group_id;
47
+ let parentIndex = -1;
48
+ if (parentId !== null) {
49
+ for(let i = stack.length - 1; i >= 0; i -= 1){
50
+ if (stack[i]?.record.group_id === parentId) {
51
+ parentIndex = i;
52
+ break;
53
+ }
54
+ }
55
+ }
56
+ for(let i = stack.length - 1; i > parentIndex; i -= 1){
57
+ const frame = stack[i];
58
+ if (frame) frame.closed = true;
59
+ }
60
+ stack.length = parentIndex + 1;
61
+ const group = {
62
+ kind: 'group',
63
+ seq: seq++,
64
+ record,
65
+ closed: false,
66
+ endTs: null,
67
+ hasError: false,
68
+ lineCount: 0,
69
+ children: []
70
+ };
71
+ childrenOf().push(group);
72
+ stack.push(group);
73
+ break;
74
+ }
75
+ case 'group_end':
76
+ {
77
+ // Close the matching open group_id; any inner frames orphaned by a dropped
78
+ // group_end close with it. An end with no matching open start is ignored.
79
+ let matchIndex = -1;
80
+ for(let i = stack.length - 1; i >= 0; i -= 1){
81
+ if (stack[i]?.record.group_id === record.group_id) {
82
+ matchIndex = i;
83
+ break;
84
+ }
85
+ }
86
+ if (matchIndex !== -1) {
87
+ for(let i = stack.length - 1; i >= matchIndex; i -= 1){
88
+ const frame = stack[i];
89
+ if (frame) frame.closed = true;
90
+ }
91
+ const matched = stack[matchIndex];
92
+ if (matched) matched.endTs = record.ts;
93
+ stack.length = matchIndex;
94
+ }
95
+ break;
96
+ }
97
+ case 'end':
98
+ case 'gap':
99
+ case 'capped':
100
+ {
101
+ if (record.type === 'end') terminated = true;
102
+ childrenOf().push({
103
+ kind: 'marker',
104
+ seq: seq++,
105
+ record
106
+ });
107
+ break;
108
+ }
109
+ case 'runner_lost':
110
+ {
111
+ terminated = true;
112
+ childrenOf().push({
113
+ kind: 'marker',
114
+ seq: seq++,
115
+ record
116
+ });
117
+ markOpenGroupsError();
118
+ break;
119
+ }
120
+ case 'agent_session':
121
+ childrenOf().push({
122
+ kind: 'session',
123
+ seq: seq++,
124
+ record
125
+ });
126
+ break;
127
+ default:
128
+ assertNever(record);
129
+ }
130
+ }
131
+ return {
132
+ nodes,
133
+ terminated,
134
+ originTs,
135
+ lineCount
136
+ };
137
+ }
138
+
139
+ //# sourceMappingURL=log-tree.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/core/log-tree.ts"],"sourcesContent":["import type {LogRecord} from '@shipfox/api-logs-dto';\n\n/**\n * Pure render transform for the step-log read stream. The runner emits a flat,\n * ordered NDJSON record list; `group_start`/`group_end` form a tree that the\n * reader reconstructs here before rendering. No React, no state — one function\n * over the record array.\n *\n * records[] ──▶ buildLogTree ──▶ { nodes (forest), terminated, originTs, lineCount }\n *\n * Group closing matches `group_id` (not a blind top-of-stack pop) so a stream that\n * drops a `group_start` under backlog/gap pressure but still delivers its\n * `group_end` does not mis-nest everything after it.\n */\n\nexport type OutputLogRecord = Extract<LogRecord, {type: 'output'}>;\nexport type GroupStartLogRecord = Extract<LogRecord, {type: 'group_start'}>;\nexport type EndLogRecord = Extract<LogRecord, {type: 'end'}>;\nexport type GapLogRecord = Extract<LogRecord, {type: 'gap'}>;\nexport type CappedLogRecord = Extract<LogRecord, {type: 'capped'}>;\nexport type RunnerLostLogRecord = Extract<LogRecord, {type: 'runner_lost'}>;\nexport type AgentSessionLogRecord = Extract<LogRecord, {type: 'agent_session'}>;\nexport type MarkerLogRecord = EndLogRecord | GapLogRecord | CappedLogRecord | RunnerLostLogRecord;\n\n/**\n * Stable, unique render key in creation order. A natural key is not enough: `group_id`\n * and a marker's `(type, ts)` can both repeat among siblings once a consumer feeds a\n * concatenated multi-step/retry stream (or two markers land in the same millisecond),\n * and the append-only build order keeps `seq` stable across re-renders.\n */\nexport interface LogNodeBase {\n seq: number;\n}\n\nexport interface OutputLogNode extends LogNodeBase {\n kind: 'output';\n lineNumber: number;\n record: OutputLogRecord;\n}\n\nexport interface MarkerLogNode extends LogNodeBase {\n kind: 'marker';\n record: MarkerLogRecord;\n}\n\nexport interface GroupLogNode extends LogNodeBase {\n kind: 'group';\n record: GroupStartLogRecord;\n /** False when no matching `group_end` arrived (still streaming, or truncated). */\n closed: boolean;\n /** `group_end` timestamp when closed by its matching end, else null. */\n endTs: number | null;\n /** Precomputed: subtree contains a `runner_lost` (a genuine failure). `stderr` is a channel, not an error, so it never sets this. */\n hasError: boolean;\n /** Precomputed output-line count in the subtree, for the collapsed summary. */\n lineCount: number;\n children: LogNode[];\n}\n\nexport interface SessionLogNode extends LogNodeBase {\n kind: 'session';\n record: AgentSessionLogRecord;\n}\n\nexport type LogNode = OutputLogNode | MarkerLogNode | GroupLogNode | SessionLogNode;\n\nexport interface LogTree {\n nodes: LogNode[];\n /** The stream is closed: the records contain an `end` or a `runner_lost`. */\n terminated: boolean;\n /** First record's timestamp; the baseline for relative timestamps. Null when empty. */\n originTs: number | null;\n /** Physical output lines (one per `output` record in v1); drives the end banner. */\n lineCount: number;\n}\n\nconst TRAILING_NEWLINE = /\\r?\\n$/;\n\n/** Strips a single trailing line ending (CRLF or LF) so a line-framed record renders without a blank continuation. */\nexport function stripTrailingNewline(data: string): string {\n return data.replace(TRAILING_NEWLINE, '');\n}\n\nexport function assertNever(value: never): never {\n throw new Error(`unexpected log record type: ${JSON.stringify(value)}`);\n}\n\nexport function buildLogTree(records: readonly LogRecord[]): LogTree {\n const nodes: LogNode[] = [];\n const stack: GroupLogNode[] = [];\n let seq = 0;\n let lineNumber = 0;\n let lineCount = 0;\n let terminated = false;\n let originTs: number | null = null;\n\n const childrenOf = (): LogNode[] => stack[stack.length - 1]?.children ?? nodes;\n\n // Bubble a failure signal (a runner_lost only) to every currently-open ancestor group\n // in one pass, so `hasError` is read in O(1) at render time instead of re-walking subtrees.\n const markOpenGroupsError = (): void => {\n for (const frame of stack) frame.hasError = true;\n };\n\n for (const record of records) {\n if (originTs === null) originTs = record.ts;\n switch (record.type) {\n case 'output': {\n lineNumber += 1;\n lineCount += 1;\n for (const frame of stack) frame.lineCount += 1;\n childrenOf().push({kind: 'output', seq: seq++, lineNumber, record});\n break;\n }\n case 'group_start': {\n // Reconcile the open stack to the declared parent before nesting. `parent_group_id`\n // is the runner's stack top at emit time (null at the root), so any reader frame\n // below that parent (or every open frame, when the parent is root) is a group whose\n // own `group_end` was dropped under backlog pressure. Orphan-close those frames so a\n // dropped end never mis-parents the groups that follow. A parent whose own start was\n // dropped is not on the stack: it falls through to best-effort root placement.\n const parentId = record.parent_group_id;\n let parentIndex = -1;\n if (parentId !== null) {\n for (let i = stack.length - 1; i >= 0; i -= 1) {\n if (stack[i]?.record.group_id === parentId) {\n parentIndex = i;\n break;\n }\n }\n }\n for (let i = stack.length - 1; i > parentIndex; i -= 1) {\n const frame = stack[i];\n if (frame) frame.closed = true;\n }\n stack.length = parentIndex + 1;\n\n const group: GroupLogNode = {\n kind: 'group',\n seq: seq++,\n record,\n closed: false,\n endTs: null,\n hasError: false,\n lineCount: 0,\n children: [],\n };\n childrenOf().push(group);\n stack.push(group);\n break;\n }\n case 'group_end': {\n // Close the matching open group_id; any inner frames orphaned by a dropped\n // group_end close with it. An end with no matching open start is ignored.\n let matchIndex = -1;\n for (let i = stack.length - 1; i >= 0; i -= 1) {\n if (stack[i]?.record.group_id === record.group_id) {\n matchIndex = i;\n break;\n }\n }\n if (matchIndex !== -1) {\n for (let i = stack.length - 1; i >= matchIndex; i -= 1) {\n const frame = stack[i];\n if (frame) frame.closed = true;\n }\n const matched = stack[matchIndex];\n if (matched) matched.endTs = record.ts;\n stack.length = matchIndex;\n }\n break;\n }\n case 'end':\n case 'gap':\n case 'capped': {\n if (record.type === 'end') terminated = true;\n childrenOf().push({kind: 'marker', seq: seq++, record});\n break;\n }\n case 'runner_lost': {\n terminated = true;\n childrenOf().push({kind: 'marker', seq: seq++, record});\n markOpenGroupsError();\n break;\n }\n case 'agent_session':\n childrenOf().push({kind: 'session', seq: seq++, record});\n break;\n default:\n assertNever(record);\n }\n }\n\n return {\n nodes,\n terminated,\n originTs,\n lineCount,\n };\n}\n"],"names":["TRAILING_NEWLINE","stripTrailingNewline","data","replace","assertNever","value","Error","JSON","stringify","buildLogTree","records","nodes","stack","seq","lineNumber","lineCount","terminated","originTs","childrenOf","length","children","markOpenGroupsError","frame","hasError","record","ts","type","push","kind","parentId","parent_group_id","parentIndex","i","group_id","closed","group","endTs","matchIndex","matched"],"mappings":"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"}
package/dist/env.d.js ADDED
@@ -0,0 +1,2 @@
1
+
2
+ //# sourceMappingURL=env.d.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/env.d.ts"],"names":[],"mappings":""}
@@ -0,0 +1,24 @@
1
+ import type { ReadLogsResponseDto } from '@shipfox/api-logs-dto';
2
+ import { type StepLogSnapshot } from '#core/log-read.js';
3
+ export declare const stepLogsQueryKeys: {
4
+ all: readonly ["step-logs"];
5
+ detail: (stepId: string, attempt: number) => readonly ["step-logs", "detail", string, number];
6
+ };
7
+ interface ReadStepAttemptLogsPageParams {
8
+ stepId: string;
9
+ attempt: number;
10
+ cursor: number;
11
+ signal?: AbortSignal;
12
+ }
13
+ export declare function readStepAttemptLogsPage({ stepId, attempt, cursor, signal, }: ReadStepAttemptLogsPageParams): Promise<ReadLogsResponseDto>;
14
+ export declare function isMissingStepLogStreamError(error: unknown): boolean;
15
+ export interface UseStepAttemptLogsQueryOptions {
16
+ retryMissingStream?: boolean;
17
+ missingStreamRetryCount?: number | undefined;
18
+ missingStreamRetryDelayMs?: number | undefined;
19
+ initialErrorRetryCount?: number;
20
+ initialErrorRetryDelayMs?: number;
21
+ }
22
+ export declare function useStepAttemptLogsQuery(stepId: string | undefined, attempt: number | undefined, options?: UseStepAttemptLogsQueryOptions): import("@tanstack/react-query").UseQueryResult<NoInfer<StepLogSnapshot>, Error>;
23
+ export {};
24
+ //# sourceMappingURL=step-logs.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"step-logs.d.ts","sourceRoot":"","sources":["../../../src/hooks/api/step-logs.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAC,mBAAmB,EAAC,MAAM,uBAAuB,CAAC;AAI/D,OAAO,EAGL,KAAK,eAAe,EAErB,MAAM,mBAAmB,CAAC;AAE3B,eAAO,MAAM,iBAAiB;;qBAEX,MAAM,WAAW,MAAM;CAEzC,CAAC;AAEF,UAAU,6BAA6B;IACrC,MAAM,EAAE,MAAM,CAAC;IACf,OAAO,EAAE,MAAM,CAAC;IAChB,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,CAAC,EAAE,WAAW,CAAC;CACtB;AAED,wBAAsB,uBAAuB,CAAC,EAC5C,MAAM,EACN,OAAO,EACP,MAAM,EACN,MAAM,GACP,EAAE,6BAA6B,GAAG,OAAO,CAAC,mBAAmB,CAAC,CAM9D;AAkBD,wBAAgB,2BAA2B,CAAC,KAAK,EAAE,OAAO,GAAG,OAAO,CAEnE;AAED,MAAM,WAAW,8BAA8B;IAC7C,kBAAkB,CAAC,EAAE,OAAO,CAAC;IAC7B,uBAAuB,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IAC7C,yBAAyB,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IAC/C,sBAAsB,CAAC,EAAE,MAAM,CAAC;IAChC,wBAAwB,CAAC,EAAE,MAAM,CAAC;CACnC;AAED,wBAAgB,uBAAuB,CACrC,MAAM,EAAE,MAAM,GAAG,SAAS,EAC1B,OAAO,EAAE,MAAM,GAAG,SAAS,EAC3B,OAAO,GAAE,8BAAmC,mFAmF7C"}
@@ -0,0 +1,129 @@
1
+ import { ApiError, apiRequest } from '@shipfox/client-api';
2
+ import { useQuery, useQueryClient } from '@tanstack/react-query';
3
+ import { useRef } from 'react';
4
+ import { mergeLogRead, STEP_LOG_LIVE_REFETCH_MS, stepLogRefetchInterval } from '#core/log-read.js';
5
+ export const stepLogsQueryKeys = {
6
+ all: [
7
+ 'step-logs'
8
+ ],
9
+ detail: (stepId, attempt)=>[
10
+ ...stepLogsQueryKeys.all,
11
+ 'detail',
12
+ stepId,
13
+ attempt
14
+ ]
15
+ };
16
+ export async function readStepAttemptLogsPage({ stepId, attempt, cursor, signal }) {
17
+ const params = new URLSearchParams({
18
+ cursor: String(cursor)
19
+ });
20
+ return await apiRequest(`/steps/${encodeURIComponent(stepId)}/attempts/${attempt}/logs?${params.toString()}`, {
21
+ signal
22
+ });
23
+ }
24
+ let StepLogObjectFetchError = class StepLogObjectFetchError extends Error {
25
+ constructor(status){
26
+ super(`Could not load compacted logs (${status})`);
27
+ this.name = 'StepLogObjectFetchError';
28
+ this.status = status;
29
+ }
30
+ };
31
+ async function readPresignedLogObject(url, signal) {
32
+ const response = await fetch(url, signal ? {
33
+ signal
34
+ } : undefined);
35
+ if (!response.ok) throw new StepLogObjectFetchError(response.status);
36
+ return await response.text();
37
+ }
38
+ export function isMissingStepLogStreamError(error) {
39
+ return error instanceof ApiError && error.status === 404 && error.code === 'not-found';
40
+ }
41
+ export function useStepAttemptLogsQuery(stepId, attempt, options = {}) {
42
+ const queryClient = useQueryClient();
43
+ const missingStreamFailureCountRef = useRef(0);
44
+ const missingStreamScopeRef = useRef(null);
45
+ const enabled = Boolean(stepId && attempt && Number.isInteger(attempt) && attempt > 0);
46
+ const queryKey = enabled && stepId && attempt ? stepLogsQueryKeys.detail(stepId, attempt) : [
47
+ ...stepLogsQueryKeys.all,
48
+ 'detail'
49
+ ];
50
+ const missingStreamScope = enabled && stepId && attempt ? `${stepId}:${attempt}:${options.missingStreamRetryCount ?? 'unbounded'}` : null;
51
+ if (missingStreamScopeRef.current !== missingStreamScope) {
52
+ missingStreamScopeRef.current = missingStreamScope;
53
+ missingStreamFailureCountRef.current = 0;
54
+ }
55
+ const initialErrorRetryCount = options.initialErrorRetryCount ?? 0;
56
+ const initialErrorRetryDelayMs = options.initialErrorRetryDelayMs ?? STEP_LOG_LIVE_REFETCH_MS;
57
+ const missingStreamRetryDelayMs = options.missingStreamRetryDelayMs ?? STEP_LOG_LIVE_REFETCH_MS;
58
+ return useQuery({
59
+ queryKey,
60
+ enabled,
61
+ queryFn: async ({ signal })=>{
62
+ const previous = queryClient.getQueryData(queryKey);
63
+ let response;
64
+ try {
65
+ response = await readStepAttemptLogsPage({
66
+ stepId: stepId ?? '',
67
+ attempt: attempt ?? 0,
68
+ cursor: previous?.nextCursor ?? 0,
69
+ signal
70
+ });
71
+ } catch (error) {
72
+ if (options.retryMissingStream && previous === undefined && isMissingStepLogStreamError(error)) {
73
+ const retryCount = options.missingStreamRetryCount;
74
+ if (retryCount === undefined) throw error;
75
+ if (missingStreamFailureCountRef.current >= retryCount) {
76
+ return emptyCompleteLogSnapshot();
77
+ }
78
+ missingStreamFailureCountRef.current += 1;
79
+ }
80
+ throw error;
81
+ }
82
+ missingStreamFailureCountRef.current = 0;
83
+ if (response.mode === 'presigned') {
84
+ const ndjson = await readPresignedLogObject(response.url, signal);
85
+ return mergeLogRead(previous, {
86
+ mode: 'presigned',
87
+ response,
88
+ ndjson
89
+ });
90
+ }
91
+ return mergeLogRead(previous, {
92
+ mode: 'inline',
93
+ response
94
+ });
95
+ },
96
+ retry: (failureCount, error)=>{
97
+ if (initialErrorRetryCount <= 0) return false;
98
+ if (queryClient.getQueryData(queryKey) !== undefined) return false;
99
+ if (options.retryMissingStream && isMissingStepLogStreamError(error)) return false;
100
+ return failureCount < initialErrorRetryCount;
101
+ },
102
+ retryDelay: initialErrorRetryDelayMs,
103
+ refetchInterval: (query)=>{
104
+ if (options.retryMissingStream && query.state.data === undefined && isMissingStepLogStreamError(query.state.error)) {
105
+ return missingStreamRetryDelayMs;
106
+ }
107
+ return stepLogRefetchInterval(query.state.data, query.state.status === 'error');
108
+ },
109
+ refetchIntervalInBackground: false,
110
+ refetchOnMount: (query)=>!query.state.data?.complete,
111
+ refetchOnWindowFocus: (query)=>!query.state.data?.complete,
112
+ refetchOnReconnect: (query)=>!query.state.data?.complete
113
+ });
114
+ }
115
+ function emptyCompleteLogSnapshot() {
116
+ return {
117
+ records: [],
118
+ nextCursor: 0,
119
+ source: 'inline',
120
+ state: 'closed',
121
+ complete: true,
122
+ hasMore: false,
123
+ truncated: false,
124
+ totalBytes: null,
125
+ expiresAt: null
126
+ };
127
+ }
128
+
129
+ //# sourceMappingURL=step-logs.js.map