@gajae-code/coding-agent 0.11.4 → 0.11.5
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/CHANGELOG.md +23 -0
- package/dist/types/cli/notify-cli.d.ts +5 -1
- package/dist/types/config/settings-schema.d.ts +20 -0
- package/dist/types/export/html/template.generated.d.ts +1 -1
- package/dist/types/gjc-runtime/deep-interview-state.d.ts +28 -0
- package/dist/types/gjc-runtime/launch-tmux.d.ts +3 -0
- package/dist/types/gjc-runtime/managed-owner-admission.d.ts +42 -0
- package/dist/types/gjc-runtime/managed-owner-supervisor.d.ts +41 -0
- package/dist/types/gjc-runtime/tmux-owner-isolation.d.ts +9 -0
- package/dist/types/gjc-runtime/ultragoal-owner-loss-recovery.d.ts +94 -0
- package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +1 -0
- package/dist/types/harness-control-plane/owner.d.ts +3 -1
- package/dist/types/internal-urls/local-protocol.d.ts +18 -0
- package/dist/types/main.d.ts +2 -2
- package/dist/types/modes/components/ansi-display-validator.d.ts +2 -0
- package/dist/types/modes/components/notifications-settings-editor.d.ts +4 -0
- package/dist/types/modes/components/session-observer-overlay.d.ts +3 -1
- package/dist/types/modes/components/settings-selector.d.ts +2 -0
- package/dist/types/modes/components/tool-transcript-format.d.ts +58 -0
- package/dist/types/modes/components/transcript-viewer-overlay.d.ts +3 -0
- package/dist/types/modes/controllers/goal-mode-controller.d.ts +8 -1
- package/dist/types/modes/controllers/selector-controller.d.ts +15 -0
- package/dist/types/modes/shared/agent-wire/workflow-gate-broker.d.ts +3 -0
- package/dist/types/modes/shared/agent-wire/workflow-gate-types.d.ts +16 -0
- package/dist/types/plan-mode/approved-plan.d.ts +1 -0
- package/dist/types/runtime-mcp/manager.d.ts +2 -0
- package/dist/types/runtime-mcp/oauth-discovery.d.ts +12 -1
- package/dist/types/sdk/bus/chat-daemon-cli.d.ts +3 -1
- package/dist/types/sdk/bus/chat-daemon-control.d.ts +39 -4
- package/dist/types/sdk/bus/config-commands.d.ts +8 -0
- package/dist/types/sdk/bus/config.d.ts +22 -0
- package/dist/types/sdk/bus/index.d.ts +162 -2
- package/dist/types/sdk/bus/notification-service.d.ts +71 -8
- package/dist/types/sdk/bus/session-control.d.ts +11 -8
- package/dist/types/sdk/bus/telegram-daemon-cli.d.ts +5 -0
- package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +6 -3
- package/dist/types/sdk/bus/telegram-daemon-control.d.ts +23 -4
- package/dist/types/sdk/bus/telegram-daemon.d.ts +214 -20
- package/dist/types/sdk/session.d.ts +16 -1
- package/dist/types/secrets/index.d.ts +4 -2
- package/dist/types/secrets/obfuscator.d.ts +1 -1
- package/dist/types/session/agent-session.d.ts +31 -2
- package/dist/types/session/artifacts.d.ts +30 -8
- package/dist/types/session/internal/managed-session-scope.d.ts +7 -0
- package/dist/types/session/internal/managed-session-storage.d.ts +63 -6
- package/dist/types/session/session-manager.d.ts +88 -18
- package/dist/types/session/session-storage.d.ts +27 -0
- package/dist/types/session/streaming-output.d.ts +4 -0
- package/dist/types/task/executor.d.ts +9 -0
- package/dist/types/tool-discovery/tool-index.d.ts +2 -0
- package/dist/types/tools/ask.d.ts +48 -0
- package/dist/types/tools/bash-skill-urls.d.ts +2 -2
- package/dist/types/tools/index.d.ts +4 -0
- package/package.json +9 -9
- package/scripts/generate-sdk-operation-inventory.ts +2 -0
- package/src/cli/notify-cli.ts +40 -18
- package/src/commands/sdk.ts +7 -2
- package/src/config/settings-schema.ts +17 -0
- package/src/daemon/runtime.ts +2 -2
- package/src/defaults/gjc/skills/deep-interview/SKILL.md +70 -49
- package/src/defaults/gjc/skills/ralplan/SKILL.md +0 -6
- package/src/defaults/gjc/skills/team/SKILL.md +7 -9
- package/src/defaults/gjc/skills/ultragoal/SKILL.md +0 -2
- package/src/eval/py/index.ts +2 -2
- package/src/export/html/template.generated.ts +1 -1
- package/src/extensibility/plugins/marketplace/manager.ts +94 -10
- package/src/gjc-runtime/deep-interview-recorder.ts +7 -0
- package/src/gjc-runtime/deep-interview-runtime.ts +10 -0
- package/src/gjc-runtime/deep-interview-state.ts +127 -3
- package/src/gjc-runtime/launch-tmux.ts +61 -6
- package/src/gjc-runtime/managed-owner-admission.ts +352 -0
- package/src/gjc-runtime/managed-owner-supervisor.ts +162 -0
- package/src/gjc-runtime/state-runtime.ts +38 -12
- package/src/gjc-runtime/team-runtime.ts +1 -1
- package/src/gjc-runtime/tmux-owner-isolation.ts +117 -0
- package/src/gjc-runtime/ultragoal-owner-loss-recovery.ts +328 -0
- package/src/gjc-runtime/ultragoal-runtime.ts +14 -0
- package/src/harness-control-plane/owner.ts +59 -3
- package/src/hindsight/client.ts +65 -2
- package/src/internal-urls/agent-protocol.ts +76 -5
- package/src/internal-urls/docs-index.generated.ts +7 -6
- package/src/internal-urls/local-protocol.ts +416 -18
- package/src/lsp/index.ts +4 -88
- package/src/main.ts +37 -13
- package/src/modes/acp/acp-agent.ts +8 -0
- package/src/modes/components/ansi-display-validator.ts +54 -0
- package/src/modes/components/notifications-settings-editor.ts +26 -2
- package/src/modes/components/session-observer-overlay.ts +40 -24
- package/src/modes/components/settings-selector.ts +3 -1
- package/src/modes/components/tool-transcript-format.ts +321 -0
- package/src/modes/components/transcript-viewer-overlay.ts +39 -17
- package/src/modes/controllers/goal-mode-controller.ts +20 -1
- package/src/modes/controllers/plan-mode-controller.ts +2 -0
- package/src/modes/controllers/runtime-mcp-command-controller.ts +3 -0
- package/src/modes/controllers/selector-controller.ts +70 -8
- package/src/modes/interactive-mode.ts +1 -0
- package/src/modes/shared/agent-wire/deep-interview-gate.ts +31 -1
- package/src/modes/shared/agent-wire/workflow-gate-broker.ts +17 -1
- package/src/modes/shared/agent-wire/workflow-gate-schema.ts +4 -2
- package/src/modes/shared/agent-wire/workflow-gate-types.ts +120 -3
- package/src/plan-mode/approved-plan.ts +3 -1
- package/src/prompts/agents/architect.md +1 -0
- package/src/prompts/agents/critic.md +1 -0
- package/src/prompts/system/custom-system-prompt.md +1 -1
- package/src/prompts/system/system-prompt.md +2 -1
- package/src/prompts/tools/lsp.md +3 -3
- package/src/runtime-mcp/manager.ts +16 -1
- package/src/runtime-mcp/oauth-discovery.ts +116 -26
- package/src/sdk/broker/session-index.ts +122 -64
- package/src/sdk/bus/chat-daemon-cli.ts +72 -10
- package/src/sdk/bus/chat-daemon-control.ts +526 -130
- package/src/sdk/bus/chat-daemon-runtime.ts +1 -0
- package/src/sdk/bus/config-commands.ts +22 -0
- package/src/sdk/bus/config.ts +37 -0
- package/src/sdk/bus/index.ts +384 -92
- package/src/sdk/bus/lifecycle-control-runtime.ts +53 -3
- package/src/sdk/bus/notification-service.ts +284 -62
- package/src/sdk/bus/recent-activity.ts +14 -5
- package/src/sdk/bus/session-control.ts +150 -44
- package/src/sdk/bus/telegram-daemon-cli.ts +20 -14
- package/src/sdk/bus/telegram-daemon-contract.ts +6 -3
- package/src/sdk/bus/telegram-daemon-control.ts +294 -76
- package/src/sdk/bus/telegram-daemon.ts +1788 -267
- package/src/sdk/protocol/operation-inventory.generated.json +11 -0
- package/src/sdk/session.ts +155 -27
- package/src/secrets/index.ts +53 -10
- package/src/secrets/obfuscator.ts +21 -18
- package/src/secrets/regex.ts +2 -0
- package/src/session/agent-session.ts +495 -253
- package/src/session/artifacts.ts +188 -23
- package/src/session/context-estimation.ts +7 -2
- package/src/session/internal/managed-session-scope.ts +200 -38
- package/src/session/internal/managed-session-storage.ts +1152 -46
- package/src/session/session-manager.ts +1055 -148
- package/src/session/session-storage.ts +109 -11
- package/src/session/streaming-output.ts +6 -0
- package/src/task/executor.ts +71 -26
- package/src/task/index.ts +63 -20
- package/src/tool-discovery/tool-index.ts +18 -0
- package/src/tools/ask.ts +129 -32
- package/src/tools/bash-skill-urls.ts +14 -3
- package/src/tools/bash.ts +2 -2
- package/src/tools/index.ts +4 -0
- package/src/tools/plan-mode-guard.ts +1 -0
- package/src/tools/sqlite-reader.ts +3 -2
- package/src/tools/subagent.ts +2 -0
|
@@ -11,6 +11,7 @@ import * as fsSync from "node:fs";
|
|
|
11
11
|
import * as fs from "node:fs/promises";
|
|
12
12
|
|
|
13
13
|
import * as path from "node:path";
|
|
14
|
+
import { openRecoveryFsRoot } from "@gajae-code/natives";
|
|
14
15
|
import { isCompiledBinary } from "@gajae-code/utils/env";
|
|
15
16
|
import { parseLinuxProcStartTime } from "./linux-proc";
|
|
16
17
|
|
|
@@ -1046,6 +1047,122 @@ function sameOwnerGenerationBaseline(left: OwnerGenerationBaseline, right: Owner
|
|
|
1046
1047
|
);
|
|
1047
1048
|
}
|
|
1048
1049
|
|
|
1050
|
+
export interface ManagedOwnerPredecessorEvidence {
|
|
1051
|
+
generation: string;
|
|
1052
|
+
sessionId: string;
|
|
1053
|
+
runId: string;
|
|
1054
|
+
incarnation: string;
|
|
1055
|
+
predecessorToken: string;
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
function exactManagedOwnerJson(authority: ReturnType<typeof openRecoveryFsRoot>, name: string): unknown {
|
|
1059
|
+
const first = authority.read(name, 64 * 1024);
|
|
1060
|
+
if (!first.ok || !first.data) throw new Error("managed_owner_replacement_evidence_unavailable");
|
|
1061
|
+
const second = authority.read(name, 64 * 1024);
|
|
1062
|
+
if (
|
|
1063
|
+
!second.ok ||
|
|
1064
|
+
!second.data ||
|
|
1065
|
+
Buffer.compare(Buffer.from(first.data), Buffer.from(second.data)) !== 0 ||
|
|
1066
|
+
JSON.stringify(first.identity) !== JSON.stringify(second.identity)
|
|
1067
|
+
)
|
|
1068
|
+
throw new Error("managed_owner_replacement_evidence_changed");
|
|
1069
|
+
const content = Buffer.from(first.data).toString("utf8");
|
|
1070
|
+
if (!content.endsWith("\n") || content.indexOf("\n") !== content.length - 1)
|
|
1071
|
+
throw new Error("managed_owner_replacement_evidence_untrusted");
|
|
1072
|
+
return JSON.parse(content);
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
/** Resolve one SIGABRT predecessor from the retained exact prior-generation root. */
|
|
1076
|
+
export function resolveManagedOwnerPredecessorSync(
|
|
1077
|
+
stateDir: string,
|
|
1078
|
+
sessionId: string,
|
|
1079
|
+
baseline: OwnerGenerationBaseline,
|
|
1080
|
+
): ManagedOwnerPredecessorEvidence | undefined {
|
|
1081
|
+
if (baseline.state === "absent") return undefined;
|
|
1082
|
+
const root = lifecyclePaths(stateDir, sessionId, baseline.generation).root;
|
|
1083
|
+
let entries: string[];
|
|
1084
|
+
try {
|
|
1085
|
+
entries = fsSync.readdirSync(root);
|
|
1086
|
+
} catch {
|
|
1087
|
+
throw new Error("managed_owner_replacement_evidence_unavailable");
|
|
1088
|
+
}
|
|
1089
|
+
const bindings = new Set<string>();
|
|
1090
|
+
const receipts = new Set<string>();
|
|
1091
|
+
for (const entry of entries) {
|
|
1092
|
+
const binding = /^child-([^/]+)\.binding\.json$/.exec(entry);
|
|
1093
|
+
const receipt = /^sigabrt-([^/]+)\.receipt\.json$/.exec(entry);
|
|
1094
|
+
if (entry.startsWith("child-") && !binding) throw new Error("managed_owner_replacement_evidence_untrusted");
|
|
1095
|
+
if (entry.startsWith("sigabrt-") && !receipt) throw new Error("managed_owner_replacement_evidence_untrusted");
|
|
1096
|
+
if (binding) bindings.add(binding[1]!);
|
|
1097
|
+
if (receipt) receipts.add(receipt[1]!);
|
|
1098
|
+
}
|
|
1099
|
+
if (bindings.size === 0 && receipts.size === 0) return undefined;
|
|
1100
|
+
const tokens = [...bindings].filter(token => receipts.has(token));
|
|
1101
|
+
if (tokens.length !== 1 || receipts.size !== 1) throw new Error("managed_owner_replacement_evidence_ambiguous");
|
|
1102
|
+
const predecessorToken = tokens[0]!;
|
|
1103
|
+
if (!/^[A-Za-z0-9._-]+$/.test(predecessorToken)) throw new Error("managed_owner_replacement_evidence_untrusted");
|
|
1104
|
+
const authority = openRecoveryFsRoot(root);
|
|
1105
|
+
try {
|
|
1106
|
+
const binding = exactManagedOwnerJson(authority, `child-${predecessorToken}.binding.json`) as Record<
|
|
1107
|
+
string,
|
|
1108
|
+
unknown
|
|
1109
|
+
>;
|
|
1110
|
+
const receipt = exactManagedOwnerJson(authority, `sigabrt-${predecessorToken}.receipt.json`) as Record<
|
|
1111
|
+
string,
|
|
1112
|
+
unknown
|
|
1113
|
+
>;
|
|
1114
|
+
const command = binding.command;
|
|
1115
|
+
const runId = binding.run_id;
|
|
1116
|
+
const incarnation = binding.endpoint_incarnation;
|
|
1117
|
+
const commandDigest =
|
|
1118
|
+
Array.isArray(command) && command.length > 0 && command.every(value => typeof value === "string" && value)
|
|
1119
|
+
? crypto.createHash("sha256").update(JSON.stringify(command)).digest("hex")
|
|
1120
|
+
: "";
|
|
1121
|
+
const valid =
|
|
1122
|
+
binding.schema_version === 2 &&
|
|
1123
|
+
binding.generation === baseline.generation &&
|
|
1124
|
+
binding.session_id === sessionId &&
|
|
1125
|
+
typeof runId === "string" &&
|
|
1126
|
+
runId.length > 0 &&
|
|
1127
|
+
typeof incarnation === "string" &&
|
|
1128
|
+
incarnation.length > 0 &&
|
|
1129
|
+
binding.child_token === predecessorToken &&
|
|
1130
|
+
binding.command_sha256 === commandDigest &&
|
|
1131
|
+
typeof binding.supervisor_pid === "number" &&
|
|
1132
|
+
Number.isSafeInteger(binding.supervisor_pid) &&
|
|
1133
|
+
binding.supervisor_pid > 0 &&
|
|
1134
|
+
typeof binding.supervisor_start_time === "string" &&
|
|
1135
|
+
typeof binding.created_at === "string" &&
|
|
1136
|
+
receipt.schema_version === 2 &&
|
|
1137
|
+
receipt.generation === binding.generation &&
|
|
1138
|
+
receipt.session_id === binding.session_id &&
|
|
1139
|
+
receipt.run_id === runId &&
|
|
1140
|
+
receipt.endpoint_incarnation === incarnation &&
|
|
1141
|
+
receipt.child_token === binding.child_token &&
|
|
1142
|
+
receipt.command_sha256 === binding.command_sha256 &&
|
|
1143
|
+
receipt.supervisor_pid === binding.supervisor_pid &&
|
|
1144
|
+
receipt.supervisor_start_time === binding.supervisor_start_time &&
|
|
1145
|
+
typeof receipt.child_pid === "number" &&
|
|
1146
|
+
Number.isSafeInteger(receipt.child_pid) &&
|
|
1147
|
+
receipt.child_pid > 0 &&
|
|
1148
|
+
typeof receipt.child_start_time === "string" &&
|
|
1149
|
+
typeof receipt.received_at === "string" &&
|
|
1150
|
+
(receipt.exit_code === null || Number.isSafeInteger(receipt.exit_code)) &&
|
|
1151
|
+
receipt.signal === "SIGABRT" &&
|
|
1152
|
+
receipt.signal_number === 6;
|
|
1153
|
+
if (!valid) throw new Error("managed_owner_replacement_evidence_untrusted");
|
|
1154
|
+
return {
|
|
1155
|
+
generation: baseline.generation,
|
|
1156
|
+
sessionId,
|
|
1157
|
+
runId,
|
|
1158
|
+
incarnation,
|
|
1159
|
+
predecessorToken,
|
|
1160
|
+
};
|
|
1161
|
+
} finally {
|
|
1162
|
+
authority.close();
|
|
1163
|
+
}
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1049
1166
|
export async function captureOwnerGenerationBaseline(
|
|
1050
1167
|
stateDir: string,
|
|
1051
1168
|
sessionId: string,
|
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import * as fs from "node:fs/promises";
|
|
3
|
+
import * as path from "node:path";
|
|
4
|
+
import { openRecoveryFsRoot } from "@gajae-code/natives";
|
|
5
|
+
import type { ManagedOwnerSigabrtReceipt } from "./managed-owner-supervisor";
|
|
6
|
+
import { sessionStateDir, sessionUltragoalDir } from "./session-layout";
|
|
7
|
+
import { appendJsonlIdempotent, writeJsonAtomic } from "./state-writer";
|
|
8
|
+
|
|
9
|
+
/** Immutable identity supplied by the owner-loss monitor and coordinator admission. */
|
|
10
|
+
export interface UltragoalRecoveryBinding {
|
|
11
|
+
sessionId: string;
|
|
12
|
+
endpointIncarnation: string;
|
|
13
|
+
ownerGeneration: string;
|
|
14
|
+
cwd: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface UltragoalOwnerLossReceipt {
|
|
18
|
+
schema_version: 1;
|
|
19
|
+
session_id: string;
|
|
20
|
+
generation: string;
|
|
21
|
+
classification: "unexpected_owner_loss";
|
|
22
|
+
result: "signal" | "exit" | "unknown_terminal";
|
|
23
|
+
observed_at: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export type AuthoritativeOwnerLossReceipt = UltragoalOwnerLossReceipt | ManagedOwnerSigabrtReceipt;
|
|
27
|
+
|
|
28
|
+
export interface UltragoalRecoverySnapshot {
|
|
29
|
+
/** B0 is captured once before recovery writes. P is protected and never rewritten. */
|
|
30
|
+
b0: { planSha256: string; ledgerSha256: string; capturedAt: string };
|
|
31
|
+
protectedPaths: string[];
|
|
32
|
+
/** Exact sanctioned deltas, never inferred from a worktree scan. */
|
|
33
|
+
sanctionedDeltas: string[];
|
|
34
|
+
/** Durable baseline-absent artifacts. */
|
|
35
|
+
absentArtifacts: string[];
|
|
36
|
+
/** Transient paths deliberately excluded from provenance. */
|
|
37
|
+
transientHistory: string[];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export type UltragoalRecoveryDisposition = "resume" | "handoff";
|
|
41
|
+
export interface UltragoalRecoveryDecision {
|
|
42
|
+
disposition: UltragoalRecoveryDisposition;
|
|
43
|
+
reason: string;
|
|
44
|
+
snapshot?: UltragoalRecoverySnapshot;
|
|
45
|
+
terminal?: { yieldId: string; result: Record<string, unknown> };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
interface TranscriptYield {
|
|
49
|
+
type: "yield";
|
|
50
|
+
id: string;
|
|
51
|
+
parentId: string | null;
|
|
52
|
+
result: Record<string, unknown>;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function nonEmpty(value: unknown): value is string {
|
|
56
|
+
return typeof value === "string" && value.trim().length > 0;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
60
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function exactChild(
|
|
64
|
+
entry: Record<string, unknown>,
|
|
65
|
+
parentId: string | null,
|
|
66
|
+
): entry is Record<string, unknown> & { id: string } {
|
|
67
|
+
return entry.parentId === parentId && nonEmpty(entry.id);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function digest(bytes: Uint8Array): string {
|
|
71
|
+
return createHash("sha256").update(bytes).digest("hex");
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function safeRelativePath(root: string, candidate: string): string | null {
|
|
75
|
+
const relative = path.relative(root, candidate);
|
|
76
|
+
if (!relative || relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) return null;
|
|
77
|
+
return relative;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function bytesOrAbsent(filePath: string): Promise<Uint8Array | null> {
|
|
81
|
+
try {
|
|
82
|
+
return await fs.readFile(filePath);
|
|
83
|
+
} catch (error) {
|
|
84
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
|
|
85
|
+
throw error;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Parse the complete transcript; partial rows, alternate terminal spellings, and
|
|
91
|
+
* duplicate/conflicting yields are intentionally invalid authority.
|
|
92
|
+
*/
|
|
93
|
+
export function parseStrictTerminalTranscript(content: string): TranscriptYield | null {
|
|
94
|
+
if (!content?.endsWith("\n")) return null;
|
|
95
|
+
const entries: Array<Record<string, unknown> & { id: string }> = [];
|
|
96
|
+
let parentId: string | null = null;
|
|
97
|
+
for (const line of content.slice(0, -1).split("\n")) {
|
|
98
|
+
if (!line) return null;
|
|
99
|
+
let parsed: unknown;
|
|
100
|
+
try {
|
|
101
|
+
parsed = JSON.parse(line);
|
|
102
|
+
} catch {
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
if (!isRecord(parsed) || !nonEmpty(parsed.type) || !exactChild(parsed, parentId)) return null;
|
|
106
|
+
if (
|
|
107
|
+
parsed.type !== "yield" &&
|
|
108
|
+
parsed.type !== "toolResult" &&
|
|
109
|
+
parsed.type !== "tool_call" &&
|
|
110
|
+
parsed.type !== "message" &&
|
|
111
|
+
parsed.type !== "mode_change"
|
|
112
|
+
)
|
|
113
|
+
return null;
|
|
114
|
+
entries.push(parsed);
|
|
115
|
+
parentId = parsed.id;
|
|
116
|
+
}
|
|
117
|
+
if (entries.length < 2) return null;
|
|
118
|
+
if (entries.filter(entry => entry.type === "yield").length !== 1) return null;
|
|
119
|
+
const yieldEntry = entries[entries.length - 2]!;
|
|
120
|
+
const resultEntry = entries[entries.length - 1]!;
|
|
121
|
+
if (yieldEntry.type !== "yield" || !isRecord(yieldEntry.result) || resultEntry.type !== "toolResult") return null;
|
|
122
|
+
if (resultEntry.toolCallId !== yieldEntry.id || !Array.isArray(resultEntry.content)) return null;
|
|
123
|
+
return {
|
|
124
|
+
type: "yield",
|
|
125
|
+
id: yieldEntry.id,
|
|
126
|
+
parentId: yieldEntry.parentId as string | null,
|
|
127
|
+
result: yieldEntry.result,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Read through a retained root descriptor; pathname resolution never authorizes recovery input. */
|
|
132
|
+
async function readRecoveryFile(root: string, candidate: string): Promise<string | null> {
|
|
133
|
+
if (process.platform !== "linux") return null;
|
|
134
|
+
const relative = safeRelativePath(path.resolve(root), path.resolve(candidate));
|
|
135
|
+
if (!relative) return null;
|
|
136
|
+
try {
|
|
137
|
+
const authority = openRecoveryFsRoot(root);
|
|
138
|
+
try {
|
|
139
|
+
const result = authority.read(relative, 4 * 1024 * 1024);
|
|
140
|
+
return result.ok && result.data ? new TextDecoder().decode(result.data) : null;
|
|
141
|
+
} finally {
|
|
142
|
+
authority.close();
|
|
143
|
+
}
|
|
144
|
+
} catch {
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Validate a path through a retained native root descriptor; path strings alone are never authority. */
|
|
150
|
+
export async function validateRecoveryPath(root: string, candidate: string): Promise<string | null> {
|
|
151
|
+
return (await readRecoveryFile(root, candidate)) === null ? null : candidate;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export function validateOwnerLossBinding(
|
|
155
|
+
binding: UltragoalRecoveryBinding,
|
|
156
|
+
receipt: unknown,
|
|
157
|
+
): receipt is AuthoritativeOwnerLossReceipt {
|
|
158
|
+
if (!isRecord(receipt) || receipt.session_id !== binding.sessionId || receipt.generation !== binding.ownerGeneration)
|
|
159
|
+
return false;
|
|
160
|
+
if (receipt.schema_version === 2)
|
|
161
|
+
return (
|
|
162
|
+
receipt.signal === "SIGABRT" &&
|
|
163
|
+
receipt.signal_number === 6 &&
|
|
164
|
+
nonEmpty(receipt.run_id) &&
|
|
165
|
+
receipt.endpoint_incarnation === binding.endpointIncarnation &&
|
|
166
|
+
nonEmpty(receipt.child_token) &&
|
|
167
|
+
nonEmpty(receipt.command_sha256) &&
|
|
168
|
+
typeof receipt.supervisor_pid === "number" &&
|
|
169
|
+
Number.isSafeInteger(receipt.supervisor_pid) &&
|
|
170
|
+
receipt.supervisor_pid > 0 &&
|
|
171
|
+
typeof receipt.child_pid === "number" &&
|
|
172
|
+
Number.isSafeInteger(receipt.child_pid) &&
|
|
173
|
+
receipt.child_pid > 0 &&
|
|
174
|
+
nonEmpty(receipt.supervisor_start_time) &&
|
|
175
|
+
nonEmpty(receipt.child_start_time) &&
|
|
176
|
+
(receipt.exit_code === null || Number.isSafeInteger(receipt.exit_code)) &&
|
|
177
|
+
nonEmpty(receipt.received_at) &&
|
|
178
|
+
Number.isFinite(Date.parse(receipt.received_at))
|
|
179
|
+
);
|
|
180
|
+
return false;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** Admission is valid only for this immutable coordinator endpoint incarnation. */
|
|
184
|
+
export function validateRecoveryAdmission(binding: UltragoalRecoveryBinding, admission: unknown): boolean {
|
|
185
|
+
if (!isRecord(admission)) return false;
|
|
186
|
+
return (
|
|
187
|
+
admission.session_id === binding.sessionId &&
|
|
188
|
+
admission.endpoint_incarnation === binding.endpointIncarnation &&
|
|
189
|
+
admission.owner_generation === binding.ownerGeneration &&
|
|
190
|
+
admission.admitted === true
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** Raw plan and ledger evidence must be complete before their B0 hashes are trusted. */
|
|
195
|
+
export function validateRawUltragoalEvidence(plan: Uint8Array, ledger: Uint8Array): boolean {
|
|
196
|
+
try {
|
|
197
|
+
const parsedPlan: unknown = JSON.parse(new TextDecoder().decode(plan));
|
|
198
|
+
if (!isRecord(parsedPlan) || !Array.isArray(parsedPlan.goals)) return false;
|
|
199
|
+
const content = new TextDecoder().decode(ledger);
|
|
200
|
+
if (!content.endsWith("\n")) return false;
|
|
201
|
+
for (const line of content.slice(0, -1).split("\n")) {
|
|
202
|
+
if (!line || !isRecord(JSON.parse(line))) return false;
|
|
203
|
+
}
|
|
204
|
+
return true;
|
|
205
|
+
} catch {
|
|
206
|
+
return false;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** Build a B0 snapshot without consulting unrelated paths or mutable sidecars. */
|
|
211
|
+
export async function captureUltragoalRecoverySnapshot(input: {
|
|
212
|
+
cwd: string;
|
|
213
|
+
sessionId: string;
|
|
214
|
+
protectedPaths: readonly string[];
|
|
215
|
+
sanctionedDeltas: readonly string[];
|
|
216
|
+
absentArtifacts: readonly string[];
|
|
217
|
+
transientHistory: readonly string[];
|
|
218
|
+
}): Promise<UltragoalRecoverySnapshot | null> {
|
|
219
|
+
const root = sessionUltragoalDir(input.cwd, input.sessionId);
|
|
220
|
+
const [plan, ledger] = await Promise.all([
|
|
221
|
+
bytesOrAbsent(path.join(root, "goals.json")),
|
|
222
|
+
bytesOrAbsent(path.join(root, "ledger.jsonl")),
|
|
223
|
+
]);
|
|
224
|
+
if (!plan || !ledger || !validateRawUltragoalEvidence(plan, ledger)) return null;
|
|
225
|
+
const allPaths = [
|
|
226
|
+
...input.protectedPaths,
|
|
227
|
+
...input.sanctionedDeltas,
|
|
228
|
+
...input.absentArtifacts,
|
|
229
|
+
...input.transientHistory,
|
|
230
|
+
];
|
|
231
|
+
if (allPaths.some(value => !nonEmpty(value) || path.isAbsolute(value) || value.split(/[\\/]/).includes("..")))
|
|
232
|
+
return null;
|
|
233
|
+
const sets = [input.protectedPaths, input.sanctionedDeltas, input.absentArtifacts, input.transientHistory].map(
|
|
234
|
+
values => new Set(values),
|
|
235
|
+
);
|
|
236
|
+
if (
|
|
237
|
+
sets.some((set, index) =>
|
|
238
|
+
sets.some((other, otherIndex) => otherIndex > index && [...set].some(value => other.has(value))),
|
|
239
|
+
)
|
|
240
|
+
)
|
|
241
|
+
return null;
|
|
242
|
+
return {
|
|
243
|
+
b0: { planSha256: digest(plan), ledgerSha256: digest(ledger), capturedAt: new Date().toISOString() },
|
|
244
|
+
protectedPaths: [...input.protectedPaths],
|
|
245
|
+
sanctionedDeltas: [...input.sanctionedDeltas],
|
|
246
|
+
absentArtifacts: [...input.absentArtifacts],
|
|
247
|
+
transientHistory: [...input.transientHistory],
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Decide only from direct owner evidence plus a complete terminal transcript.
|
|
253
|
+
* All ambiguity maps to durable handoff; callers never guess a resume.
|
|
254
|
+
*/
|
|
255
|
+
export async function planUltragoalOwnerLossRecovery(input: {
|
|
256
|
+
binding: UltragoalRecoveryBinding;
|
|
257
|
+
receipt: unknown;
|
|
258
|
+
admission: unknown;
|
|
259
|
+
|
|
260
|
+
transcriptPath: string;
|
|
261
|
+
protectedPaths?: readonly string[];
|
|
262
|
+
sanctionedDeltas?: readonly string[];
|
|
263
|
+
absentArtifacts?: readonly string[];
|
|
264
|
+
transientHistory?: readonly string[];
|
|
265
|
+
}): Promise<UltragoalRecoveryDecision> {
|
|
266
|
+
if (!validateOwnerLossBinding(input.binding, input.receipt))
|
|
267
|
+
return { disposition: "handoff", reason: "owner_receipt_untrusted" };
|
|
268
|
+
const content = await readRecoveryFile(input.binding.cwd, input.transcriptPath);
|
|
269
|
+
if (!validateRecoveryAdmission(input.binding, input.admission))
|
|
270
|
+
return { disposition: "handoff", reason: "coordinator_admission_untrusted" };
|
|
271
|
+
if (content === null) return { disposition: "handoff", reason: "transcript_path_untrusted" };
|
|
272
|
+
const terminal = parseStrictTerminalTranscript(content);
|
|
273
|
+
if (!terminal) return { disposition: "handoff", reason: "terminal_transcript_missing_or_invalid" };
|
|
274
|
+
if (terminal.result.status !== "success") return { disposition: "handoff", reason: "terminal_aborted_errored" };
|
|
275
|
+
const snapshot = await captureUltragoalRecoverySnapshot({
|
|
276
|
+
cwd: input.binding.cwd,
|
|
277
|
+
sessionId: input.binding.sessionId,
|
|
278
|
+
protectedPaths: input.protectedPaths ?? ["goals.json", "ledger.jsonl"],
|
|
279
|
+
sanctionedDeltas: input.sanctionedDeltas ?? [],
|
|
280
|
+
absentArtifacts: input.absentArtifacts ?? [],
|
|
281
|
+
transientHistory: input.transientHistory ?? [],
|
|
282
|
+
});
|
|
283
|
+
if (!snapshot) return { disposition: "handoff", reason: "baseline_or_path_invariant_failed" };
|
|
284
|
+
return {
|
|
285
|
+
disposition: "resume",
|
|
286
|
+
reason: "terminal_transcript_authoritative",
|
|
287
|
+
snapshot,
|
|
288
|
+
terminal: { yieldId: terminal.id, result: terminal.result },
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/** Persist a monotonic replay journal and fail-closed handoff through sanctioned state writers. */
|
|
293
|
+
export async function persistUltragoalRecoveryDecision(input: {
|
|
294
|
+
cwd: string;
|
|
295
|
+
sessionId: string;
|
|
296
|
+
binding: UltragoalRecoveryBinding;
|
|
297
|
+
decision: UltragoalRecoveryDecision;
|
|
298
|
+
}): Promise<{ handoffPath: string; journalPath: string }> {
|
|
299
|
+
const stateDir = sessionStateDir(input.cwd, input.sessionId);
|
|
300
|
+
const handoffPath = path.join(stateDir, "ultragoal-owner-loss-recovery.json");
|
|
301
|
+
const journalPath = path.join(stateDir, "ultragoal-owner-loss-recovery.jsonl");
|
|
302
|
+
const record = {
|
|
303
|
+
schema_version: 1,
|
|
304
|
+
binding: input.binding,
|
|
305
|
+
disposition: input.decision.disposition,
|
|
306
|
+
reason: input.decision.reason,
|
|
307
|
+
snapshot: input.decision.snapshot ?? null,
|
|
308
|
+
terminal: input.decision.terminal ?? null,
|
|
309
|
+
created_at: new Date().toISOString(),
|
|
310
|
+
};
|
|
311
|
+
await writeJsonAtomic(handoffPath, record, {
|
|
312
|
+
cwd: input.cwd,
|
|
313
|
+
audit: { category: "state", verb: "write", owner: "gjc-runtime", sessionId: input.sessionId },
|
|
314
|
+
});
|
|
315
|
+
await appendJsonlIdempotent(
|
|
316
|
+
journalPath,
|
|
317
|
+
{ ...record, event: "owner_loss_recovery_decision" },
|
|
318
|
+
{
|
|
319
|
+
cwd: input.cwd,
|
|
320
|
+
audit: { category: "state", verb: "write", owner: "gjc-runtime", sessionId: input.sessionId },
|
|
321
|
+
key: entry => {
|
|
322
|
+
if (!isRecord(entry) || entry.event !== "owner_loss_recovery_decision") return undefined;
|
|
323
|
+
return `${entry.binding === undefined ? "" : JSON.stringify(entry.binding)}:${String(entry.disposition)}:${String(entry.reason)}`;
|
|
324
|
+
},
|
|
325
|
+
},
|
|
326
|
+
);
|
|
327
|
+
return { handoffPath, journalPath };
|
|
328
|
+
}
|
|
@@ -33,6 +33,20 @@ import {
|
|
|
33
33
|
writeGuardedJsonAtomic,
|
|
34
34
|
} from "./state-writer";
|
|
35
35
|
|
|
36
|
+
export {
|
|
37
|
+
captureUltragoalRecoverySnapshot,
|
|
38
|
+
parseStrictTerminalTranscript,
|
|
39
|
+
persistUltragoalRecoveryDecision,
|
|
40
|
+
planUltragoalOwnerLossRecovery,
|
|
41
|
+
type UltragoalOwnerLossReceipt,
|
|
42
|
+
type UltragoalRecoveryBinding,
|
|
43
|
+
type UltragoalRecoveryDecision,
|
|
44
|
+
type UltragoalRecoverySnapshot,
|
|
45
|
+
validateOwnerLossBinding,
|
|
46
|
+
validateRawUltragoalEvidence,
|
|
47
|
+
validateRecoveryAdmission,
|
|
48
|
+
validateRecoveryPath,
|
|
49
|
+
} from "./ultragoal-owner-loss-recovery";
|
|
36
50
|
export type UltragoalGjcGoalMode = "aggregate" | "per-story";
|
|
37
51
|
export type UltragoalGoalStatus =
|
|
38
52
|
| "pending"
|
|
@@ -115,6 +115,8 @@ export interface OwnerOptions {
|
|
|
115
115
|
controlServerFactory?: (socketPath: string, handler: EndpointHandler) => ControlServer;
|
|
116
116
|
/** Test seam for deterministic lease-release teardown failures. */
|
|
117
117
|
leaseRelease?: typeof releaseLease;
|
|
118
|
+
/** Test seam for deterministic lease-heartbeat behavior (e.g. renewal barriers). */
|
|
119
|
+
leaseHeartbeat?: typeof heartbeat;
|
|
118
120
|
/** Test seams for frame persistence failures. */
|
|
119
121
|
framePersistence?: {
|
|
120
122
|
appendEvent?: typeof appendEvent;
|
|
@@ -150,6 +152,11 @@ export class RuntimeOwner {
|
|
|
150
152
|
#leaseEpoch = 0;
|
|
151
153
|
#leaseHeld = false;
|
|
152
154
|
#heartbeatTimer: NodeJS.Timeout | null = null;
|
|
155
|
+
#heartbeatFenced = false;
|
|
156
|
+
// Every renewal the heartbeat interval starts is tracked here until it settles.
|
|
157
|
+
// Teardown drains the whole set so no already-started renewal can still be
|
|
158
|
+
// polling the lease mutation lock when releaseLease runs.
|
|
159
|
+
#heartbeatInFlight = new Set<Promise<unknown>>();
|
|
153
160
|
#socketPath: string;
|
|
154
161
|
#finalizeChecks?: FinalizeChecks;
|
|
155
162
|
#validationCommands?: ValidationCommandSpec[];
|
|
@@ -179,6 +186,7 @@ export class RuntimeOwner {
|
|
|
179
186
|
cleanupRetryMs: opts.cleanupRetryMs ?? DEFAULT_CLEANUP_RETRY_MS,
|
|
180
187
|
cleanupRetryLimit: opts.cleanupRetryLimit ?? Number.POSITIVE_INFINITY,
|
|
181
188
|
leaseRelease: opts.leaseRelease ?? releaseLease,
|
|
189
|
+
leaseHeartbeat: opts.leaseHeartbeat ?? heartbeat,
|
|
182
190
|
};
|
|
183
191
|
this.#finalizeChecks = opts.finalizeChecks;
|
|
184
192
|
this.#validationCommands = opts.validationCommands;
|
|
@@ -230,9 +238,23 @@ export class RuntimeOwner {
|
|
|
230
238
|
this.#leaseHeld = true;
|
|
231
239
|
this.#leaseEpoch = lease.leaseEpoch;
|
|
232
240
|
this.#heartbeatTimer = setInterval(() => {
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
241
|
+
// Once teardown fences the heartbeat we must never start a renewal again:
|
|
242
|
+
// a renewal here would contend the lease mutation lock with the release
|
|
243
|
+
// path and can starve it (lease_lock_timeout).
|
|
244
|
+
if (this.#heartbeatFenced) return;
|
|
245
|
+
const renewal = this.#opts
|
|
246
|
+
.leaseHeartbeat(root, sessionId, this.ownerId, this.#opts.ttlMs, this.#opts.clock)
|
|
247
|
+
.catch(err => {
|
|
248
|
+
// Self-stop if a legitimate dead-owner takeover revoked our lease.
|
|
249
|
+
if (err instanceof Error && err.message.includes("not_lease_holder")) void this.stop().catch(() => {});
|
|
250
|
+
});
|
|
251
|
+
// Track every in-flight renewal so teardown can drain them ALL before
|
|
252
|
+
// releasing the lease. setInterval can start a new renewal before the
|
|
253
|
+
// previous one settles, and lock acquisition is non-FIFO, so awaiting only
|
|
254
|
+
// the latest would leave an older renewal able to contend releaseLease.
|
|
255
|
+
this.#heartbeatInFlight.add(renewal);
|
|
256
|
+
void renewal.finally(() => {
|
|
257
|
+
this.#heartbeatInFlight.delete(renewal);
|
|
236
258
|
});
|
|
237
259
|
}, this.#opts.heartbeatMs);
|
|
238
260
|
this.#heartbeatTimer.unref?.();
|
|
@@ -876,6 +898,14 @@ export class RuntimeOwner {
|
|
|
876
898
|
}
|
|
877
899
|
if (!this.#serverClosed) throw new AggregateError(failures, "Runtime owner endpoint cleanup failed.");
|
|
878
900
|
|
|
901
|
+
// Transport and endpoint are verified closed, so we are committed to
|
|
902
|
+
// surrendering the lease. Stop, fence, and join the heartbeat first: an
|
|
903
|
+
// in-flight or scheduled renewal would otherwise contend the lease mutation
|
|
904
|
+
// lock with releaseLease and can starve it (lease_lock_timeout). Fencing
|
|
905
|
+
// before release preserves exact-owner fencing — a fenced owner never renews,
|
|
906
|
+
// so it can neither revive its own lease nor touch a successor's.
|
|
907
|
+
await this.#fenceHeartbeat();
|
|
908
|
+
|
|
879
909
|
if (this.#leaseHeld) {
|
|
880
910
|
try {
|
|
881
911
|
await this.#opts.leaseRelease(this.#opts.root, this.#opts.sessionId, this.ownerId);
|
|
@@ -892,12 +922,38 @@ export class RuntimeOwner {
|
|
|
892
922
|
}
|
|
893
923
|
if (this.#leaseHeld) throw new AggregateError(failures, "Runtime owner lease cleanup failed.");
|
|
894
924
|
|
|
925
|
+
// Heartbeat was already fenced before the lease release above; this is a
|
|
926
|
+
// defensive no-op that also covers any path that skipped the release stage.
|
|
895
927
|
if (this.#heartbeatTimer) {
|
|
896
928
|
clearInterval(this.#heartbeatTimer);
|
|
897
929
|
this.#heartbeatTimer = null;
|
|
898
930
|
}
|
|
899
931
|
if (failures.length > 0) throw new AggregateError(failures, "Runtime owner cleanup failed.");
|
|
900
932
|
}
|
|
933
|
+
|
|
934
|
+
/**
|
|
935
|
+
* Permanently stop the lease heartbeat and drain every in-flight renewal.
|
|
936
|
+
*
|
|
937
|
+
* Fencing is terminal: `#heartbeatFenced` stays set so no scheduled interval
|
|
938
|
+
* callback can start a new renewal after teardown begins. The interval is
|
|
939
|
+
* cleared, then EVERY renewal already started (there may be more than one, since
|
|
940
|
+
* `setInterval` does not wait for the async `heartbeat()` of a prior tick) is
|
|
941
|
+
* awaited so each releases the lease mutation lock before the release path
|
|
942
|
+
* acquires it. This eliminates heartbeat/release self-contention on the
|
|
943
|
+
* non-FIFO lock (lease_lock_timeout) without weakening exact-owner fencing.
|
|
944
|
+
*/
|
|
945
|
+
async #fenceHeartbeat(): Promise<void> {
|
|
946
|
+
this.#heartbeatFenced = true;
|
|
947
|
+
if (this.#heartbeatTimer) {
|
|
948
|
+
clearInterval(this.#heartbeatTimer);
|
|
949
|
+
this.#heartbeatTimer = null;
|
|
950
|
+
}
|
|
951
|
+
// Snapshot then drain: no new renewal can be added once fenced, so this
|
|
952
|
+
// settles every renewal that could still hold or be waiting on the lock.
|
|
953
|
+
const inFlight = [...this.#heartbeatInFlight];
|
|
954
|
+
this.#heartbeatInFlight.clear();
|
|
955
|
+
if (inFlight.length > 0) await Promise.allSettled(inFlight);
|
|
956
|
+
}
|
|
901
957
|
}
|
|
902
958
|
|
|
903
959
|
export interface ResolvedOwner {
|
package/src/hindsight/client.ts
CHANGED
|
@@ -12,6 +12,8 @@ import type { HindsightConfig } from "./config";
|
|
|
12
12
|
|
|
13
13
|
const USER_AGENT = "oh-my-gajae-code";
|
|
14
14
|
const DEFAULT_USER_AGENT = USER_AGENT;
|
|
15
|
+
const REQUEST_TIMEOUT_MS = 30_000;
|
|
16
|
+
const RESPONSE_MAX_BYTES = 8 * 1024 * 1024;
|
|
15
17
|
|
|
16
18
|
export type Budget = "low" | "mid" | "high" | string;
|
|
17
19
|
export type TagsMatch = "any" | "all" | "any_strict" | "all_strict";
|
|
@@ -210,6 +212,49 @@ interface RequestOptions {
|
|
|
210
212
|
allow404?: boolean;
|
|
211
213
|
}
|
|
212
214
|
|
|
215
|
+
class ResponseTooLargeError extends Error {}
|
|
216
|
+
|
|
217
|
+
async function readResponseText(response: Response): Promise<string> {
|
|
218
|
+
const contentLength = response.headers.get("content-length");
|
|
219
|
+
if (contentLength !== null) {
|
|
220
|
+
const declaredBytes = Number(contentLength);
|
|
221
|
+
if (Number.isFinite(declaredBytes) && declaredBytes > RESPONSE_MAX_BYTES) {
|
|
222
|
+
void response.body?.cancel().catch(() => undefined);
|
|
223
|
+
throw new ResponseTooLargeError();
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const reader = response.body?.getReader();
|
|
228
|
+
if (!reader) return "";
|
|
229
|
+
|
|
230
|
+
const chunks: Uint8Array[] = [];
|
|
231
|
+
let totalBytes = 0;
|
|
232
|
+
try {
|
|
233
|
+
while (true) {
|
|
234
|
+
const { done, value } = await reader.read();
|
|
235
|
+
if (done) break;
|
|
236
|
+
if (!value || value.byteLength === 0) continue;
|
|
237
|
+
|
|
238
|
+
totalBytes += value.byteLength;
|
|
239
|
+
if (totalBytes > RESPONSE_MAX_BYTES) {
|
|
240
|
+
void reader.cancel().catch(() => undefined);
|
|
241
|
+
throw new ResponseTooLargeError();
|
|
242
|
+
}
|
|
243
|
+
chunks.push(value);
|
|
244
|
+
}
|
|
245
|
+
} finally {
|
|
246
|
+
reader.releaseLock();
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const bytes = new Uint8Array(totalBytes);
|
|
250
|
+
let offset = 0;
|
|
251
|
+
for (const chunk of chunks) {
|
|
252
|
+
bytes.set(chunk, offset);
|
|
253
|
+
offset += chunk.byteLength;
|
|
254
|
+
}
|
|
255
|
+
return new TextDecoder().decode(bytes);
|
|
256
|
+
}
|
|
257
|
+
|
|
213
258
|
export class HindsightApi {
|
|
214
259
|
#baseUrl: string;
|
|
215
260
|
#headers: Record<string, string>;
|
|
@@ -489,7 +534,8 @@ export class HindsightApi {
|
|
|
489
534
|
if (qs) url += `?${qs}`;
|
|
490
535
|
}
|
|
491
536
|
|
|
492
|
-
const
|
|
537
|
+
const requestSignal = AbortSignal.timeout(REQUEST_TIMEOUT_MS);
|
|
538
|
+
const init: RequestInit = { method, headers: this.#headers, signal: requestSignal };
|
|
493
539
|
if (opts?.body !== undefined) {
|
|
494
540
|
init.body = JSON.stringify(pruneUndefined(opts.body));
|
|
495
541
|
}
|
|
@@ -498,6 +544,9 @@ export class HindsightApi {
|
|
|
498
544
|
try {
|
|
499
545
|
response = await fetch(url, init);
|
|
500
546
|
} catch (err) {
|
|
547
|
+
if (requestSignal.aborted) {
|
|
548
|
+
throw new HindsightError(`${operation} request failed: timed out`);
|
|
549
|
+
}
|
|
501
550
|
throw new HindsightError(
|
|
502
551
|
`${operation} request failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
503
552
|
undefined,
|
|
@@ -506,10 +555,24 @@ export class HindsightApi {
|
|
|
506
555
|
}
|
|
507
556
|
|
|
508
557
|
if (opts?.allow404 && response.status === 404) {
|
|
558
|
+
await response.body?.cancel().catch(() => undefined);
|
|
509
559
|
return null as T;
|
|
510
560
|
}
|
|
511
561
|
|
|
512
|
-
|
|
562
|
+
let text: string;
|
|
563
|
+
try {
|
|
564
|
+
text = await readResponseText(response);
|
|
565
|
+
} catch (err) {
|
|
566
|
+
const reason = requestSignal.aborted
|
|
567
|
+
? "timed out"
|
|
568
|
+
: err instanceof ResponseTooLargeError
|
|
569
|
+
? "response exceeded size limit"
|
|
570
|
+
: "response could not be read";
|
|
571
|
+
throw new HindsightError(`${operation} request failed: ${reason}`, response.status);
|
|
572
|
+
}
|
|
573
|
+
if (requestSignal.aborted) {
|
|
574
|
+
throw new HindsightError(`${operation} request failed: timed out`, response.status);
|
|
575
|
+
}
|
|
513
576
|
const parsed = text ? safeJsonParse(text) : null;
|
|
514
577
|
|
|
515
578
|
if (!response.ok) {
|