| author | |
| committer | |
| log | 311da51db22ffc7e759634b785b8327b5d075fd8 |
| tree | 65e0f14106b0b1bbf727828cf3467948872abfb6 |
| parent | 6f7fee3541b9ef1fcedd996d055052a4be440bbb |
| signature | Signed by SSH key SHA256:cOKiuRFOeSRxne6EWgHtdQQSlBxjOXm2hOCFnCdLQbQ (~clover) |
Clover wants long-running scripts to show their progress in the chat instead of
leaving it buried in command output. Capture existing @clo/lib/progress copies
without requiring script changes or a library upgrade.
Preload a synchronous Node module hook through thread-scoped command
environments. Observe each loaded progress module and forward its stable event
protocol to a bounded loopback collector. Preserve existing Node options,
command output, exit behavior, worker isolation, and parent progress pipes.
Codex, Claude, Pi, Muse, ACP commands, and T3 terminals use this path; the
current Cursor and OpenCode APIs do not expose the necessary command environment.
Copy the progress implementation and browser renderer from Clover's unpublished
library until that release is available. Web and desktop render nested task
trees above the composer, and mobile renders task summaries. Fresh subscriptions
receive full trees after reconnects or switching chats. Disconnections retain
the last values without claiming success. Keep progress out of durable chat state.
Verify real v3/v4 scripts, preload ordering, handled and unhandled errors,
concurrent producers, reconnects, bounded streams, provider environments, and
client batching. Exercise the real client with a v4 script in Iris, light, dark,
and a narrow viewport, including chat switching and reloads.
Assisted-by: gpt-6.1-sol94 files changed, 11204 insertions(+), 240 deletions(-)
apps/mobile/src/features/threads/ScriptProgress.tsx created+65| ... | ... | @@ -0,0 +1,65 @@ |
| 1 | import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; | |
| 2 | import { ScrollView, View } from "react-native"; | |
| 3 | ||
| 4 | import { AppText as Text } from "../../components/AppText"; | |
| 5 | import { scriptProgressEnvironment } from "../../state/scriptProgress"; | |
| 6 | import { useEnvironmentQuery } from "../../state/query"; | |
| 7 | ||
| 8 | export function ScriptProgress({ | |
| 9 | environmentId, | |
| 10 | threadId, | |
| 11 | }: { | |
| 12 | environmentId: EnvironmentId; | |
| 13 | threadId: ThreadId; | |
| 14 | }) { | |
| 15 | const { data, isSuccess } = useEnvironmentQuery( | |
| 16 | scriptProgressEnvironment.watch({ environmentId, input: { threadId } }), | |
| 17 | ); | |
| 18 | const streams = isSuccess | |
| 19 | ? (data?.streams.filter((stream) => stream.summary.length > 0) ?? []) | |
| 20 | : []; | |
| 21 | if (streams.length === 0) return null; | |
| 22 | ||
| 23 | return ( | |
| 24 | <View accessibilityLabel="Script progress" className="mb-2 rounded-2xl bg-card px-4 py-3"> | |
| 25 | <ScrollView style={{ maxHeight: 180 }}> | |
| 26 | {streams.map((stream) => ( | |
| 27 | <View key={stream.id} className="gap-2"> | |
| 28 | {stream.status === "disconnected" ? ( | |
| 29 | <Text className="text-xs text-foreground-muted">{stream.label} · Disconnected</Text> | |
| 30 | ) : null} | |
| 31 | {stream.summary.map((node, index) => { | |
| 32 | const fraction = | |
| 33 | node.total > 0 ? Math.max(0, Math.min(1, node.value / node.total)) : 0; | |
| 34 | return ( | |
| 35 | // Summary rows are ordered by their progress root; labels may repeat. | |
| 36 | // oxlint-disable-next-line react/no-array-index-key | |
| 37 | <View key={index} className="gap-1"> | |
| 38 | <View className="flex-row items-center gap-2"> | |
| 39 | <Text className="min-w-0 flex-1 text-sm text-foreground">{node.text}</Text> | |
| 40 | <Text className="text-xs text-foreground-muted">{node.detail}</Text> | |
| 41 | </View> | |
| 42 | {node.total > 0 ? ( | |
| 43 | <View | |
| 44 | accessibilityRole="progressbar" | |
| 45 | accessibilityLabel={node.text} | |
| 46 | accessibilityValue={{ | |
| 47 | min: 0, | |
| 48 | max: node.total, | |
| 49 | now: fraction * node.total, | |
| 50 | text: node.detail, | |
| 51 | }} | |
| 52 | className="h-1 overflow-hidden rounded-full bg-border-subtle" | |
| 53 | > | |
| 54 | <View className="h-full bg-primary" style={{ width: `${fraction * 100}%` }} /> | |
| 55 | </View> | |
| 56 | ) : null} | |
| 57 | </View> | |
| 58 | ); | |
| 59 | })} | |
| 60 | </View> | |
| 61 | ))} | |
| 62 | </ScrollView> | |
| 63 | </View> | |
| 64 | ); | |
| 65 | } |
apps/mobile/src/features/threads/ThreadDetailScreen.tsx+5| ... | ... | @@ -1,6 +1,7 @@ |
| 1 | 1 | import { useAtomValue } from "@effect/atom-react"; |
| 2 | 2 | import { useChildThreadInputs, useThreadReportedModelSelection } from "../../state/entities"; |
| 3 | 3 | import { UsageLimitRecoveryCard } from "./UsageLimitRecoveryCard"; |
| 4 | import { ScriptProgress } from "./ScriptProgress"; | |
| 4 | 5 | import { useNavigation } from "@react-navigation/native"; |
| 5 | 6 | import type { WorktreeSetupCardProps } from "./worktree-setup-card"; |
| 6 | 7 | import type { ComposerTextPaste } from "../../native/T3ComposerEditor.types"; |
| ... | ... | @@ -1243,6 +1244,10 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread |
| 1243 | 1244 | list's bottom inset, so any padding above the pill/composer |
| 1244 | 1245 | pushes the resting content floor up by the same amount. */} |
| 1245 | 1246 | <View ref={composerOverlayRef} onLayout={onComposerLayout} className="w-full"> |
| 1247 | <ScriptProgress | |
| 1248 | environmentId={props.environmentId} | |
| 1249 | threadId={props.selectedThread.id} | |
| 1250 | /> | |
| 1246 | 1251 | <FloatingWorkingControl |
| 1247 | 1252 | colorScheme={isDarkMode ? "dark" : "light"} |
| 1248 | 1253 | status={floatingStatus} |
apps/mobile/src/state/scriptProgress.ts created+6| ... | ... | @@ -0,0 +1,6 @@ |
| 1 | import { createScriptProgressEnvironmentAtoms } from "@t3tools/client-runtime/state/script-progress"; | |
| 2 | ||
| 3 | import { connectionAtomRuntime } from "../connection/runtime"; | |
| 4 | ||
| 5 | export const scriptProgressEnvironment = | |
| 6 | createScriptProgressEnvironmentAtoms(connectionAtomRuntime); |
apps/server/package.json+1| ... | ... | @@ -30,6 +30,7 @@ |
| 30 | 30 | }, |
| 31 | 31 | "dependencies": { |
| 32 | 32 | "@anthropic-ai/claude-agent-sdk": "^0.3.276", |
| 33 | "@clo/lib": "workspace:*", | |
| 33 | 34 | "@cursor/sdk": "1.0.35", |
| 34 | 35 | "@effect/platform-node": "catalog:", |
| 35 | 36 | "@effect/platform-node-shared": "catalog:", |
apps/server/src/auth/RpcAuthorization.test.ts+5| ... | ... | @@ -35,6 +35,11 @@ describe("RPC authorization scopes", () => { |
| 35 | 35 | it("requires filesystem read permission to watch file and folder changes", () => { |
| 36 | 36 | expect(requiredScopeForRpcMethod(WS_METHODS.projectsWatch)).toBe(AuthFilesystemReadScope); |
| 37 | 37 | }); |
| 38 | it("reads script progress under orchestration read access", () => { | |
| 39 | expect(requiredScopeForRpcMethod(WS_METHODS.subscribeScriptProgress)).toBe( | |
| 40 | AuthOrchestrationReadScope, | |
| 41 | ); | |
| 42 | }); | |
| 38 | 43 | it("declares exactly one scope for every RPC in the server group", () => { |
| 39 | 44 | expect(new Set(Object.keys(RPC_REQUIRED_SCOPES))).toEqual(new Set(WsRpcGroup.requests.keys())); |
| 40 | 45 | }); |
apps/server/src/auth/RpcAuthorization.ts+1| ... | ... | @@ -189,6 +189,7 @@ export const RPC_REQUIRED_SCOPES = { |
| 189 | 189 | [WS_METHODS.terminalClose]: AuthTerminalOperateScope, |
| 190 | 190 | [WS_METHODS.subscribeTerminalEvents]: AuthTerminalReadScope, |
| 191 | 191 | [WS_METHODS.subscribeTerminalMetadata]: AuthTerminalReadScope, |
| 192 | [WS_METHODS.subscribeScriptProgress]: AuthOrchestrationReadScope, | |
| 192 | 193 | [WS_METHODS.previewOpen]: AuthPreviewOperateScope, |
| 193 | 194 | [WS_METHODS.previewNavigate]: AuthPreviewOperateScope, |
| 194 | 195 | [WS_METHODS.previewResize]: AuthPreviewOperateScope, |
apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts+4-1| ... | ... | @@ -1,3 +1,4 @@ |
| 1 | import * as ScriptProgress from "@t3tools/provider-core/server/ScriptProgress"; | |
| 1 | 2 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 2 | 3 | import { describe, expect, it } from "@effect/vitest"; |
| 3 | 4 | import { AjvJsonSchemaValidator } from "@modelcontextprotocol/sdk/validation/ajv"; |
| ... | ... | @@ -3911,7 +3912,9 @@ describe("orchestrator MCP toolkit", () => { |
| 3911 | 3912 | Layer.provideMerge(layerOrchestration), |
| 3912 | 3913 | Layer.provide( |
| 3913 | 3914 | CodexOrchestratorReplayHarness.makeProviderAdapterRegistryLayer(transcript).pipe( |
| 3914 | Layer.provide(McpProviderSessions.layer), | |
| 3915 | Layer.provide( | |
| 3916 | Layer.merge(McpProviderSessions.layer, ScriptProgress.layerUnavailable), | |
| 3917 | ), | |
| 3915 | 3918 | ), |
| 3916 | 3919 | ), |
| 3917 | 3920 | Layer.provide(layerProviderRegistry), |
apps/server/src/observability/RpcInstrumentation.ts+1| ... | ... | @@ -184,6 +184,7 @@ const RPC_AGGREGATES = { |
| 184 | 184 | [WS_METHODS.terminalClose]: "terminal", |
| 185 | 185 | [WS_METHODS.subscribeTerminalEvents]: "terminal", |
| 186 | 186 | [WS_METHODS.subscribeTerminalMetadata]: "terminal", |
| 187 | [WS_METHODS.subscribeScriptProgress]: "orchestration", | |
| 187 | 188 | [WS_METHODS.previewOpen]: "preview", |
| 188 | 189 | [WS_METHODS.previewNavigate]: "preview", |
| 189 | 190 | [WS_METHODS.previewResize]: "preview", |
apps/server/src/orchestration-v2/AcpRegistryOrchestratorV2.live.test.ts+2-1| ... | ... | @@ -1,3 +1,4 @@ |
| 1 | import * as ScriptProgress from "@t3tools/provider-core/server/ScriptProgress"; | |
| 1 | 2 | import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; |
| 2 | 3 | import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts"; |
| 3 | 4 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| ... | ... | @@ -145,7 +146,7 @@ const layerLive = RuntimeLayer.layer.pipe( |
| 145 | 146 | Layer.provide(layerBackgroundPolicy), |
| 146 | 147 | Layer.provide(ProviderTurnStartServiceTestkit.layer), |
| 147 | 148 | Layer.provide(layerPlatformTest), |
| 148 | Layer.provide(McpProviderSessions.layer), | |
| 149 | Layer.provide(Layer.merge(McpProviderSessions.layer, ScriptProgress.layerUnavailable)), | |
| 149 | 150 | ); |
| 150 | 151 | |
| 151 | 152 | const waitForIdle = Effect.fn("AcpRegistryOrchestratorV2Live.waitForIdle")(function* ( |
apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts+2| ... | ... | @@ -1,3 +1,4 @@ |
| 1 | import * as ScriptProgress from "@t3tools/provider-core/server/ScriptProgress"; | |
| 1 | 2 | import { |
| 2 | 3 | extractCopilotSubagentUpdate, |
| 3 | 4 | makeCopilotSubagentRouting, |
| ... | ... | @@ -108,6 +109,7 @@ const layerTest = Layer.mergeAll( |
| 108 | 109 | NodeServices.layer, |
| 109 | 110 | IdAllocator.layer, |
| 110 | 111 | McpProviderSessions.layer, |
| 112 | ScriptProgress.layerUnavailable, | |
| 111 | 113 | layerHost, |
| 112 | 114 | ); |
| 113 | 115 | const ACP_TEST_DRIVER = ProviderDriverKind.make("acp-test"); |
apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts+2| ... | ... | @@ -1,3 +1,4 @@ |
| 1 | import * as ScriptProgress from "@t3tools/provider-core/server/ScriptProgress"; | |
| 1 | 2 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 2 | 3 | import { assert, describe, it } from "@effect/vitest"; |
| 3 | 4 | import { ProviderInstanceId, ProviderSessionId, ThreadId } from "@t3tools/contracts"; |
| ... | ... | @@ -93,6 +94,7 @@ const layerTest = Layer.mergeAll( |
| 93 | 94 | NodeServices.layer, |
| 94 | 95 | IdAllocator.layer, |
| 95 | 96 | McpProviderSessions.layer, |
| 97 | ScriptProgress.layerUnavailable, | |
| 96 | 98 | layerHost, |
| 97 | 99 | layerRegistry, |
| 98 | 100 | ServerSettings.layerTest(), |
apps/server/src/orchestration-v2/Adapters/AntigravityAdapterV2.test.ts+2| ... | ... | @@ -1,3 +1,4 @@ |
| 1 | import * as ScriptProgress from "@t3tools/provider-core/server/ScriptProgress"; | |
| 1 | 2 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 2 | 3 | import { assert, describe, it } from "@effect/vitest"; |
| 3 | 4 | import { |
| ... | ... | @@ -131,6 +132,7 @@ const layerSession = Layer.mergeAll( |
| 131 | 132 | NodeServices.layer, |
| 132 | 133 | IdAllocator.layer, |
| 133 | 134 | McpProviderSessions.layer, |
| 135 | ScriptProgress.layerUnavailable, | |
| 134 | 136 | TestProviderHost.layer().pipe(Layer.provide(NodeServices.layer)), |
| 135 | 137 | ); |
| 136 | 138 |
apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts+761-119| ... | ... | @@ -1,3 +1,4 @@ |
| 1 | import * as ScriptProgress from "@t3tools/provider-core/server/ScriptProgress"; | |
| 1 | 2 | // @effect-diagnostics nodeBuiltinImport:off -- workflow fixtures use files and the fake Claude CLI uses Node streams |
| 2 | 3 | import * as NodeEvents from "node:events"; |
| 3 | 4 | import * as NodeFS from "node:fs"; |
| ... | ... | @@ -1114,7 +1115,12 @@ describe("ClaudeAdapterV2 Auto-accept edits", () => { |
| 1114 | 1115 | assert.equal((yield* Fiber.join(decision))?.behavior, "allow"); |
| 1115 | 1116 | }).pipe( |
| 1116 | 1117 | Effect.provide( |
| 1117 | Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), | |
| 1118 | Layer.mergeAll( | |
| 1119 | IdAllocator.layer, | |
| 1120 | McpProviderSessions.layer, | |
| 1121 | ScriptProgress.layerUnavailable, | |
| 1122 | NodeServices.layer, | |
| 1123 | ), | |
| 1118 | 1124 | ), |
| 1119 | 1125 | ), |
| 1120 | 1126 | ), |
| ... | ... | @@ -1143,7 +1149,7 @@ describe("ClaudeAdapterV2 approval cancellation", () => { |
| 1143 | 1149 | const controller = new AbortController(); |
| 1144 | 1150 | let removes = 0; |
| 1145 | 1151 | const removeEventListener = controller.signal.removeEventListener.bind(controller.signal); |
| 1146 | controller.signal.removeEventListener = (...args) => { | |
| 1152 | controller.signal.removeEventListener = (...args: Parameters<typeof removeEventListener>) => { | |
| 1147 | 1153 | removes += 1; |
| 1148 | 1154 | return removeEventListener(...args); |
| 1149 | 1155 | }; |
| ... | ... | @@ -1261,7 +1267,12 @@ describe("ClaudeAdapterV2 executable path", () => { |
| 1261 | 1267 | ).pipe( |
| 1262 | 1268 | Effect.provideService(HostProcess.Environment, testCase.hostEnvironment), |
| 1263 | 1269 | Effect.provide( |
| 1264 | Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), | |
| 1270 | Layer.mergeAll( | |
| 1271 | IdAllocator.layer, | |
| 1272 | McpProviderSessions.layer, | |
| 1273 | ScriptProgress.layerUnavailable, | |
| 1274 | NodeServices.layer, | |
| 1275 | ), | |
| 1265 | 1276 | ), |
| 1266 | 1277 | ), |
| 1267 | 1278 | ); |
| ... | ... | @@ -1281,7 +1292,12 @@ describe("ClaudeAdapterV2 executable path", () => { |
| 1281 | 1292 | }), |
| 1282 | 1293 | ).pipe( |
| 1283 | 1294 | Effect.provide( |
| 1284 | Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), | |
| 1295 | Layer.mergeAll( | |
| 1296 | IdAllocator.layer, | |
| 1297 | McpProviderSessions.layer, | |
| 1298 | ScriptProgress.layerUnavailable, | |
| 1299 | NodeServices.layer, | |
| 1300 | ), | |
| 1285 | 1301 | ), |
| 1286 | 1302 | ), |
| 1287 | 1303 | ); |
| ... | ... | @@ -1505,7 +1521,12 @@ describe("ClaudeAdapterV2 resume compaction", () => { |
| 1505 | 1521 | ); |
| 1506 | 1522 | }).pipe( |
| 1507 | 1523 | Effect.provide( |
| 1508 | Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), | |
| 1524 | Layer.mergeAll( | |
| 1525 | IdAllocator.layer, | |
| 1526 | McpProviderSessions.layer, | |
| 1527 | ScriptProgress.layerUnavailable, | |
| 1528 | NodeServices.layer, | |
| 1529 | ), | |
| 1509 | 1530 | ), |
| 1510 | 1531 | ), |
| 1511 | 1532 | ), |
| ... | ... | @@ -1655,7 +1676,12 @@ describe("ClaudeAdapterV2 attachments", () => { |
| 1655 | 1676 | ]); |
| 1656 | 1677 | }).pipe( |
| 1657 | 1678 | Effect.provide( |
| 1658 | Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), | |
| 1679 | Layer.mergeAll( | |
| 1680 | IdAllocator.layer, | |
| 1681 | McpProviderSessions.layer, | |
| 1682 | ScriptProgress.layerUnavailable, | |
| 1683 | NodeServices.layer, | |
| 1684 | ), | |
| 1659 | 1685 | ), |
| 1660 | 1686 | ), |
| 1661 | 1687 | ), |
| ... | ... | @@ -1743,7 +1769,12 @@ describe("ClaudeAdapterV2 attachments", () => { |
| 1743 | 1769 | assert.equal(openCount, 0); |
| 1744 | 1770 | }).pipe( |
| 1745 | 1771 | Effect.provide( |
| 1746 | Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), | |
| 1772 | Layer.mergeAll( | |
| 1773 | IdAllocator.layer, | |
| 1774 | McpProviderSessions.layer, | |
| 1775 | ScriptProgress.layerUnavailable, | |
| 1776 | NodeServices.layer, | |
| 1777 | ), | |
| 1747 | 1778 | ), |
| 1748 | 1779 | ), |
| 1749 | 1780 | ), |
| ... | ... | @@ -1928,7 +1959,12 @@ describe("ClaudeAdapterV2 native fork", () => { |
| 1928 | 1959 | assert.equal(openedQueries[0]?.options.sessionId, undefined); |
| 1929 | 1960 | }).pipe( |
| 1930 | 1961 | Effect.provide( |
| 1931 | Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), | |
| 1962 | Layer.mergeAll( | |
| 1963 | IdAllocator.layer, | |
| 1964 | McpProviderSessions.layer, | |
| 1965 | ScriptProgress.layerUnavailable, | |
| 1966 | NodeServices.layer, | |
| 1967 | ), | |
| 1932 | 1968 | ), |
| 1933 | 1969 | ), |
| 1934 | 1970 | ), |
| ... | ... | @@ -2118,7 +2154,12 @@ describe("ClaudeAdapterV2 native session identity", () => { |
| 2118 | 2154 | return openedQueries; |
| 2119 | 2155 | }).pipe( |
| 2120 | 2156 | Effect.provide( |
| 2121 | Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), | |
| 2157 | Layer.mergeAll( | |
| 2158 | IdAllocator.layer, | |
| 2159 | McpProviderSessions.layer, | |
| 2160 | ScriptProgress.layerUnavailable, | |
| 2161 | NodeServices.layer, | |
| 2162 | ), | |
| 2122 | 2163 | ), |
| 2123 | 2164 | ), |
| 2124 | 2165 | ); |
| ... | ... | @@ -2343,6 +2384,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { |
| 2343 | 2384 | readonly environment?: NodeJS.ProcessEnv; |
| 2344 | 2385 | readonly subagentLaunchToolUseId?: string; |
| 2345 | 2386 | readonly nativeSessionId?: string; |
| 2387 | readonly threadId?: string; | |
| 2346 | 2388 | readonly settings?: ClaudeSettings; |
| 2347 | 2389 | // A CLI process opened after the first streams from its own queue, so the |
| 2348 | 2390 | // first one can exit (Queue.shutdown) and a later turn can start another. |
| ... | ... | @@ -2470,7 +2512,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { |
| 2470 | 2512 | assertComplete: Effect.void, |
| 2471 | 2513 | }, |
| 2472 | 2514 | }); |
| 2473 | const threadId = ThreadId.make("thread-claude-wake"); | |
| 2515 | const threadId = ThreadId.make(options?.threadId ?? "thread-claude-wake"); | |
| 2474 | 2516 | const runtime = yield* adapter.openSession({ |
| 2475 | 2517 | threadId, |
| 2476 | 2518 | providerSessionId: ProviderSessionId.make("provider-session-claude-wake"), |
| ... | ... | @@ -2540,6 +2582,64 @@ describe("ClaudeAdapterV2 background wake turns", () => { |
| 2540 | 2582 | }); |
| 2541 | 2583 | const makeWakeHarness = makeWakeHarnessWithOptions(); |
| 2542 | 2584 | |
| 2585 | it.effect("keeps progress environments isolated across concurrent Claude queries", () => | |
| 2586 | Effect.gen(function* () { | |
| 2587 | const environment = { NODE_OPTIONS: "--trace-warnings", USER_SETTING: "keep" }; | |
| 2588 | const harnesses = yield* Effect.all( | |
| 2589 | ["one", "two"].map((name) => | |
| 2590 | makeWakeHarnessWithOptions({ | |
| 2591 | threadId: `claude-progress-${name}`, | |
| 2592 | nativeSessionId: `native-progress-${name}`, | |
| 2593 | environment, | |
| 2594 | }), | |
| 2595 | ), | |
| 2596 | { concurrency: "unbounded" }, | |
| 2597 | ); | |
| 2598 | const now = yield* DateTime.now; | |
| 2599 | yield* Effect.all( | |
| 2600 | harnesses.map((harness, index) => | |
| 2601 | harness.runtime.startTurn( | |
| 2602 | makeClaudeTestTurnInput({ | |
| 2603 | threadId: harness.threadId, | |
| 2604 | providerThread: harness.providerThread, | |
| 2605 | now, | |
| 2606 | attemptId: RunAttemptId.make(`progress-query-${index}`), | |
| 2607 | text: "Run a script", | |
| 2608 | attachments: [], | |
| 2609 | }), | |
| 2610 | ), | |
| 2611 | ), | |
| 2612 | { concurrency: "unbounded" }, | |
| 2613 | ); | |
| 2614 | const options = harnesses.map((harness) => harness.getOpenedOptions()?.env); | |
| 2615 | assert.equal(options[0]?.T3_SCRIPT_PROGRESS_TOKEN, "claude-progress-one"); | |
| 2616 | assert.equal(options[1]?.T3_SCRIPT_PROGRESS_TOKEN, "claude-progress-two"); | |
| 2617 | for (const option of options) { | |
| 2618 | assert.equal( | |
| 2619 | option?.NODE_OPTIONS, | |
| 2620 | "--import=file:///private/progress.mjs --trace-warnings", | |
| 2621 | ); | |
| 2622 | assert.equal(option?.USER_SETTING, "keep"); | |
| 2623 | } | |
| 2624 | assert.deepEqual(environment, { NODE_OPTIONS: "--trace-warnings", USER_SETTING: "keep" }); | |
| 2625 | assert.notStrictEqual(options[0], options[1]); | |
| 2626 | }).pipe( | |
| 2627 | Effect.scoped, | |
| 2628 | Effect.provideService(ScriptProgress.ScriptProgress, { | |
| 2629 | environmentForThread: (threadId) => | |
| 2630 | Effect.succeed({ | |
| 2631 | NODE_OPTIONS: "--import=file:///private/progress.mjs", | |
| 2632 | T3_SCRIPT_PROGRESS_ENDPOINT: "http://127.0.0.1:1234/progress", | |
| 2633 | T3_SCRIPT_PROGRESS_TOKEN: threadId, | |
| 2634 | }), | |
| 2635 | subscribe: () => Stream.empty, | |
| 2636 | }), | |
| 2637 | Effect.provide( | |
| 2638 | Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), | |
| 2639 | ), | |
| 2640 | ), | |
| 2641 | ); | |
| 2642 | ||
| 2543 | 2643 | it.effect.each([ |
| 2544 | 2644 | { isError: false, title: "Check weather" }, |
| 2545 | 2645 | { isError: true, title: "Check weather" }, |
| ... | ... | @@ -2645,7 +2745,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { |
| 2645 | 2745 | }).pipe( |
| 2646 | 2746 | Effect.scoped, |
| 2647 | 2747 | Effect.provide( |
| 2648 | Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), | |
| 2748 | Layer.mergeAll( | |
| 2749 | IdAllocator.layer, | |
| 2750 | McpProviderSessions.layer, | |
| 2751 | ScriptProgress.layerUnavailable, | |
| 2752 | NodeServices.layer, | |
| 2753 | ), | |
| 2649 | 2754 | ), |
| 2650 | 2755 | ), |
| 2651 | 2756 | ); |
| ... | ... | @@ -2734,7 +2839,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { |
| 2734 | 2839 | assert.isTrue(yield* harness.hasPendingBackgroundWork); |
| 2735 | 2840 | }).pipe( |
| 2736 | 2841 | Effect.provide( |
| 2737 | Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), | |
| 2842 | Layer.mergeAll( | |
| 2843 | IdAllocator.layer, | |
| 2844 | McpProviderSessions.layer, | |
| 2845 | ScriptProgress.layerUnavailable, | |
| 2846 | NodeServices.layer, | |
| 2847 | ), | |
| 2738 | 2848 | ), |
| 2739 | 2849 | ), |
| 2740 | 2850 | ), |
| ... | ... | @@ -2858,7 +2968,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { |
| 2858 | 2968 | }).pipe( |
| 2859 | 2969 | Effect.scoped, |
| 2860 | 2970 | Effect.provide( |
| 2861 | Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), | |
| 2971 | Layer.mergeAll( | |
| 2972 | IdAllocator.layer, | |
| 2973 | McpProviderSessions.layer, | |
| 2974 | ScriptProgress.layerUnavailable, | |
| 2975 | NodeServices.layer, | |
| 2976 | ), | |
| 2862 | 2977 | ), |
| 2863 | 2978 | ), |
| 2864 | 2979 | ); |
| ... | ... | @@ -2939,7 +3054,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { |
| 2939 | 3054 | }).pipe( |
| 2940 | 3055 | Effect.scoped, |
| 2941 | 3056 | Effect.provide( |
| 2942 | Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), | |
| 3057 | Layer.mergeAll( | |
| 3058 | IdAllocator.layer, | |
| 3059 | McpProviderSessions.layer, | |
| 3060 | ScriptProgress.layerUnavailable, | |
| 3061 | NodeServices.layer, | |
| 3062 | ), | |
| 2943 | 3063 | ), |
| 2944 | 3064 | ), |
| 2945 | 3065 | ); |
| ... | ... | @@ -3013,7 +3133,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { |
| 3013 | 3133 | assert.equal(node?.type === "node.updated" ? node.node.status : undefined, failed.status); |
| 3014 | 3134 | }).pipe( |
| 3015 | 3135 | Effect.provide( |
| 3016 | Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), | |
| 3136 | Layer.mergeAll( | |
| 3137 | IdAllocator.layer, | |
| 3138 | McpProviderSessions.layer, | |
| 3139 | ScriptProgress.layerUnavailable, | |
| 3140 | NodeServices.layer, | |
| 3141 | ), | |
| 3017 | 3142 | ), |
| 3018 | 3143 | ), |
| 3019 | 3144 | ), |
| ... | ... | @@ -3121,7 +3246,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { |
| 3121 | 3246 | assert.lengthOf(harness.terminalEvents(), 1); |
| 3122 | 3247 | }).pipe( |
| 3123 | 3248 | Effect.provide( |
| 3124 | Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), | |
| 3249 | Layer.mergeAll( | |
| 3250 | IdAllocator.layer, | |
| 3251 | McpProviderSessions.layer, | |
| 3252 | ScriptProgress.layerUnavailable, | |
| 3253 | NodeServices.layer, | |
| 3254 | ), | |
| 3125 | 3255 | ), |
| 3126 | 3256 | ), |
| 3127 | 3257 | ), |
| ... | ... | @@ -3191,7 +3321,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { |
| 3191 | 3321 | assert.notEqual(notices()[0]?.id, notices()[2]?.id); |
| 3192 | 3322 | }).pipe( |
| 3193 | 3323 | Effect.provide( |
| 3194 | Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), | |
| 3324 | Layer.mergeAll( | |
| 3325 | IdAllocator.layer, | |
| 3326 | McpProviderSessions.layer, | |
| 3327 | ScriptProgress.layerUnavailable, | |
| 3328 | NodeServices.layer, | |
| 3329 | ), | |
| 3195 | 3330 | ), |
| 3196 | 3331 | ), |
| 3197 | 3332 | ); |
| ... | ... | @@ -3242,7 +3377,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { |
| 3242 | 3377 | ); |
| 3243 | 3378 | }).pipe( |
| 3244 | 3379 | Effect.provide( |
| 3245 | Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), | |
| 3380 | Layer.mergeAll( | |
| 3381 | IdAllocator.layer, | |
| 3382 | McpProviderSessions.layer, | |
| 3383 | ScriptProgress.layerUnavailable, | |
| 3384 | NodeServices.layer, | |
| 3385 | ), | |
| 3246 | 3386 | ), |
| 3247 | 3387 | ), |
| 3248 | 3388 | ); |
| ... | ... | @@ -3293,7 +3433,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { |
| 3293 | 3433 | assert.notInclude(terminal.failure.message, "repeated API errors"); |
| 3294 | 3434 | }).pipe( |
| 3295 | 3435 | Effect.provide( |
| 3296 | Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), | |
| 3436 | Layer.mergeAll( | |
| 3437 | IdAllocator.layer, | |
| 3438 | McpProviderSessions.layer, | |
| 3439 | ScriptProgress.layerUnavailable, | |
| 3440 | NodeServices.layer, | |
| 3441 | ), | |
| 3297 | 3442 | ), |
| 3298 | 3443 | ), |
| 3299 | 3444 | ); |
| ... | ... | @@ -3336,7 +3481,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { |
| 3336 | 3481 | assert.equal(terminal.failure.code, "api_error_403"); |
| 3337 | 3482 | }).pipe( |
| 3338 | 3483 | Effect.provide( |
| 3339 | Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), | |
| 3484 | Layer.mergeAll( | |
| 3485 | IdAllocator.layer, | |
| 3486 | McpProviderSessions.layer, | |
| 3487 | ScriptProgress.layerUnavailable, | |
| 3488 | NodeServices.layer, | |
| 3489 | ), | |
| 3340 | 3490 | ), |
| 3341 | 3491 | ), |
| 3342 | 3492 | ); |
| ... | ... | @@ -3394,7 +3544,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { |
| 3394 | 3544 | assert.equal(terminal.failure.class, recovered ? "provider_error" : "usage_limit"); |
| 3395 | 3545 | }).pipe( |
| 3396 | 3546 | Effect.provide( |
| 3397 | Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), | |
| 3547 | Layer.mergeAll( | |
| 3548 | IdAllocator.layer, | |
| 3549 | McpProviderSessions.layer, | |
| 3550 | ScriptProgress.layerUnavailable, | |
| 3551 | NodeServices.layer, | |
| 3552 | ), | |
| 3398 | 3553 | ), |
| 3399 | 3554 | ), |
| 3400 | 3555 | ); |
| ... | ... | @@ -3458,7 +3613,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { |
| 3458 | 3613 | ); |
| 3459 | 3614 | }).pipe( |
| 3460 | 3615 | Effect.provide( |
| 3461 | Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), | |
| 3616 | Layer.mergeAll( | |
| 3617 | IdAllocator.layer, | |
| 3618 | McpProviderSessions.layer, | |
| 3619 | ScriptProgress.layerUnavailable, | |
| 3620 | NodeServices.layer, | |
| 3621 | ), | |
| 3462 | 3622 | ), |
| 3463 | 3623 | ), |
| 3464 | 3624 | ); |
| ... | ... | @@ -3516,7 +3676,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { |
| 3516 | 3676 | } |
| 3517 | 3677 | }).pipe( |
| 3518 | 3678 | Effect.provide( |
| 3519 | Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), | |
| 3679 | Layer.mergeAll( | |
| 3680 | IdAllocator.layer, | |
| 3681 | McpProviderSessions.layer, | |
| 3682 | ScriptProgress.layerUnavailable, | |
| 3683 | NodeServices.layer, | |
| 3684 | ), | |
| 3520 | 3685 | ), |
| 3521 | 3686 | ), |
| 3522 | 3687 | ); |
| ... | ... | @@ -3564,7 +3729,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { |
| 3564 | 3729 | assert.notInclude(terminal.failure.message.toLowerCase(), "usage limit"); |
| 3565 | 3730 | }).pipe( |
| 3566 | 3731 | Effect.provide( |
| 3567 | Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), | |
| 3732 | Layer.mergeAll( | |
| 3733 | IdAllocator.layer, | |
| 3734 | McpProviderSessions.layer, | |
| 3735 | ScriptProgress.layerUnavailable, | |
| 3736 | NodeServices.layer, | |
| 3737 | ), | |
| 3568 | 3738 | ), |
| 3569 | 3739 | ), |
| 3570 | 3740 | ); |
| ... | ... | @@ -3632,7 +3802,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { |
| 3632 | 3802 | assert.deepEqual(reportedContext, [2]); |
| 3633 | 3803 | }).pipe( |
| 3634 | 3804 | Effect.provide( |
| 3635 | Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), | |
| 3805 | Layer.mergeAll( | |
| 3806 | IdAllocator.layer, | |
| 3807 | McpProviderSessions.layer, | |
| 3808 | ScriptProgress.layerUnavailable, | |
| 3809 | NodeServices.layer, | |
| 3810 | ), | |
| 3636 | 3811 | ), |
| 3637 | 3812 | ), |
| 3638 | 3813 | ); |
| ... | ... | @@ -3696,7 +3871,11 @@ describe("ClaudeAdapterV2 background wake turns", () => { |
| 3696 | 3871 | }).pipe( |
| 3697 | 3872 | Effect.scoped, |
| 3698 | 3873 | Effect.provide( |
| 3699 | Layer.mergeAll(NodeServices.layer, IdAllocator.layer, McpProviderSessions.layer), | |
| 3874 | Layer.mergeAll( | |
| 3875 | NodeServices.layer, | |
| 3876 | IdAllocator.layer, | |
| 3877 | Layer.merge(McpProviderSessions.layer, ScriptProgress.layerUnavailable), | |
| 3878 | ), | |
| 3700 | 3879 | ), |
| 3701 | 3880 | ), |
| 3702 | 3881 | ); |
| ... | ... | @@ -3773,7 +3952,11 @@ describe("ClaudeAdapterV2 background wake turns", () => { |
| 3773 | 3952 | }).pipe( |
| 3774 | 3953 | Effect.scoped, |
| 3775 | 3954 | Effect.provide( |
| 3776 | Layer.mergeAll(NodeServices.layer, IdAllocator.layer, McpProviderSessions.layer), | |
| 3955 | Layer.mergeAll( | |
| 3956 | NodeServices.layer, | |
| 3957 | IdAllocator.layer, | |
| 3958 | Layer.merge(McpProviderSessions.layer, ScriptProgress.layerUnavailable), | |
| 3959 | ), | |
| 3777 | 3960 | ), |
| 3778 | 3961 | ), |
| 3779 | 3962 | ); |
| ... | ... | @@ -3876,7 +4059,11 @@ describe("ClaudeAdapterV2 background wake turns", () => { |
| 3876 | 4059 | }).pipe( |
| 3877 | 4060 | Effect.scoped, |
| 3878 | 4061 | Effect.provide( |
| 3879 | Layer.mergeAll(NodeServices.layer, IdAllocator.layer, McpProviderSessions.layer), | |
| 4062 | Layer.mergeAll( | |
| 4063 | NodeServices.layer, | |
| 4064 | IdAllocator.layer, | |
| 4065 | Layer.merge(McpProviderSessions.layer, ScriptProgress.layerUnavailable), | |
| 4066 | ), | |
| 3880 | 4067 | ), |
| 3881 | 4068 | ), |
| 3882 | 4069 | ); |
| ... | ... | @@ -4089,7 +4276,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { |
| 4089 | 4276 | assert.deepEqual(harness.permissionModeChanges, ["bypassPermissions"]); |
| 4090 | 4277 | }).pipe( |
| 4091 | 4278 | Effect.provide( |
| 4092 | Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), | |
| 4279 | Layer.mergeAll( | |
| 4280 | IdAllocator.layer, | |
| 4281 | McpProviderSessions.layer, | |
| 4282 | ScriptProgress.layerUnavailable, | |
| 4283 | NodeServices.layer, | |
| 4284 | ), | |
| 4093 | 4285 | ), |
| 4094 | 4286 | ), |
| 4095 | 4287 | ), |
| ... | ... | @@ -4317,7 +4509,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { |
| 4317 | 4509 | }).pipe( |
| 4318 | 4510 | Effect.scoped, |
| 4319 | 4511 | Effect.provide( |
| 4320 | Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), | |
| 4512 | Layer.mergeAll( | |
| 4513 | IdAllocator.layer, | |
| 4514 | McpProviderSessions.layer, | |
| 4515 | ScriptProgress.layerUnavailable, | |
| 4516 | NodeServices.layer, | |
| 4517 | ), | |
| 4321 | 4518 | ), |
| 4322 | 4519 | ), |
| 4323 | 4520 | ); |
| ... | ... | @@ -4348,7 +4545,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { |
| 4348 | 4545 | }).pipe( |
| 4349 | 4546 | Effect.scoped, |
| 4350 | 4547 | Effect.provide( |
| 4351 | Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), | |
| 4548 | Layer.mergeAll( | |
| 4549 | IdAllocator.layer, | |
| 4550 | McpProviderSessions.layer, | |
| 4551 | ScriptProgress.layerUnavailable, | |
| 4552 | NodeServices.layer, | |
| 4553 | ), | |
| 4352 | 4554 | ), |
| 4353 | 4555 | ), |
| 4354 | 4556 | ); |
| ... | ... | @@ -4375,7 +4577,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { |
| 4375 | 4577 | }).pipe( |
| 4376 | 4578 | Effect.scoped, |
| 4377 | 4579 | Effect.provide( |
| 4378 | Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), | |
| 4580 | Layer.mergeAll( | |
| 4581 | IdAllocator.layer, | |
| 4582 | McpProviderSessions.layer, | |
| 4583 | ScriptProgress.layerUnavailable, | |
| 4584 | NodeServices.layer, | |
| 4585 | ), | |
| 4379 | 4586 | ), |
| 4380 | 4587 | ), |
| 4381 | 4588 | ); |
| ... | ... | @@ -4417,7 +4624,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { |
| 4417 | 4624 | }).pipe( |
| 4418 | 4625 | Effect.scoped, |
| 4419 | 4626 | Effect.provide( |
| 4420 | Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), | |
| 4627 | Layer.mergeAll( | |
| 4628 | IdAllocator.layer, | |
| 4629 | McpProviderSessions.layer, | |
| 4630 | ScriptProgress.layerUnavailable, | |
| 4631 | NodeServices.layer, | |
| 4632 | ), | |
| 4421 | 4633 | ), |
| 4422 | 4634 | ), |
| 4423 | 4635 | ); |
| ... | ... | @@ -4440,7 +4652,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { |
| 4440 | 4652 | }).pipe( |
| 4441 | 4653 | Effect.scoped, |
| 4442 | 4654 | Effect.provide( |
| 4443 | Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), | |
| 4655 | Layer.mergeAll( | |
| 4656 | IdAllocator.layer, | |
| 4657 | McpProviderSessions.layer, | |
| 4658 | ScriptProgress.layerUnavailable, | |
| 4659 | NodeServices.layer, | |
| 4660 | ), | |
| 4444 | 4661 | ), |
| 4445 | 4662 | ), |
| 4446 | 4663 | ); |
| ... | ... | @@ -4455,7 +4672,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { |
| 4455 | 4672 | }).pipe( |
| 4456 | 4673 | Effect.scoped, |
| 4457 | 4674 | Effect.provide( |
| 4458 | Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), | |
| 4675 | Layer.mergeAll( | |
| 4676 | IdAllocator.layer, | |
| 4677 | McpProviderSessions.layer, | |
| 4678 | ScriptProgress.layerUnavailable, | |
| 4679 | NodeServices.layer, | |
| 4680 | ), | |
| 4459 | 4681 | ), |
| 4460 | 4682 | ), |
| 4461 | 4683 | ); |
| ... | ... | @@ -4532,7 +4754,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { |
| 4532 | 4754 | yield* awaitUntil(() => harness.terminalEvents().length === 1, "recovered Claude turn"); |
| 4533 | 4755 | }).pipe( |
| 4534 | 4756 | Effect.provide( |
| 4535 | Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), | |
| 4757 | Layer.mergeAll( | |
| 4758 | IdAllocator.layer, | |
| 4759 | McpProviderSessions.layer, | |
| 4760 | ScriptProgress.layerUnavailable, | |
| 4761 | NodeServices.layer, | |
| 4762 | ), | |
| 4536 | 4763 | ), |
| 4537 | 4764 | ), |
| 4538 | 4765 | ), |
| ... | ... | @@ -4590,7 +4817,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { |
| 4590 | 4817 | assert.equal(terminal.failure.code, "api_error_529"); |
| 4591 | 4818 | }).pipe( |
| 4592 | 4819 | Effect.provide( |
| 4593 | Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), | |
| 4820 | Layer.mergeAll( | |
| 4821 | IdAllocator.layer, | |
| 4822 | McpProviderSessions.layer, | |
| 4823 | ScriptProgress.layerUnavailable, | |
| 4824 | NodeServices.layer, | |
| 4825 | ), | |
| 4594 | 4826 | ), |
| 4595 | 4827 | ), |
| 4596 | 4828 | ), |
| ... | ... | @@ -4656,7 +4888,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { |
| 4656 | 4888 | ); |
| 4657 | 4889 | }).pipe( |
| 4658 | 4890 | Effect.provide( |
| 4659 | Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), | |
| 4891 | Layer.mergeAll( | |
| 4892 | IdAllocator.layer, | |
| 4893 | McpProviderSessions.layer, | |
| 4894 | ScriptProgress.layerUnavailable, | |
| 4895 | NodeServices.layer, | |
| 4896 | ), | |
| 4660 | 4897 | ), |
| 4661 | 4898 | ), |
| 4662 | 4899 | ), |
| ... | ... | @@ -4725,7 +4962,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { |
| 4725 | 4962 | assert.isFalse(yield* harness.hasPendingBackgroundWork); |
| 4726 | 4963 | }).pipe( |
| 4727 | 4964 | Effect.provide( |
| 4728 | Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), | |
| 4965 | Layer.mergeAll( | |
| 4966 | IdAllocator.layer, | |
| 4967 | McpProviderSessions.layer, | |
| 4968 | ScriptProgress.layerUnavailable, | |
| 4969 | NodeServices.layer, | |
| 4970 | ), | |
| 4729 | 4971 | ), |
| 4730 | 4972 | ), |
| 4731 | 4973 | ), |
| ... | ... | @@ -4864,7 +5106,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { |
| 4864 | 5106 | ]); |
| 4865 | 5107 | }).pipe( |
| 4866 | 5108 | Effect.provide( |
| 4867 | Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), | |
| 5109 | Layer.mergeAll( | |
| 5110 | IdAllocator.layer, | |
| 5111 | McpProviderSessions.layer, | |
| 5112 | ScriptProgress.layerUnavailable, | |
| 5113 | NodeServices.layer, | |
| 5114 | ), | |
| 4868 | 5115 | ), |
| 4869 | 5116 | ), |
| 4870 | 5117 | ), |
| ... | ... | @@ -4918,7 +5165,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { |
| 4918 | 5165 | assert.lengthOf(harness.continuationRequests, 0); |
| 4919 | 5166 | }).pipe( |
| 4920 | 5167 | Effect.provide( |
| 4921 | Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), | |
| 5168 | Layer.mergeAll( | |
| 5169 | IdAllocator.layer, | |
| 5170 | McpProviderSessions.layer, | |
| 5171 | ScriptProgress.layerUnavailable, | |
| 5172 | NodeServices.layer, | |
| 5173 | ), | |
| 4922 | 5174 | ), |
| 4923 | 5175 | ), |
| 4924 | 5176 | ), |
| ... | ... | @@ -4967,7 +5219,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { |
| 4967 | 5219 | assert.lengthOf(harness.continuationRequests, 0); |
| 4968 | 5220 | }).pipe( |
| 4969 | 5221 | Effect.provide( |
| 4970 | Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), | |
| 5222 | Layer.mergeAll( | |
| 5223 | IdAllocator.layer, | |
| 5224 | McpProviderSessions.layer, | |
| 5225 | ScriptProgress.layerUnavailable, | |
| 5226 | NodeServices.layer, | |
| 5227 | ), | |
| 4971 | 5228 | ), |
| 4972 | 5229 | ), |
| 4973 | 5230 | ), |
| ... | ... | @@ -5155,7 +5412,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { |
| 5155 | 5412 | yield* awaitUntil(() => continuationRequests.length === 1, "replacement wake"); |
| 5156 | 5413 | }).pipe( |
| 5157 | 5414 | Effect.provide( |
| 5158 | Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), | |
| 5415 | Layer.mergeAll( | |
| 5416 | IdAllocator.layer, | |
| 5417 | McpProviderSessions.layer, | |
| 5418 | ScriptProgress.layerUnavailable, | |
| 5419 | NodeServices.layer, | |
| 5420 | ), | |
| 5159 | 5421 | ), |
| 5160 | 5422 | ), |
| 5161 | 5423 | ), |
| ... | ... | @@ -5217,7 +5479,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { |
| 5217 | 5479 | assert.isFalse(yield* harness.hasPendingBackgroundWork); |
| 5218 | 5480 | }).pipe( |
| 5219 | 5481 | Effect.provide( |
| 5220 | Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), | |
| 5482 | Layer.mergeAll( | |
| 5483 | IdAllocator.layer, | |
| 5484 | McpProviderSessions.layer, | |
| 5485 | ScriptProgress.layerUnavailable, | |
| 5486 | NodeServices.layer, | |
| 5487 | ), | |
| 5221 | 5488 | ), |
| 5222 | 5489 | ), |
| 5223 | 5490 | ), |
| ... | ... | @@ -5485,7 +5752,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { |
| 5485 | 5752 | assert.isFalse(yield* hasPendingBackgroundWork); |
| 5486 | 5753 | }).pipe( |
| 5487 | 5754 | Effect.provide( |
| 5488 | Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), | |
| 5755 | Layer.mergeAll( | |
| 5756 | IdAllocator.layer, | |
| 5757 | McpProviderSessions.layer, | |
| 5758 | ScriptProgress.layerUnavailable, | |
| 5759 | NodeServices.layer, | |
| 5760 | ), | |
| 5489 | 5761 | ), |
| 5490 | 5762 | ), |
| 5491 | 5763 | ), |
| ... | ... | @@ -5709,7 +5981,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { |
| 5709 | 5981 | ); |
| 5710 | 5982 | }).pipe( |
| 5711 | 5983 | Effect.provide( |
| 5712 | Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), | |
| 5984 | Layer.mergeAll( | |
| 5985 | IdAllocator.layer, | |
| 5986 | McpProviderSessions.layer, | |
| 5987 | ScriptProgress.layerUnavailable, | |
| 5988 | NodeServices.layer, | |
| 5989 | ), | |
| 5713 | 5990 | ), |
| 5714 | 5991 | ), |
| 5715 | 5992 | ), |
| ... | ... | @@ -5796,7 +6073,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { |
| 5796 | 6073 | ); |
| 5797 | 6074 | }).pipe( |
| 5798 | 6075 | Effect.provide( |
| 5799 | Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), | |
| 6076 | Layer.mergeAll( | |
| 6077 | IdAllocator.layer, | |
| 6078 | McpProviderSessions.layer, | |
| 6079 | ScriptProgress.layerUnavailable, | |
| 6080 | NodeServices.layer, | |
| 6081 | ), | |
| 5800 | 6082 | ), |
| 5801 | 6083 | ), |
| 5802 | 6084 | ), |
| ... | ... | @@ -5929,7 +6211,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { |
| 5929 | 6211 | assert.equal(result?.behavior, "allow"); |
| 5930 | 6212 | }).pipe( |
| 5931 | 6213 | Effect.provide( |
| 5932 | Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), | |
| 6214 | Layer.mergeAll( | |
| 6215 | IdAllocator.layer, | |
| 6216 | McpProviderSessions.layer, | |
| 6217 | ScriptProgress.layerUnavailable, | |
| 6218 | NodeServices.layer, | |
| 6219 | ), | |
| 5933 | 6220 | ), |
| 5934 | 6221 | ), |
| 5935 | 6222 | ), |
| ... | ... | @@ -6029,7 +6316,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { |
| 6029 | 6316 | ); |
| 6030 | 6317 | }).pipe( |
| 6031 | 6318 | Effect.provide( |
| 6032 | Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), | |
| 6319 | Layer.mergeAll( | |
| 6320 | IdAllocator.layer, | |
| 6321 | McpProviderSessions.layer, | |
| 6322 | ScriptProgress.layerUnavailable, | |
| 6323 | NodeServices.layer, | |
| 6324 | ), | |
| 6033 | 6325 | ), |
| 6034 | 6326 | ), |
| 6035 | 6327 | ), |
| ... | ... | @@ -6111,7 +6403,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { |
| 6111 | 6403 | assert.equal(harness.terminalEvents()[1]?.status, "failed"); |
| 6112 | 6404 | }).pipe( |
| 6113 | 6405 | Effect.provide( |
| 6114 | Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), | |
| 6406 | Layer.mergeAll( | |
| 6407 | IdAllocator.layer, | |
| 6408 | McpProviderSessions.layer, | |
| 6409 | ScriptProgress.layerUnavailable, | |
| 6410 | NodeServices.layer, | |
| 6411 | ), | |
| 6115 | 6412 | ), |
| 6116 | 6413 | ), |
| 6117 | 6414 | ), |
| ... | ... | @@ -6141,7 +6438,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { |
| 6141 | 6438 | assert.notEqual(original.promptUuid(0), copy.promptUuid(0)); |
| 6142 | 6439 | }).pipe( |
| 6143 | 6440 | Effect.provide( |
| 6144 | Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), | |
| 6441 | Layer.mergeAll( | |
| 6442 | IdAllocator.layer, | |
| 6443 | McpProviderSessions.layer, | |
| 6444 | ScriptProgress.layerUnavailable, | |
| 6445 | NodeServices.layer, | |
| 6446 | ), | |
| 6145 | 6447 | ), |
| 6146 | 6448 | ), |
| 6147 | 6449 | ), |
| ... | ... | @@ -6182,7 +6484,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { |
| 6182 | 6484 | assert.include(terminal.failure.message, "never started a turn"); |
| 6183 | 6485 | }).pipe( |
| 6184 | 6486 | Effect.provide( |
| 6185 | Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), | |
| 6487 | Layer.mergeAll( | |
| 6488 | IdAllocator.layer, | |
| 6489 | McpProviderSessions.layer, | |
| 6490 | ScriptProgress.layerUnavailable, | |
| 6491 | NodeServices.layer, | |
| 6492 | ), | |
| 6186 | 6493 | ), |
| 6187 | 6494 | ), |
| 6188 | 6495 | ), |
| ... | ... | @@ -6235,7 +6542,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { |
| 6235 | 6542 | assert.equal(harness.terminalEvents()[0]?.status, "completed"); |
| 6236 | 6543 | }).pipe( |
| 6237 | 6544 | Effect.provide( |
| 6238 | Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), | |
| 6545 | Layer.mergeAll( | |
| 6546 | IdAllocator.layer, | |
| 6547 | McpProviderSessions.layer, | |
| 6548 | ScriptProgress.layerUnavailable, | |
| 6549 | NodeServices.layer, | |
| 6550 | ), | |
| 6239 | 6551 | ), |
| 6240 | 6552 | ), |
| 6241 | 6553 | ), |
| ... | ... | @@ -6285,7 +6597,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { |
| 6285 | 6597 | assert.isTrue(yield* harness.hasPendingBackgroundWork); |
| 6286 | 6598 | }).pipe( |
| 6287 | 6599 | Effect.provide( |
| 6288 | Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), | |
| 6600 | Layer.mergeAll( | |
| 6601 | IdAllocator.layer, | |
| 6602 | McpProviderSessions.layer, | |
| 6603 | ScriptProgress.layerUnavailable, | |
| 6604 | NodeServices.layer, | |
| 6605 | ), | |
| 6289 | 6606 | ), |
| 6290 | 6607 | ), |
| 6291 | 6608 | ), |
| ... | ... | @@ -6346,7 +6663,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { |
| 6346 | 6663 | ); |
| 6347 | 6664 | }).pipe( |
| 6348 | 6665 | Effect.provide( |
| 6349 | Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), | |
| 6666 | Layer.mergeAll( | |
| 6667 | IdAllocator.layer, | |
| 6668 | McpProviderSessions.layer, | |
| 6669 | ScriptProgress.layerUnavailable, | |
| 6670 | NodeServices.layer, | |
| 6671 | ), | |
| 6350 | 6672 | ), |
| 6351 | 6673 | ), |
| 6352 | 6674 | ), |
| ... | ... | @@ -6401,7 +6723,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { |
| 6401 | 6723 | assert.lengthOf(harness.offeredMessages, 1); |
| 6402 | 6724 | }).pipe( |
| 6403 | 6725 | Effect.provide( |
| 6404 | Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), | |
| 6726 | Layer.mergeAll( | |
| 6727 | IdAllocator.layer, | |
| 6728 | McpProviderSessions.layer, | |
| 6729 | ScriptProgress.layerUnavailable, | |
| 6730 | NodeServices.layer, | |
| 6731 | ), | |
| 6405 | 6732 | ), |
| 6406 | 6733 | ), |
| 6407 | 6734 | ), |
| ... | ... | @@ -6493,7 +6820,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { |
| 6493 | 6820 | assert.include(pause.turnItem.message, "This turn is paused until the 5-hour limit"); |
| 6494 | 6821 | }).pipe( |
| 6495 | 6822 | Effect.provide( |
| 6496 | Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), | |
| 6823 | Layer.mergeAll( | |
| 6824 | IdAllocator.layer, | |
| 6825 | McpProviderSessions.layer, | |
| 6826 | ScriptProgress.layerUnavailable, | |
| 6827 | NodeServices.layer, | |
| 6828 | ), | |
| 6497 | 6829 | ), |
| 6498 | 6830 | ), |
| 6499 | 6831 | ), |
| ... | ... | @@ -6588,7 +6920,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { |
| 6588 | 6920 | assert.isFalse(yield* harness.hasPendingBackgroundWork); |
| 6589 | 6921 | }).pipe( |
| 6590 | 6922 | Effect.provide( |
| 6591 | Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), | |
| 6923 | Layer.mergeAll( | |
| 6924 | IdAllocator.layer, | |
| 6925 | McpProviderSessions.layer, | |
| 6926 | ScriptProgress.layerUnavailable, | |
| 6927 | NodeServices.layer, | |
| 6928 | ), | |
| 6592 | 6929 | ), |
| 6593 | 6930 | ), |
| 6594 | 6931 | ), |
| ... | ... | @@ -6654,7 +6991,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { |
| 6654 | 6991 | assert.equal(harness.terminalEvents()[1]?.status, "completed"); |
| 6655 | 6992 | }).pipe( |
| 6656 | 6993 | Effect.provide( |
| 6657 | Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), | |
| 6994 | Layer.mergeAll( | |
| 6995 | IdAllocator.layer, | |
| 6996 | McpProviderSessions.layer, | |
| 6997 | ScriptProgress.layerUnavailable, | |
| 6998 | NodeServices.layer, | |
| 6999 | ), | |
| 6658 | 7000 | ), |
| 6659 | 7001 | ), |
| 6660 | 7002 | ), |
| ... | ... | @@ -6696,7 +7038,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { |
| 6696 | 7038 | ); |
| 6697 | 7039 | }).pipe( |
| 6698 | 7040 | Effect.provide( |
| 6699 | Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), | |
| 7041 | Layer.mergeAll( | |
| 7042 | IdAllocator.layer, | |
| 7043 | McpProviderSessions.layer, | |
| 7044 | ScriptProgress.layerUnavailable, | |
| 7045 | NodeServices.layer, | |
| 7046 | ), | |
| 6700 | 7047 | ), |
| 6701 | 7048 | ), |
| 6702 | 7049 | ), |
| ... | ... | @@ -6783,7 +7130,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { |
| 6783 | 7130 | assert.isFalse(hasMessageText(staleResultText)); |
| 6784 | 7131 | }).pipe( |
| 6785 | 7132 | Effect.provide( |
| 6786 | Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), | |
| 7133 | Layer.mergeAll( | |
| 7134 | IdAllocator.layer, | |
| 7135 | McpProviderSessions.layer, | |
| 7136 | ScriptProgress.layerUnavailable, | |
| 7137 | NodeServices.layer, | |
| 7138 | ), | |
| 6787 | 7139 | ), |
| 6788 | 7140 | ), |
| 6789 | 7141 | ), |
| ... | ... | @@ -6837,7 +7189,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { |
| 6837 | 7189 | assert.equal(harness.terminalEvents()[1]?.status, "completed"); |
| 6838 | 7190 | }).pipe( |
| 6839 | 7191 | Effect.provide( |
| 6840 | Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), | |
| 7192 | Layer.mergeAll( | |
| 7193 | IdAllocator.layer, | |
| 7194 | McpProviderSessions.layer, | |
| 7195 | ScriptProgress.layerUnavailable, | |
| 7196 | NodeServices.layer, | |
| 7197 | ), | |
| 6841 | 7198 | ), |
| 6842 | 7199 | ), |
| 6843 | 7200 | ), |
| ... | ... | @@ -6901,7 +7258,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { |
| 6901 | 7258 | assert.lengthOf(harness.terminalEvents(), 1); |
| 6902 | 7259 | }).pipe( |
| 6903 | 7260 | Effect.provide( |
| 6904 | Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), | |
| 7261 | Layer.mergeAll( | |
| 7262 | IdAllocator.layer, | |
| 7263 | McpProviderSessions.layer, | |
| 7264 | ScriptProgress.layerUnavailable, | |
| 7265 | NodeServices.layer, | |
| 7266 | ), | |
| 6905 | 7267 | ), |
| 6906 | 7268 | ), |
| 6907 | 7269 | ), |
| ... | ... | @@ -6965,7 +7327,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { |
| 6965 | 7327 | ); |
| 6966 | 7328 | }).pipe( |
| 6967 | 7329 | Effect.provide( |
| 6968 | Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), | |
| 7330 | Layer.mergeAll( | |
| 7331 | IdAllocator.layer, | |
| 7332 | McpProviderSessions.layer, | |
| 7333 | ScriptProgress.layerUnavailable, | |
| 7334 | NodeServices.layer, | |
| 7335 | ), | |
| 6969 | 7336 | ), |
| 6970 | 7337 | ), |
| 6971 | 7338 | ), |
| ... | ... | @@ -7003,7 +7370,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { |
| 7003 | 7370 | assert.equal(harness.terminalEvents()[0]?.status, "failed"); |
| 7004 | 7371 | }).pipe( |
| 7005 | 7372 | Effect.provide( |
| 7006 | Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), | |
| 7373 | Layer.mergeAll( | |
| 7374 | IdAllocator.layer, | |
| 7375 | McpProviderSessions.layer, | |
| 7376 | ScriptProgress.layerUnavailable, | |
| 7377 | NodeServices.layer, | |
| 7378 | ), | |
| 7007 | 7379 | ), |
| 7008 | 7380 | ), |
| 7009 | 7381 | ), |
| ... | ... | @@ -7033,7 +7405,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { |
| 7033 | 7405 | assert.lengthOf(harness.offeredMessages, 0); |
| 7034 | 7406 | }).pipe( |
| 7035 | 7407 | Effect.provide( |
| 7036 | Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), | |
| 7408 | Layer.mergeAll( | |
| 7409 | IdAllocator.layer, | |
| 7410 | McpProviderSessions.layer, | |
| 7411 | ScriptProgress.layerUnavailable, | |
| 7412 | NodeServices.layer, | |
| 7413 | ), | |
| 7037 | 7414 | ), |
| 7038 | 7415 | ), |
| 7039 | 7416 | ), |
| ... | ... | @@ -7234,7 +7611,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { |
| 7234 | 7611 | ); |
| 7235 | 7612 | }).pipe( |
| 7236 | 7613 | Effect.provide( |
| 7237 | Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), | |
| 7614 | Layer.mergeAll( | |
| 7615 | IdAllocator.layer, | |
| 7616 | McpProviderSessions.layer, | |
| 7617 | ScriptProgress.layerUnavailable, | |
| 7618 | NodeServices.layer, | |
| 7619 | ), | |
| 7238 | 7620 | ), |
| 7239 | 7621 | ), |
| 7240 | 7622 | ), |
| ... | ... | @@ -7299,7 +7681,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { |
| 7299 | 7681 | ); |
| 7300 | 7682 | }).pipe( |
| 7301 | 7683 | Effect.provide( |
| 7302 | Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), | |
| 7684 | Layer.mergeAll( | |
| 7685 | IdAllocator.layer, | |
| 7686 | McpProviderSessions.layer, | |
| 7687 | ScriptProgress.layerUnavailable, | |
| 7688 | NodeServices.layer, | |
| 7689 | ), | |
| 7303 | 7690 | ), |
| 7304 | 7691 | ), |
| 7305 | 7692 | ), |
| ... | ... | @@ -7459,7 +7846,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { |
| 7459 | 7846 | assert.deepEqual([...new Set(userRunItems)], ["assistant_message"]); |
| 7460 | 7847 | }).pipe( |
| 7461 | 7848 | Effect.provide( |
| 7462 | Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), | |
| 7849 | Layer.mergeAll( | |
| 7850 | IdAllocator.layer, | |
| 7851 | McpProviderSessions.layer, | |
| 7852 | ScriptProgress.layerUnavailable, | |
| 7853 | NodeServices.layer, | |
| 7854 | ), | |
| 7463 | 7855 | ), |
| 7464 | 7856 | ), |
| 7465 | 7857 | ), |
| ... | ... | @@ -7569,7 +7961,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { |
| 7569 | 7961 | ); |
| 7570 | 7962 | }).pipe( |
| 7571 | 7963 | Effect.provide( |
| 7572 | Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), | |
| 7964 | Layer.mergeAll( | |
| 7965 | IdAllocator.layer, | |
| 7966 | McpProviderSessions.layer, | |
| 7967 | ScriptProgress.layerUnavailable, | |
| 7968 | NodeServices.layer, | |
| 7969 | ), | |
| 7573 | 7970 | ), |
| 7574 | 7971 | ), |
| 7575 | 7972 | ), |
| ... | ... | @@ -7734,7 +8131,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { |
| 7734 | 8131 | ); |
| 7735 | 8132 | }).pipe( |
| 7736 | 8133 | Effect.provide( |
| 7737 | Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), | |
| 8134 | Layer.mergeAll( | |
| 8135 | IdAllocator.layer, | |
| 8136 | McpProviderSessions.layer, | |
| 8137 | ScriptProgress.layerUnavailable, | |
| 8138 | NodeServices.layer, | |
| 8139 | ), | |
| 7738 | 8140 | ), |
| 7739 | 8141 | ), |
| 7740 | 8142 | ), |
| ... | ... | @@ -7926,7 +8328,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { |
| 7926 | 8328 | ); |
| 7927 | 8329 | }).pipe( |
| 7928 | 8330 | Effect.provide( |
| 7929 | Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), | |
| 8331 | Layer.mergeAll( | |
| 8332 | IdAllocator.layer, | |
| 8333 | McpProviderSessions.layer, | |
| 8334 | ScriptProgress.layerUnavailable, | |
| 8335 | NodeServices.layer, | |
| 8336 | ), | |
| 7930 | 8337 | ), |
| 7931 | 8338 | ), |
| 7932 | 8339 | ), |
| ... | ... | @@ -8060,7 +8467,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { |
| 8060 | 8467 | assert.lengthOf(harness.continuationRequests, 0); |
| 8061 | 8468 | }).pipe( |
| 8062 | 8469 | Effect.provide( |
| 8063 | Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), | |
| 8470 | Layer.mergeAll( | |
| 8471 | IdAllocator.layer, | |
| 8472 | McpProviderSessions.layer, | |
| 8473 | ScriptProgress.layerUnavailable, | |
| 8474 | NodeServices.layer, | |
| 8475 | ), | |
| 8064 | 8476 | ), |
| 8065 | 8477 | ), |
| 8066 | 8478 | ), |
| ... | ... | @@ -8116,7 +8528,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { |
| 8116 | 8528 | assert.deepEqual(bashStatuses(), ["command_execution:running", "command_execution:failed"]); |
| 8117 | 8529 | }).pipe( |
| 8118 | 8530 | Effect.provide( |
| 8119 | Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), | |
| 8531 | Layer.mergeAll( | |
| 8532 | IdAllocator.layer, | |
| 8533 | McpProviderSessions.layer, | |
| 8534 | ScriptProgress.layerUnavailable, | |
| 8535 | NodeServices.layer, | |
| 8536 | ), | |
| 8120 | 8537 | ), |
| 8121 | 8538 | ), |
| 8122 | 8539 | ), |
| ... | ... | @@ -8182,7 +8599,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { |
| 8182 | 8599 | ]); |
| 8183 | 8600 | }).pipe( |
| 8184 | 8601 | Effect.provide( |
| 8185 | Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), | |
| 8602 | Layer.mergeAll( | |
| 8603 | IdAllocator.layer, | |
| 8604 | McpProviderSessions.layer, | |
| 8605 | ScriptProgress.layerUnavailable, | |
| 8606 | NodeServices.layer, | |
| 8607 | ), | |
| 8186 | 8608 | ), |
| 8187 | 8609 | ), |
| 8188 | 8610 | ), |
| ... | ... | @@ -8352,7 +8774,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { |
| 8352 | 8774 | ]); |
| 8353 | 8775 | }).pipe( |
| 8354 | 8776 | Effect.provide( |
| 8355 | Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), | |
| 8777 | Layer.mergeAll( | |
| 8778 | IdAllocator.layer, | |
| 8779 | McpProviderSessions.layer, | |
| 8780 | ScriptProgress.layerUnavailable, | |
| 8781 | NodeServices.layer, | |
| 8782 | ), | |
| 8356 | 8783 | ), |
| 8357 | 8784 | ), |
| 8358 | 8785 | ), |
| ... | ... | @@ -8512,7 +8939,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { |
| 8512 | 8939 | ]); |
| 8513 | 8940 | }).pipe( |
| 8514 | 8941 | Effect.provide( |
| 8515 | Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), | |
| 8942 | Layer.mergeAll( | |
| 8943 | IdAllocator.layer, | |
| 8944 | McpProviderSessions.layer, | |
| 8945 | ScriptProgress.layerUnavailable, | |
| 8946 | NodeServices.layer, | |
| 8947 | ), | |
| 8516 | 8948 | ), |
| 8517 | 8949 | ), |
| 8518 | 8950 | ), |
| ... | ... | @@ -8593,7 +9025,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { |
| 8593 | 9025 | assert.deepEqual(routing.assistantTexts(harness.threadId), ["The auditor finished."]); |
| 8594 | 9026 | }).pipe( |
| 8595 | 9027 | Effect.provide( |
| 8596 | Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), | |
| 9028 | Layer.mergeAll( | |
| 9029 | IdAllocator.layer, | |
| 9030 | McpProviderSessions.layer, | |
| 9031 | ScriptProgress.layerUnavailable, | |
| 9032 | NodeServices.layer, | |
| 9033 | ), | |
| 8597 | 9034 | ), |
| 8598 | 9035 | ), |
| 8599 | 9036 | ), |
| ... | ... | @@ -8772,7 +9209,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { |
| 8772 | 9209 | assert.lengthOf(harness.continuationRequests, 1); |
| 8773 | 9210 | }).pipe( |
| 8774 | 9211 | Effect.provide( |
| 8775 | Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), | |
| 9212 | Layer.mergeAll( | |
| 9213 | IdAllocator.layer, | |
| 9214 | McpProviderSessions.layer, | |
| 9215 | ScriptProgress.layerUnavailable, | |
| 9216 | NodeServices.layer, | |
| 9217 | ), | |
| 8776 | 9218 | ), |
| 8777 | 9219 | ), |
| 8778 | 9220 | ), |
| ... | ... | @@ -8837,7 +9279,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { |
| 8837 | 9279 | assert.deepEqual(retryStatuses, ["running"]); |
| 8838 | 9280 | }).pipe( |
| 8839 | 9281 | Effect.provide( |
| 8840 | Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), | |
| 9282 | Layer.mergeAll( | |
| 9283 | IdAllocator.layer, | |
| 9284 | McpProviderSessions.layer, | |
| 9285 | ScriptProgress.layerUnavailable, | |
| 9286 | NodeServices.layer, | |
| 9287 | ), | |
| 8841 | 9288 | ), |
| 8842 | 9289 | ), |
| 8843 | 9290 | ), |
| ... | ... | @@ -8930,7 +9377,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { |
| 8930 | 9377 | assert.deepEqual(routing.assistantTexts(harness.threadId), ["The auditor finished."]); |
| 8931 | 9378 | }).pipe( |
| 8932 | 9379 | Effect.provide( |
| 8933 | Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), | |
| 9380 | Layer.mergeAll( | |
| 9381 | IdAllocator.layer, | |
| 9382 | McpProviderSessions.layer, | |
| 9383 | ScriptProgress.layerUnavailable, | |
| 9384 | NodeServices.layer, | |
| 9385 | ), | |
| 8934 | 9386 | ), |
| 8935 | 9387 | ), |
| 8936 | 9388 | ), |
| ... | ... | @@ -9017,7 +9469,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { |
| 9017 | 9469 | assert.equal(latest.subagent.completedAt === null, resume); |
| 9018 | 9470 | }).pipe( |
| 9019 | 9471 | Effect.provide( |
| 9020 | Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), | |
| 9472 | Layer.mergeAll( | |
| 9473 | IdAllocator.layer, | |
| 9474 | McpProviderSessions.layer, | |
| 9475 | ScriptProgress.layerUnavailable, | |
| 9476 | NodeServices.layer, | |
| 9477 | ), | |
| 9021 | 9478 | ), |
| 9022 | 9479 | ), |
| 9023 | 9480 | ), |
| ... | ... | @@ -9139,7 +9596,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { |
| 9139 | 9596 | assert.isFalse(yield* fixture.hasPendingBackgroundWork); |
| 9140 | 9597 | }).pipe( |
| 9141 | 9598 | Effect.provide( |
| 9142 | Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), | |
| 9599 | Layer.mergeAll( | |
| 9600 | IdAllocator.layer, | |
| 9601 | McpProviderSessions.layer, | |
| 9602 | ScriptProgress.layerUnavailable, | |
| 9603 | NodeServices.layer, | |
| 9604 | ), | |
| 9143 | 9605 | ), |
| 9144 | 9606 | ), |
| 9145 | 9607 | ), |
| ... | ... | @@ -9186,7 +9648,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { |
| 9186 | 9648 | ); |
| 9187 | 9649 | }).pipe( |
| 9188 | 9650 | Effect.provide( |
| 9189 | Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), | |
| 9651 | Layer.mergeAll( | |
| 9652 | IdAllocator.layer, | |
| 9653 | McpProviderSessions.layer, | |
| 9654 | ScriptProgress.layerUnavailable, | |
| 9655 | NodeServices.layer, | |
| 9656 | ), | |
| 9190 | 9657 | ), |
| 9191 | 9658 | ), |
| 9192 | 9659 | ), |
| ... | ... | @@ -9222,7 +9689,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { |
| 9222 | 9689 | }); |
| 9223 | 9690 | }).pipe( |
| 9224 | 9691 | Effect.provide( |
| 9225 | Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), | |
| 9692 | Layer.mergeAll( | |
| 9693 | IdAllocator.layer, | |
| 9694 | McpProviderSessions.layer, | |
| 9695 | ScriptProgress.layerUnavailable, | |
| 9696 | NodeServices.layer, | |
| 9697 | ), | |
| 9226 | 9698 | ), |
| 9227 | 9699 | ), |
| 9228 | 9700 | ), |
| ... | ... | @@ -9281,7 +9753,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { |
| 9281 | 9753 | ); |
| 9282 | 9754 | }).pipe( |
| 9283 | 9755 | Effect.provide( |
| 9284 | Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), | |
| 9756 | Layer.mergeAll( | |
| 9757 | IdAllocator.layer, | |
| 9758 | McpProviderSessions.layer, | |
| 9759 | ScriptProgress.layerUnavailable, | |
| 9760 | NodeServices.layer, | |
| 9761 | ), | |
| 9285 | 9762 | ), |
| 9286 | 9763 | ), |
| 9287 | 9764 | ), |
| ... | ... | @@ -9395,7 +9872,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { |
| 9395 | 9872 | assert.equal(closes, 0); |
| 9396 | 9873 | }).pipe( |
| 9397 | 9874 | Effect.provide( |
| 9398 | Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), | |
| 9875 | Layer.mergeAll( | |
| 9876 | IdAllocator.layer, | |
| 9877 | McpProviderSessions.layer, | |
| 9878 | ScriptProgress.layerUnavailable, | |
| 9879 | NodeServices.layer, | |
| 9880 | ), | |
| 9399 | 9881 | ), |
| 9400 | 9882 | ), |
| 9401 | 9883 | ), |
| ... | ... | @@ -9508,7 +9990,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { |
| 9508 | 9990 | assert.equal(child?.appThread.modelSelection?.model, initialModel ?? parentModel); |
| 9509 | 9991 | }).pipe( |
| 9510 | 9992 | Effect.provide( |
| 9511 | Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), | |
| 9993 | Layer.mergeAll( | |
| 9994 | IdAllocator.layer, | |
| 9995 | McpProviderSessions.layer, | |
| 9996 | ScriptProgress.layerUnavailable, | |
| 9997 | NodeServices.layer, | |
| 9998 | ), | |
| 9512 | 9999 | ), |
| 9513 | 10000 | ), |
| 9514 | 10001 | ), |
| ... | ... | @@ -9622,7 +10109,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { |
| 9622 | 10109 | ); |
| 9623 | 10110 | }).pipe( |
| 9624 | 10111 | Effect.provide( |
| 9625 | Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), | |
| 10112 | Layer.mergeAll( | |
| 10113 | IdAllocator.layer, | |
| 10114 | McpProviderSessions.layer, | |
| 10115 | ScriptProgress.layerUnavailable, | |
| 10116 | NodeServices.layer, | |
| 10117 | ), | |
| 9626 | 10118 | ), |
| 9627 | 10119 | ), |
| 9628 | 10120 | ), |
| ... | ... | @@ -9726,7 +10218,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { |
| 9726 | 10218 | }); |
| 9727 | 10219 | }).pipe( |
| 9728 | 10220 | Effect.provide( |
| 9729 | Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), | |
| 10221 | Layer.mergeAll( | |
| 10222 | IdAllocator.layer, | |
| 10223 | McpProviderSessions.layer, | |
| 10224 | ScriptProgress.layerUnavailable, | |
| 10225 | NodeServices.layer, | |
| 10226 | ), | |
| 9730 | 10227 | ), |
| 9731 | 10228 | ), |
| 9732 | 10229 | ), |
| ... | ... | @@ -9846,7 +10343,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { |
| 9846 | 10343 | assert.deepEqual(childEfforts, ["low", "low", "max"]); |
| 9847 | 10344 | }).pipe( |
| 9848 | 10345 | Effect.provide( |
| 9849 | Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), | |
| 10346 | Layer.mergeAll( | |
| 10347 | IdAllocator.layer, | |
| 10348 | McpProviderSessions.layer, | |
| 10349 | ScriptProgress.layerUnavailable, | |
| 10350 | NodeServices.layer, | |
| 10351 | ), | |
| 9850 | 10352 | ), |
| 9851 | 10353 | ), |
| 9852 | 10354 | ), |
| ... | ... | @@ -9935,7 +10437,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { |
| 9935 | 10437 | yield* awaitUntil(() => harness.terminalEvents().length === 1, "turn terminal"); |
| 9936 | 10438 | }).pipe( |
| 9937 | 10439 | Effect.provide( |
| 9938 | Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), | |
| 10440 | Layer.mergeAll( | |
| 10441 | IdAllocator.layer, | |
| 10442 | McpProviderSessions.layer, | |
| 10443 | ScriptProgress.layerUnavailable, | |
| 10444 | NodeServices.layer, | |
| 10445 | ), | |
| 9939 | 10446 | ), |
| 9940 | 10447 | ), |
| 9941 | 10448 | ), |
| ... | ... | @@ -10032,7 +10539,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { |
| 10032 | 10539 | assert.isFalse(yield* harness.hasPendingBackgroundWork); |
| 10033 | 10540 | }).pipe( |
| 10034 | 10541 | Effect.provide( |
| 10035 | Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), | |
| 10542 | Layer.mergeAll( | |
| 10543 | IdAllocator.layer, | |
| 10544 | McpProviderSessions.layer, | |
| 10545 | ScriptProgress.layerUnavailable, | |
| 10546 | NodeServices.layer, | |
| 10547 | ), | |
| 10036 | 10548 | ), |
| 10037 | 10549 | ), |
| 10038 | 10550 | ), |
| ... | ... | @@ -10322,7 +10834,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { |
| 10322 | 10834 | assert.isFalse(yield* harness.hasPendingBackgroundWork); |
| 10323 | 10835 | }).pipe( |
| 10324 | 10836 | Effect.provide( |
| 10325 | Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), | |
| 10837 | Layer.mergeAll( | |
| 10838 | IdAllocator.layer, | |
| 10839 | McpProviderSessions.layer, | |
| 10840 | ScriptProgress.layerUnavailable, | |
| 10841 | NodeServices.layer, | |
| 10842 | ), | |
| 10326 | 10843 | ), |
| 10327 | 10844 | ), |
| 10328 | 10845 | ), |
| ... | ... | @@ -10508,7 +11025,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { |
| 10508 | 11025 | ); |
| 10509 | 11026 | }).pipe( |
| 10510 | 11027 | Effect.provide( |
| 10511 | Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), | |
| 11028 | Layer.mergeAll( | |
| 11029 | IdAllocator.layer, | |
| 11030 | McpProviderSessions.layer, | |
| 11031 | ScriptProgress.layerUnavailable, | |
| 11032 | NodeServices.layer, | |
| 11033 | ), | |
| 10512 | 11034 | ), |
| 10513 | 11035 | ), |
| 10514 | 11036 | ), |
| ... | ... | @@ -10699,7 +11221,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { |
| 10699 | 11221 | assert.isFalse(yield* hasPendingBackgroundWork); |
| 10700 | 11222 | }).pipe( |
| 10701 | 11223 | Effect.provide( |
| 10702 | Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), | |
| 11224 | Layer.mergeAll( | |
| 11225 | IdAllocator.layer, | |
| 11226 | McpProviderSessions.layer, | |
| 11227 | ScriptProgress.layerUnavailable, | |
| 11228 | NodeServices.layer, | |
| 11229 | ), | |
| 10703 | 11230 | ), |
| 10704 | 11231 | ), |
| 10705 | 11232 | ), |
| ... | ... | @@ -10954,7 +11481,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { |
| 10954 | 11481 | assert.isFalse(yield* hasPendingBackgroundWork); |
| 10955 | 11482 | }).pipe( |
| 10956 | 11483 | Effect.provide( |
| 10957 | Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), | |
| 11484 | Layer.mergeAll( | |
| 11485 | IdAllocator.layer, | |
| 11486 | McpProviderSessions.layer, | |
| 11487 | ScriptProgress.layerUnavailable, | |
| 11488 | NodeServices.layer, | |
| 11489 | ), | |
| 10958 | 11490 | ), |
| 10959 | 11491 | ), |
| 10960 | 11492 | ), |
| ... | ... | @@ -11123,7 +11655,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { |
| 11123 | 11655 | assert.lengthOf(processQueues, 3); |
| 11124 | 11656 | }).pipe( |
| 11125 | 11657 | Effect.provide( |
| 11126 | Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), | |
| 11658 | Layer.mergeAll( | |
| 11659 | IdAllocator.layer, | |
| 11660 | McpProviderSessions.layer, | |
| 11661 | ScriptProgress.layerUnavailable, | |
| 11662 | NodeServices.layer, | |
| 11663 | ), | |
| 11127 | 11664 | ), |
| 11128 | 11665 | ), |
| 11129 | 11666 | ), |
| ... | ... | @@ -11255,7 +11792,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { |
| 11255 | 11792 | assert.isTrue(yield* hasPendingBackgroundWork); |
| 11256 | 11793 | }).pipe( |
| 11257 | 11794 | Effect.provide( |
| 11258 | Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), | |
| 11795 | Layer.mergeAll( | |
| 11796 | IdAllocator.layer, | |
| 11797 | McpProviderSessions.layer, | |
| 11798 | ScriptProgress.layerUnavailable, | |
| 11799 | NodeServices.layer, | |
| 11800 | ), | |
| 11259 | 11801 | ), |
| 11260 | 11802 | ), |
| 11261 | 11803 | ), |
| ... | ... | @@ -11459,7 +12001,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { |
| 11459 | 12001 | assert.equal(continuationRequests[1]?.detail, "Retry build completed successfully"); |
| 11460 | 12002 | }).pipe( |
| 11461 | 12003 | Effect.provide( |
| 11462 | Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), | |
| 12004 | Layer.mergeAll( | |
| 12005 | IdAllocator.layer, | |
| 12006 | McpProviderSessions.layer, | |
| 12007 | ScriptProgress.layerUnavailable, | |
| 12008 | NodeServices.layer, | |
| 12009 | ), | |
| 11463 | 12010 | ), |
| 11464 | 12011 | ), |
| 11465 | 12012 | ), |
| ... | ... | @@ -11538,7 +12085,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { |
| 11538 | 12085 | assert.lengthOf(providerThreadRosterEvents(events), 0); |
| 11539 | 12086 | }).pipe( |
| 11540 | 12087 | Effect.provide( |
| 11541 | Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), | |
| 12088 | Layer.mergeAll( | |
| 12089 | IdAllocator.layer, | |
| 12090 | McpProviderSessions.layer, | |
| 12091 | ScriptProgress.layerUnavailable, | |
| 12092 | NodeServices.layer, | |
| 12093 | ), | |
| 11542 | 12094 | ), |
| 11543 | 12095 | ), |
| 11544 | 12096 | ), |
| ... | ... | @@ -11817,7 +12369,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { |
| 11817 | 12369 | assert.isFalse(yield* harness.hasPendingBackgroundWork); |
| 11818 | 12370 | }).pipe( |
| 11819 | 12371 | Effect.provide( |
| 11820 | Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), | |
| 12372 | Layer.mergeAll( | |
| 12373 | IdAllocator.layer, | |
| 12374 | McpProviderSessions.layer, | |
| 12375 | ScriptProgress.layerUnavailable, | |
| 12376 | NodeServices.layer, | |
| 12377 | ), | |
| 11821 | 12378 | ), |
| 11822 | 12379 | ), |
| 11823 | 12380 | ), |
| ... | ... | @@ -11886,7 +12443,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { |
| 11886 | 12443 | ); |
| 11887 | 12444 | }).pipe( |
| 11888 | 12445 | Effect.provide( |
| 11889 | Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), | |
| 12446 | Layer.mergeAll( | |
| 12447 | IdAllocator.layer, | |
| 12448 | McpProviderSessions.layer, | |
| 12449 | ScriptProgress.layerUnavailable, | |
| 12450 | NodeServices.layer, | |
| 12451 | ), | |
| 11890 | 12452 | ), |
| 11891 | 12453 | ), |
| 11892 | 12454 | ), |
| ... | ... | @@ -11994,7 +12556,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { |
| 11994 | 12556 | assert.deepEqual(stoppedTasks, [WORKFLOW_TASK_ID]); |
| 11995 | 12557 | }).pipe( |
| 11996 | 12558 | Effect.provide( |
| 11997 | Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), | |
| 12559 | Layer.mergeAll( | |
| 12560 | IdAllocator.layer, | |
| 12561 | McpProviderSessions.layer, | |
| 12562 | ScriptProgress.layerUnavailable, | |
| 12563 | NodeServices.layer, | |
| 12564 | ), | |
| 11998 | 12565 | ), |
| 11999 | 12566 | ), |
| 12000 | 12567 | ), |
| ... | ... | @@ -12252,7 +12819,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { |
| 12252 | 12819 | ); |
| 12253 | 12820 | }).pipe( |
| 12254 | 12821 | Effect.provide( |
| 12255 | Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), | |
| 12822 | Layer.mergeAll( | |
| 12823 | IdAllocator.layer, | |
| 12824 | McpProviderSessions.layer, | |
| 12825 | ScriptProgress.layerUnavailable, | |
| 12826 | NodeServices.layer, | |
| 12827 | ), | |
| 12256 | 12828 | ), |
| 12257 | 12829 | ), |
| 12258 | 12830 | ), |
| ... | ... | @@ -12323,7 +12895,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { |
| 12323 | 12895 | ); |
| 12324 | 12896 | }).pipe( |
| 12325 | 12897 | Effect.provide( |
| 12326 | Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), | |
| 12898 | Layer.mergeAll( | |
| 12899 | IdAllocator.layer, | |
| 12900 | McpProviderSessions.layer, | |
| 12901 | ScriptProgress.layerUnavailable, | |
| 12902 | NodeServices.layer, | |
| 12903 | ), | |
| 12327 | 12904 | ), |
| 12328 | 12905 | ), |
| 12329 | 12906 | ), |
| ... | ... | @@ -12391,7 +12968,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { |
| 12391 | 12968 | assert.equal(coordinatorCount(), betweenTurnCount + 1); |
| 12392 | 12969 | }).pipe( |
| 12393 | 12970 | Effect.provide( |
| 12394 | Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), | |
| 12971 | Layer.mergeAll( | |
| 12972 | IdAllocator.layer, | |
| 12973 | McpProviderSessions.layer, | |
| 12974 | ScriptProgress.layerUnavailable, | |
| 12975 | NodeServices.layer, | |
| 12976 | ), | |
| 12395 | 12977 | ), |
| 12396 | 12978 | ), |
| 12397 | 12979 | ), |
| ... | ... | @@ -12521,7 +13103,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { |
| 12521 | 13103 | ); |
| 12522 | 13104 | }).pipe( |
| 12523 | 13105 | Effect.provide( |
| 12524 | Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), | |
| 13106 | Layer.mergeAll( | |
| 13107 | IdAllocator.layer, | |
| 13108 | McpProviderSessions.layer, | |
| 13109 | ScriptProgress.layerUnavailable, | |
| 13110 | NodeServices.layer, | |
| 13111 | ), | |
| 12525 | 13112 | ), |
| 12526 | 13113 | ), |
| 12527 | 13114 | ), |
| ... | ... | @@ -12691,7 +13278,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { |
| 12691 | 13278 | ); |
| 12692 | 13279 | }).pipe( |
| 12693 | 13280 | Effect.provide( |
| 12694 | Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), | |
| 13281 | Layer.mergeAll( | |
| 13282 | IdAllocator.layer, | |
| 13283 | McpProviderSessions.layer, | |
| 13284 | ScriptProgress.layerUnavailable, | |
| 13285 | NodeServices.layer, | |
| 13286 | ), | |
| 12695 | 13287 | ), |
| 12696 | 13288 | ), |
| 12697 | 13289 | ), |
| ... | ... | @@ -12834,7 +13426,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { |
| 12834 | 13426 | ); |
| 12835 | 13427 | }).pipe( |
| 12836 | 13428 | Effect.provide( |
| 12837 | Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), | |
| 13429 | Layer.mergeAll( | |
| 13430 | IdAllocator.layer, | |
| 13431 | McpProviderSessions.layer, | |
| 13432 | ScriptProgress.layerUnavailable, | |
| 13433 | NodeServices.layer, | |
| 13434 | ), | |
| 12838 | 13435 | ), |
| 12839 | 13436 | ), |
| 12840 | 13437 | ), |
| ... | ... | @@ -13068,7 +13665,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { |
| 13068 | 13665 | ); |
| 13069 | 13666 | }).pipe( |
| 13070 | 13667 | Effect.provide( |
| 13071 | Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), | |
| 13668 | Layer.mergeAll( | |
| 13669 | IdAllocator.layer, | |
| 13670 | McpProviderSessions.layer, | |
| 13671 | ScriptProgress.layerUnavailable, | |
| 13672 | NodeServices.layer, | |
| 13673 | ), | |
| 13072 | 13674 | ), |
| 13073 | 13675 | ), |
| 13074 | 13676 | ), |
| ... | ... | @@ -13192,7 +13794,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { |
| 13192 | 13794 | ); |
| 13193 | 13795 | }).pipe( |
| 13194 | 13796 | Effect.provide( |
| 13195 | Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), | |
| 13797 | Layer.mergeAll( | |
| 13798 | IdAllocator.layer, | |
| 13799 | McpProviderSessions.layer, | |
| 13800 | ScriptProgress.layerUnavailable, | |
| 13801 | NodeServices.layer, | |
| 13802 | ), | |
| 13196 | 13803 | ), |
| 13197 | 13804 | ), |
| 13198 | 13805 | ), |
| ... | ... | @@ -13303,7 +13910,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { |
| 13303 | 13910 | } |
| 13304 | 13911 | }).pipe( |
| 13305 | 13912 | Effect.provide( |
| 13306 | Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), | |
| 13913 | Layer.mergeAll( | |
| 13914 | IdAllocator.layer, | |
| 13915 | McpProviderSessions.layer, | |
| 13916 | ScriptProgress.layerUnavailable, | |
| 13917 | NodeServices.layer, | |
| 13918 | ), | |
| 13307 | 13919 | ), |
| 13308 | 13920 | ), |
| 13309 | 13921 | ), |
| ... | ... | @@ -13607,7 +14219,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { |
| 13607 | 14219 | } |
| 13608 | 14220 | }).pipe( |
| 13609 | 14221 | Effect.provide( |
| 13610 | Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), | |
| 14222 | Layer.mergeAll( | |
| 14223 | IdAllocator.layer, | |
| 14224 | McpProviderSessions.layer, | |
| 14225 | ScriptProgress.layerUnavailable, | |
| 14226 | NodeServices.layer, | |
| 14227 | ), | |
| 13611 | 14228 | ), |
| 13612 | 14229 | ), |
| 13613 | 14230 | ), |
| ... | ... | @@ -13675,7 +14292,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { |
| 13675 | 14292 | yield* Queue.take(harness.terminalReceipts); |
| 13676 | 14293 | }).pipe( |
| 13677 | 14294 | Effect.provide( |
| 13678 | Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), | |
| 14295 | Layer.mergeAll( | |
| 14296 | IdAllocator.layer, | |
| 14297 | McpProviderSessions.layer, | |
| 14298 | ScriptProgress.layerUnavailable, | |
| 14299 | NodeServices.layer, | |
| 14300 | ), | |
| 13679 | 14301 | ), |
| 13680 | 14302 | ), |
| 13681 | 14303 | ), |
| ... | ... | @@ -13904,7 +14526,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { |
| 13904 | 14526 | assert.deepEqual(currentAssistantAnswers(), ["No answer from this workflow attempt."]); |
| 13905 | 14527 | }).pipe( |
| 13906 | 14528 | Effect.provide( |
| 13907 | Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), | |
| 14529 | Layer.mergeAll( | |
| 14530 | IdAllocator.layer, | |
| 14531 | McpProviderSessions.layer, | |
| 14532 | ScriptProgress.layerUnavailable, | |
| 14533 | NodeServices.layer, | |
| 14534 | ), | |
| 13908 | 14535 | ), |
| 13909 | 14536 | ), |
| 13910 | 14537 | ), |
| ... | ... | @@ -13990,7 +14617,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { |
| 13990 | 14617 | }).pipe( |
| 13991 | 14618 | Effect.scoped, |
| 13992 | 14619 | Effect.provide( |
| 13993 | Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), | |
| 14620 | Layer.mergeAll( | |
| 14621 | IdAllocator.layer, | |
| 14622 | McpProviderSessions.layer, | |
| 14623 | ScriptProgress.layerUnavailable, | |
| 14624 | NodeServices.layer, | |
| 14625 | ), | |
| 13994 | 14626 | ), |
| 13995 | 14627 | ), |
| 13996 | 14628 | ); |
| ... | ... | @@ -14029,7 +14661,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { |
| 14029 | 14661 | }).pipe( |
| 14030 | 14662 | Effect.scoped, |
| 14031 | 14663 | Effect.provide( |
| 14032 | Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), | |
| 14664 | Layer.mergeAll( | |
| 14665 | IdAllocator.layer, | |
| 14666 | McpProviderSessions.layer, | |
| 14667 | ScriptProgress.layerUnavailable, | |
| 14668 | NodeServices.layer, | |
| 14669 | ), | |
| 14033 | 14670 | ), |
| 14034 | 14671 | ), |
| 14035 | 14672 | ); |
| ... | ... | @@ -14068,7 +14705,12 @@ describe("ClaudeAdapterV2 background wake turns", () => { |
| 14068 | 14705 | }).pipe( |
| 14069 | 14706 | Effect.scoped, |
| 14070 | 14707 | Effect.provide( |
| 14071 | Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), | |
| 14708 | Layer.mergeAll( | |
| 14709 | IdAllocator.layer, | |
| 14710 | McpProviderSessions.layer, | |
| 14711 | ScriptProgress.layerUnavailable, | |
| 14712 | NodeServices.layer, | |
| 14713 | ), | |
| 14072 | 14714 | ), |
| 14073 | 14715 | ), |
| 14074 | 14716 | ); |
apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts+8-1| ... | ... | @@ -1,3 +1,4 @@ |
| 1 | import * as ScriptProgress from "@t3tools/provider-core/server/ScriptProgress"; | |
| 1 | 2 | import { makeProviderTextDeltaCoalescer } from "./ProviderTextDeltaCoalescer.ts"; |
| 2 | 3 | import { |
| 3 | 4 | dynamicToolTitle, |
| ... | ... | @@ -3464,6 +3465,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( |
| 3464 | 3465 | adapterOptions: ClaudeAdapterV2Options, |
| 3465 | 3466 | ) { |
| 3466 | 3467 | const mcpSessions = yield* McpProviderSessions.McpProviderSessions; |
| 3468 | const scriptProgress = yield* ScriptProgress.ScriptProgress; | |
| 3467 | 3469 | const { attachmentsDir, fileSystem, path, crypto, idAllocator, queryRunner } = adapterOptions; |
| 3468 | 3470 | // Subagent model names and effort levels, including the instance's custom models. |
| 3469 | 3471 | const subagentModelCatalog = scopeClaudeModelCatalog( |
| ... | ... | @@ -8548,7 +8550,10 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( |
| 8548 | 8550 | cwd: turnInput.runtimePolicy.cwd, |
| 8549 | 8551 | attachmentsDir, |
| 8550 | 8552 | settings: adapterOptions.settings, |
| 8551 | environment: adapterOptions.environment, | |
| 8553 | environment: ScriptProgress.withScriptProgressEnvironment( | |
| 8554 | adapterOptions.environment, | |
| 8555 | yield* scriptProgress.environmentForThread(turnInput.threadId), | |
| 8556 | ), | |
| 8552 | 8557 | tools: queryPolicy.tools ?? CLAUDE_CODE_PRESET_TOOLS, |
| 8553 | 8558 | ...(mcpOverrides.allowedTools === undefined |
| 8554 | 8559 | ? {} |
| ... | ... | @@ -9515,6 +9520,7 @@ export type ClaudeAdapterV2DriverEnv = |
| 9515 | 9520 | | Crypto.Crypto |
| 9516 | 9521 | | FileSystem.FileSystem |
| 9517 | 9522 | | IdAllocator.IdAllocatorV2 |
| 9523 | | ScriptProgress.ScriptProgress | |
| 9518 | 9524 | | McpProviderSessions.McpProviderSessions |
| 9519 | 9525 | | Path.Path |
| 9520 | 9526 | | ServerConfig.ServerConfig; |
| ... | ... | @@ -9608,6 +9614,7 @@ const layer: Layer.Layer< |
| 9608 | 9614 | | Crypto.Crypto |
| 9609 | 9615 | | FileSystem.FileSystem |
| 9610 | 9616 | | IdAllocator.IdAllocatorV2 |
| 9617 | | ScriptProgress.ScriptProgress | |
| 9611 | 9618 | | McpProviderSessions.McpProviderSessions |
| 9612 | 9619 | | Path.Path |
| 9613 | 9620 | | ServerConfig.ServerConfig |
apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts+501-67| ... | ... | @@ -1,3 +1,4 @@ |
| 1 | import * as ScriptProgress from "@t3tools/provider-core/server/ScriptProgress"; | |
| 1 | 2 | import * as NodeOS from "node:os"; |
| 2 | 3 | |
| 3 | 4 | import { historyResponseItems } from "@t3tools/provider-core/server/handoffBudget"; |
| ... | ... | @@ -826,7 +827,12 @@ describe("CodexAdapterV2 process spawning", () => { |
| 826 | 827 | assert.deepEqual(spawnedCommands, [path.join(NodeOS.homedir(), "bin", "codex")]); |
| 827 | 828 | }).pipe( |
| 828 | 829 | Effect.provide( |
| 829 | Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), | |
| 830 | Layer.mergeAll( | |
| 831 | IdAllocator.layer, | |
| 832 | McpProviderSessions.layer, | |
| 833 | ScriptProgress.layerUnavailable, | |
| 834 | NodeServices.layer, | |
| 835 | ), | |
| 830 | 836 | ), |
| 831 | 837 | Effect.provideService(HostProcess.Platform, "linux"), |
| 832 | 838 | ), |
| ... | ... | @@ -1864,6 +1870,104 @@ describe("CodexAdapterV2 session initialize", () => { |
| 1864 | 1870 | ); |
| 1865 | 1871 | }).pipe( |
| 1866 | 1872 | Effect.scoped, |
| 1873 | Effect.provide( | |
| 1874 | Layer.mergeAll( | |
| 1875 | IdAllocator.layer, | |
| 1876 | McpProviderSessions.layer, | |
| 1877 | ScriptProgress.layerUnavailable, | |
| 1878 | NodeServices.layer, | |
| 1879 | ), | |
| 1880 | ), | |
| 1881 | ), | |
| 1882 | ); | |
| 1883 | ||
| 1884 | it.effect("keeps progress credentials local to each Codex thread on a shared app-server", () => | |
| 1885 | Effect.gen(function* () { | |
| 1886 | const progressEnvironment = (threadId: string) => ({ | |
| 1887 | NODE_OPTIONS: "--import=file:///private/t3-progress.mjs", | |
| 1888 | T3_SCRIPT_PROGRESS_ENDPOINT: "http://127.0.0.1:1234/progress", | |
| 1889 | T3_SCRIPT_PROGRESS_TOKEN: threadId, | |
| 1890 | }); | |
| 1891 | const entries: Array<CodexReplay.CodexAppServerReplayEntry> = replayPreamble( | |
| 1892 | "progress-first", | |
| 1893 | ).slice(0, 3); | |
| 1894 | for (const [index, nativeId] of ["progress-first", "progress-second"].entries()) { | |
| 1895 | const readId = index * 2 + 2; | |
| 1896 | entries.push( | |
| 1897 | { | |
| 1898 | type: "expect_outbound", | |
| 1899 | label: "config/read", | |
| 1900 | frame: { | |
| 1901 | id: readId, | |
| 1902 | method: "config/read", | |
| 1903 | params: { cwd: "/workspace", includeLayers: false }, | |
| 1904 | }, | |
| 1905 | }, | |
| 1906 | { | |
| 1907 | type: "emit_inbound", | |
| 1908 | label: "config/read", | |
| 1909 | frame: { | |
| 1910 | id: readId, | |
| 1911 | result: { | |
| 1912 | origins: {}, | |
| 1913 | config: { | |
| 1914 | shell_environment_policy: { | |
| 1915 | set: { KEEP_ME: "yes", NODE_OPTIONS: "--trace-warnings" }, | |
| 1916 | include_only: ["PATH"], | |
| 1917 | exclude: ["SECRET_*"], | |
| 1918 | }, | |
| 1919 | }, | |
| 1920 | }, | |
| 1921 | }, | |
| 1922 | }, | |
| 1923 | { | |
| 1924 | type: "expect_outbound", | |
| 1925 | label: "thread/start", | |
| 1926 | frame: { | |
| 1927 | id: readId + 1, | |
| 1928 | method: "thread/start", | |
| 1929 | params: { | |
| 1930 | config: { | |
| 1931 | ...CodexAdapterV2.CODEX_THREAD_CONFIG, | |
| 1932 | "shell_environment_policy.set": { | |
| 1933 | KEEP_ME: "yes", | |
| 1934 | ...progressEnvironment(nativeId), | |
| 1935 | NODE_OPTIONS: "--import=file:///private/t3-progress.mjs --trace-warnings", | |
| 1936 | }, | |
| 1937 | "shell_environment_policy.include_only": [ | |
| 1938 | "PATH", | |
| 1939 | "NODE_OPTIONS", | |
| 1940 | "T3_SCRIPT_PROGRESS_ENDPOINT", | |
| 1941 | "T3_SCRIPT_PROGRESS_TOKEN", | |
| 1942 | ], | |
| 1943 | }, | |
| 1944 | }, | |
| 1945 | }, | |
| 1946 | }, | |
| 1947 | ...replayPreamble(nativeId) | |
| 1948 | .slice(4, 5) | |
| 1949 | .map((entry) => withReplayRequestId(entry, readId + 1)), | |
| 1950 | ); | |
| 1951 | } | |
| 1952 | const session = yield* openReplaySession( | |
| 1953 | makeCodexReplayTranscript({ scenario: "thread-local-progress", entries }), | |
| 1954 | ); | |
| 1955 | const first = yield* session.ensureThread("progress-first"); | |
| 1956 | const second = yield* session.ensureThread("progress-second"); | |
| 1957 | assert.equal(first.nativeThreadRef?.nativeId, "progress-first"); | |
| 1958 | assert.equal(second.nativeThreadRef?.nativeId, "progress-second"); | |
| 1959 | assert.equal(session.initializeRequests(), 1); | |
| 1960 | }).pipe( | |
| 1961 | Effect.scoped, | |
| 1962 | Effect.provideService(ScriptProgress.ScriptProgress, { | |
| 1963 | environmentForThread: (threadId) => | |
| 1964 | Effect.succeed({ | |
| 1965 | NODE_OPTIONS: "--import=file:///private/t3-progress.mjs", | |
| 1966 | T3_SCRIPT_PROGRESS_ENDPOINT: "http://127.0.0.1:1234/progress", | |
| 1967 | T3_SCRIPT_PROGRESS_TOKEN: threadId, | |
| 1968 | }), | |
| 1969 | subscribe: () => Stream.empty, | |
| 1970 | }), | |
| 1867 | 1971 | Effect.provide( |
| 1868 | 1972 | Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), |
| 1869 | 1973 | ), |
| ... | ... | @@ -1896,7 +2000,12 @@ describe("CodexAdapterV2 session initialize", () => { |
| 1896 | 2000 | }).pipe( |
| 1897 | 2001 | Effect.scoped, |
| 1898 | 2002 | Effect.provide( |
| 1899 | Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), | |
| 2003 | Layer.mergeAll( | |
| 2004 | IdAllocator.layer, | |
| 2005 | McpProviderSessions.layer, | |
| 2006 | ScriptProgress.layerUnavailable, | |
| 2007 | NodeServices.layer, | |
| 2008 | ), | |
| 1900 | 2009 | ), |
| 1901 | 2010 | ), |
| 1902 | 2011 | ); |
| ... | ... | @@ -1944,7 +2053,12 @@ describe("CodexAdapterV2 session initialize", () => { |
| 1944 | 2053 | }).pipe( |
| 1945 | 2054 | Effect.scoped, |
| 1946 | 2055 | Effect.provide( |
| 1947 | Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), | |
| 2056 | Layer.mergeAll( | |
| 2057 | IdAllocator.layer, | |
| 2058 | McpProviderSessions.layer, | |
| 2059 | ScriptProgress.layerUnavailable, | |
| 2060 | NodeServices.layer, | |
| 2061 | ), | |
| 1948 | 2062 | ), |
| 1949 | 2063 | ), |
| 1950 | 2064 | ); |
| ... | ... | @@ -2190,7 +2304,12 @@ describe("CodexAdapterV2 post-settle continuation", () => { |
| 2190 | 2304 | }).pipe( |
| 2191 | 2305 | Effect.scoped, |
| 2192 | 2306 | Effect.provide( |
| 2193 | Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), | |
| 2307 | Layer.mergeAll( | |
| 2308 | IdAllocator.layer, | |
| 2309 | McpProviderSessions.layer, | |
| 2310 | ScriptProgress.layerUnavailable, | |
| 2311 | NodeServices.layer, | |
| 2312 | ), | |
| 2194 | 2313 | ), |
| 2195 | 2314 | ), |
| 2196 | 2315 | ); |
| ... | ... | @@ -2231,7 +2350,12 @@ describe("CodexAdapterV2 post-settle continuation", () => { |
| 2231 | 2350 | }).pipe( |
| 2232 | 2351 | Effect.scoped, |
| 2233 | 2352 | Effect.provide( |
| 2234 | Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), | |
| 2353 | Layer.mergeAll( | |
| 2354 | IdAllocator.layer, | |
| 2355 | McpProviderSessions.layer, | |
| 2356 | ScriptProgress.layerUnavailable, | |
| 2357 | NodeServices.layer, | |
| 2358 | ), | |
| 2235 | 2359 | ), |
| 2236 | 2360 | ), |
| 2237 | 2361 | ); |
| ... | ... | @@ -2274,7 +2398,12 @@ describe("CodexAdapterV2 post-settle continuation", () => { |
| 2274 | 2398 | }).pipe( |
| 2275 | 2399 | Effect.scoped, |
| 2276 | 2400 | Effect.provide( |
| 2277 | Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), | |
| 2401 | Layer.mergeAll( | |
| 2402 | IdAllocator.layer, | |
| 2403 | McpProviderSessions.layer, | |
| 2404 | ScriptProgress.layerUnavailable, | |
| 2405 | NodeServices.layer, | |
| 2406 | ), | |
| 2278 | 2407 | ), |
| 2279 | 2408 | ), |
| 2280 | 2409 | ); |
| ... | ... | @@ -2313,7 +2442,12 @@ describe("CodexAdapterV2 post-settle continuation", () => { |
| 2313 | 2442 | }).pipe( |
| 2314 | 2443 | Effect.scoped, |
| 2315 | 2444 | Effect.provide( |
| 2316 | Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), | |
| 2445 | Layer.mergeAll( | |
| 2446 | IdAllocator.layer, | |
| 2447 | McpProviderSessions.layer, | |
| 2448 | ScriptProgress.layerUnavailable, | |
| 2449 | NodeServices.layer, | |
| 2450 | ), | |
| 2317 | 2451 | ), |
| 2318 | 2452 | ), |
| 2319 | 2453 | ); |
| ... | ... | @@ -2412,7 +2546,12 @@ describe("CodexAdapterV2 post-settle continuation", () => { |
| 2412 | 2546 | }).pipe( |
| 2413 | 2547 | Effect.scoped, |
| 2414 | 2548 | Effect.provide( |
| 2415 | Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), | |
| 2549 | Layer.mergeAll( | |
| 2550 | IdAllocator.layer, | |
| 2551 | McpProviderSessions.layer, | |
| 2552 | ScriptProgress.layerUnavailable, | |
| 2553 | NodeServices.layer, | |
| 2554 | ), | |
| 2416 | 2555 | ), |
| 2417 | 2556 | ), |
| 2418 | 2557 | ); |
| ... | ... | @@ -2499,7 +2638,12 @@ describe("CodexAdapterV2 post-settle continuation", () => { |
| 2499 | 2638 | }).pipe( |
| 2500 | 2639 | Effect.scoped, |
| 2501 | 2640 | Effect.provide( |
| 2502 | Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), | |
| 2641 | Layer.mergeAll( | |
| 2642 | IdAllocator.layer, | |
| 2643 | McpProviderSessions.layer, | |
| 2644 | ScriptProgress.layerUnavailable, | |
| 2645 | NodeServices.layer, | |
| 2646 | ), | |
| 2503 | 2647 | ), |
| 2504 | 2648 | ), |
| 2505 | 2649 | ); |
| ... | ... | @@ -2564,7 +2708,12 @@ describe("CodexAdapterV2 post-settle continuation", () => { |
| 2564 | 2708 | }).pipe( |
| 2565 | 2709 | Effect.scoped, |
| 2566 | 2710 | Effect.provide( |
| 2567 | Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), | |
| 2711 | Layer.mergeAll( | |
| 2712 | IdAllocator.layer, | |
| 2713 | McpProviderSessions.layer, | |
| 2714 | ScriptProgress.layerUnavailable, | |
| 2715 | NodeServices.layer, | |
| 2716 | ), | |
| 2568 | 2717 | ), |
| 2569 | 2718 | ), |
| 2570 | 2719 | ); |
| ... | ... | @@ -2628,7 +2777,12 @@ describe("CodexAdapterV2 post-settle continuation", () => { |
| 2628 | 2777 | }).pipe( |
| 2629 | 2778 | Effect.scoped, |
| 2630 | 2779 | Effect.provide( |
| 2631 | Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), | |
| 2780 | Layer.mergeAll( | |
| 2781 | IdAllocator.layer, | |
| 2782 | McpProviderSessions.layer, | |
| 2783 | ScriptProgress.layerUnavailable, | |
| 2784 | NodeServices.layer, | |
| 2785 | ), | |
| 2632 | 2786 | ), |
| 2633 | 2787 | ), |
| 2634 | 2788 | ); |
| ... | ... | @@ -2782,7 +2936,12 @@ describe("CodexAdapterV2 post-settle continuation", () => { |
| 2782 | 2936 | assert.isEmpty(assistantMessages(harness.events)); |
| 2783 | 2937 | }).pipe( |
| 2784 | 2938 | Effect.provide( |
| 2785 | Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), | |
| 2939 | Layer.mergeAll( | |
| 2940 | IdAllocator.layer, | |
| 2941 | McpProviderSessions.layer, | |
| 2942 | ScriptProgress.layerUnavailable, | |
| 2943 | NodeServices.layer, | |
| 2944 | ), | |
| 2786 | 2945 | ), |
| 2787 | 2946 | ), |
| 2788 | 2947 | ), |
| ... | ... | @@ -2992,7 +3151,12 @@ describe("CodexAdapterV2 post-settle continuation", () => { |
| 2992 | 3151 | assert.lengthOf(requests(), 4); |
| 2993 | 3152 | }).pipe( |
| 2994 | 3153 | Effect.provide( |
| 2995 | Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), | |
| 3154 | Layer.mergeAll( | |
| 3155 | IdAllocator.layer, | |
| 3156 | McpProviderSessions.layer, | |
| 3157 | ScriptProgress.layerUnavailable, | |
| 3158 | NodeServices.layer, | |
| 3159 | ), | |
| 2996 | 3160 | ), |
| 2997 | 3161 | ), |
| 2998 | 3162 | ), |
| ... | ... | @@ -3090,7 +3254,12 @@ describe("CodexAdapterV2 post-settle continuation", () => { |
| 3090 | 3254 | assert.equal(harness.terminalEvents()[0]?.status, "completed"); |
| 3091 | 3255 | }).pipe( |
| 3092 | 3256 | Effect.provide( |
| 3093 | Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), | |
| 3257 | Layer.mergeAll( | |
| 3258 | IdAllocator.layer, | |
| 3259 | McpProviderSessions.layer, | |
| 3260 | ScriptProgress.layerUnavailable, | |
| 3261 | NodeServices.layer, | |
| 3262 | ), | |
| 3094 | 3263 | ), |
| 3095 | 3264 | ), |
| 3096 | 3265 | ), |
| ... | ... | @@ -3170,7 +3339,12 @@ describe("CodexAdapterV2 post-settle continuation", () => { |
| 3170 | 3339 | assert.equal(harness.terminalEvents()[0]?.status, "completed"); |
| 3171 | 3340 | }).pipe( |
| 3172 | 3341 | Effect.provide( |
| 3173 | Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), | |
| 3342 | Layer.mergeAll( | |
| 3343 | IdAllocator.layer, | |
| 3344 | McpProviderSessions.layer, | |
| 3345 | ScriptProgress.layerUnavailable, | |
| 3346 | NodeServices.layer, | |
| 3347 | ), | |
| 3174 | 3348 | ), |
| 3175 | 3349 | ), |
| 3176 | 3350 | ), |
| ... | ... | @@ -3222,7 +3396,12 @@ describe("CodexAdapterV2 post-settle continuation", () => { |
| 3222 | 3396 | assert.equal(DateTime.toEpochMillis(resumed.updatedAt), 1782622450000); |
| 3223 | 3397 | }).pipe( |
| 3224 | 3398 | Effect.provide( |
| 3225 | Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), | |
| 3399 | Layer.mergeAll( | |
| 3400 | IdAllocator.layer, | |
| 3401 | McpProviderSessions.layer, | |
| 3402 | ScriptProgress.layerUnavailable, | |
| 3403 | NodeServices.layer, | |
| 3404 | ), | |
| 3226 | 3405 | ), |
| 3227 | 3406 | ), |
| 3228 | 3407 | ), |
| ... | ... | @@ -3381,7 +3560,12 @@ describe("CodexAdapterV2 post-settle continuation", () => { |
| 3381 | 3560 | assert.equal(DateTime.toEpochMillis(resumed.updatedAt), 1782622450000); |
| 3382 | 3561 | }).pipe( |
| 3383 | 3562 | Effect.provide( |
| 3384 | Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), | |
| 3563 | Layer.mergeAll( | |
| 3564 | IdAllocator.layer, | |
| 3565 | McpProviderSessions.layer, | |
| 3566 | ScriptProgress.layerUnavailable, | |
| 3567 | NodeServices.layer, | |
| 3568 | ), | |
| 3385 | 3569 | ), |
| 3386 | 3570 | ), |
| 3387 | 3571 | ), |
| ... | ... | @@ -3467,7 +3651,12 @@ describe("CodexAdapterV2 post-settle continuation", () => { |
| 3467 | 3651 | assert.equal(resumed.nativeThreadRef?.nativeId, nativeThreadId); |
| 3468 | 3652 | }).pipe( |
| 3469 | 3653 | Effect.provide( |
| 3470 | Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), | |
| 3654 | Layer.mergeAll( | |
| 3655 | IdAllocator.layer, | |
| 3656 | McpProviderSessions.layer, | |
| 3657 | ScriptProgress.layerUnavailable, | |
| 3658 | NodeServices.layer, | |
| 3659 | ), | |
| 3471 | 3660 | ), |
| 3472 | 3661 | ), |
| 3473 | 3662 | ), |
| ... | ... | @@ -3619,7 +3808,12 @@ describe("CodexAdapterV2 post-settle continuation", () => { |
| 3619 | 3808 | assert.isAbove(terminalIndex, resumedCommandIndex); |
| 3620 | 3809 | }).pipe( |
| 3621 | 3810 | Effect.provide( |
| 3622 | Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), | |
| 3811 | Layer.mergeAll( | |
| 3812 | IdAllocator.layer, | |
| 3813 | McpProviderSessions.layer, | |
| 3814 | ScriptProgress.layerUnavailable, | |
| 3815 | NodeServices.layer, | |
| 3816 | ), | |
| 3623 | 3817 | ), |
| 3624 | 3818 | ), |
| 3625 | 3819 | ), |
| ... | ... | @@ -3722,7 +3916,12 @@ describe("CodexAdapterV2 post-settle continuation", () => { |
| 3722 | 3916 | ); |
| 3723 | 3917 | }).pipe( |
| 3724 | 3918 | Effect.provide( |
| 3725 | Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), | |
| 3919 | Layer.mergeAll( | |
| 3920 | IdAllocator.layer, | |
| 3921 | McpProviderSessions.layer, | |
| 3922 | ScriptProgress.layerUnavailable, | |
| 3923 | NodeServices.layer, | |
| 3924 | ), | |
| 3726 | 3925 | ), |
| 3727 | 3926 | ), |
| 3728 | 3927 | ), |
| ... | ... | @@ -3890,7 +4089,12 @@ describe("CodexAdapterV2 post-settle continuation", () => { |
| 3890 | 4089 | assert.deepEqual(assistantMessages(harness.events), []); |
| 3891 | 4090 | }).pipe( |
| 3892 | 4091 | Effect.provide( |
| 3893 | Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), | |
| 4092 | Layer.mergeAll( | |
| 4093 | IdAllocator.layer, | |
| 4094 | McpProviderSessions.layer, | |
| 4095 | ScriptProgress.layerUnavailable, | |
| 4096 | NodeServices.layer, | |
| 4097 | ), | |
| 3894 | 4098 | ), |
| 3895 | 4099 | ), |
| 3896 | 4100 | ), |
| ... | ... | @@ -3978,7 +4182,12 @@ describe("CodexAdapterV2 post-settle continuation", () => { |
| 3978 | 4182 | ); |
| 3979 | 4183 | }).pipe( |
| 3980 | 4184 | Effect.provide( |
| 3981 | Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), | |
| 4185 | Layer.mergeAll( | |
| 4186 | IdAllocator.layer, | |
| 4187 | McpProviderSessions.layer, | |
| 4188 | ScriptProgress.layerUnavailable, | |
| 4189 | NodeServices.layer, | |
| 4190 | ), | |
| 3982 | 4191 | ), |
| 3983 | 4192 | ), |
| 3984 | 4193 | ), |
| ... | ... | @@ -4111,7 +4320,12 @@ describe("CodexAdapterV2 post-settle continuation", () => { |
| 4111 | 4320 | ); |
| 4112 | 4321 | }).pipe( |
| 4113 | 4322 | Effect.provide( |
| 4114 | Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), | |
| 4323 | Layer.mergeAll( | |
| 4324 | IdAllocator.layer, | |
| 4325 | McpProviderSessions.layer, | |
| 4326 | ScriptProgress.layerUnavailable, | |
| 4327 | NodeServices.layer, | |
| 4328 | ), | |
| 4115 | 4329 | ), |
| 4116 | 4330 | ), |
| 4117 | 4331 | ), |
| ... | ... | @@ -4158,7 +4372,12 @@ describe("CodexAdapterV2 post-settle continuation", () => { |
| 4158 | 4372 | ); |
| 4159 | 4373 | }).pipe( |
| 4160 | 4374 | Effect.provide( |
| 4161 | Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), | |
| 4375 | Layer.mergeAll( | |
| 4376 | IdAllocator.layer, | |
| 4377 | McpProviderSessions.layer, | |
| 4378 | ScriptProgress.layerUnavailable, | |
| 4379 | NodeServices.layer, | |
| 4380 | ), | |
| 4162 | 4381 | ), |
| 4163 | 4382 | ), |
| 4164 | 4383 | ), |
| ... | ... | @@ -4206,7 +4425,12 @@ describe("CodexAdapterV2 post-settle continuation", () => { |
| 4206 | 4425 | ); |
| 4207 | 4426 | }).pipe( |
| 4208 | 4427 | Effect.provide( |
| 4209 | Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), | |
| 4428 | Layer.mergeAll( | |
| 4429 | IdAllocator.layer, | |
| 4430 | McpProviderSessions.layer, | |
| 4431 | ScriptProgress.layerUnavailable, | |
| 4432 | NodeServices.layer, | |
| 4433 | ), | |
| 4210 | 4434 | ), |
| 4211 | 4435 | ), |
| 4212 | 4436 | ), |
| ... | ... | @@ -4329,7 +4553,12 @@ describe("CodexAdapterV2 post-settle continuation", () => { |
| 4329 | 4553 | ); |
| 4330 | 4554 | }).pipe( |
| 4331 | 4555 | Effect.provide( |
| 4332 | Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), | |
| 4556 | Layer.mergeAll( | |
| 4557 | IdAllocator.layer, | |
| 4558 | McpProviderSessions.layer, | |
| 4559 | ScriptProgress.layerUnavailable, | |
| 4560 | NodeServices.layer, | |
| 4561 | ), | |
| 4333 | 4562 | ), |
| 4334 | 4563 | ), |
| 4335 | 4564 | ), |
| ... | ... | @@ -4361,7 +4590,12 @@ describe("CodexAdapterV2 post-settle continuation", () => { |
| 4361 | 4590 | ); |
| 4362 | 4591 | }).pipe( |
| 4363 | 4592 | Effect.provide( |
| 4364 | Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), | |
| 4593 | Layer.mergeAll( | |
| 4594 | IdAllocator.layer, | |
| 4595 | McpProviderSessions.layer, | |
| 4596 | ScriptProgress.layerUnavailable, | |
| 4597 | NodeServices.layer, | |
| 4598 | ), | |
| 4365 | 4599 | ), |
| 4366 | 4600 | ), |
| 4367 | 4601 | ), |
| ... | ... | @@ -4394,7 +4628,12 @@ describe("CodexAdapterV2 post-settle continuation", () => { |
| 4394 | 4628 | ); |
| 4395 | 4629 | }).pipe( |
| 4396 | 4630 | Effect.provide( |
| 4397 | Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), | |
| 4631 | Layer.mergeAll( | |
| 4632 | IdAllocator.layer, | |
| 4633 | McpProviderSessions.layer, | |
| 4634 | ScriptProgress.layerUnavailable, | |
| 4635 | NodeServices.layer, | |
| 4636 | ), | |
| 4398 | 4637 | ), |
| 4399 | 4638 | ), |
| 4400 | 4639 | ), |
| ... | ... | @@ -4427,7 +4666,12 @@ describe("CodexAdapterV2 post-settle continuation", () => { |
| 4427 | 4666 | ); |
| 4428 | 4667 | }).pipe( |
| 4429 | 4668 | Effect.provide( |
| 4430 | Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), | |
| 4669 | Layer.mergeAll( | |
| 4670 | IdAllocator.layer, | |
| 4671 | McpProviderSessions.layer, | |
| 4672 | ScriptProgress.layerUnavailable, | |
| 4673 | NodeServices.layer, | |
| 4674 | ), | |
| 4431 | 4675 | ), |
| 4432 | 4676 | ), |
| 4433 | 4677 | ), |
| ... | ... | @@ -4460,7 +4704,12 @@ describe("CodexAdapterV2 post-settle continuation", () => { |
| 4460 | 4704 | ); |
| 4461 | 4705 | }).pipe( |
| 4462 | 4706 | Effect.provide( |
| 4463 | Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), | |
| 4707 | Layer.mergeAll( | |
| 4708 | IdAllocator.layer, | |
| 4709 | McpProviderSessions.layer, | |
| 4710 | ScriptProgress.layerUnavailable, | |
| 4711 | NodeServices.layer, | |
| 4712 | ), | |
| 4464 | 4713 | ), |
| 4465 | 4714 | ), |
| 4466 | 4715 | ), |
| ... | ... | @@ -4493,7 +4742,12 @@ describe("CodexAdapterV2 post-settle continuation", () => { |
| 4493 | 4742 | ); |
| 4494 | 4743 | }).pipe( |
| 4495 | 4744 | Effect.provide( |
| 4496 | Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), | |
| 4745 | Layer.mergeAll( | |
| 4746 | IdAllocator.layer, | |
| 4747 | McpProviderSessions.layer, | |
| 4748 | ScriptProgress.layerUnavailable, | |
| 4749 | NodeServices.layer, | |
| 4750 | ), | |
| 4497 | 4751 | ), |
| 4498 | 4752 | ), |
| 4499 | 4753 | ), |
| ... | ... | @@ -4526,7 +4780,12 @@ describe("CodexAdapterV2 post-settle continuation", () => { |
| 4526 | 4780 | ); |
| 4527 | 4781 | }).pipe( |
| 4528 | 4782 | Effect.provide( |
| 4529 | Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), | |
| 4783 | Layer.mergeAll( | |
| 4784 | IdAllocator.layer, | |
| 4785 | McpProviderSessions.layer, | |
| 4786 | ScriptProgress.layerUnavailable, | |
| 4787 | NodeServices.layer, | |
| 4788 | ), | |
| 4530 | 4789 | ), |
| 4531 | 4790 | ), |
| 4532 | 4791 | ), |
| ... | ... | @@ -4686,7 +4945,12 @@ describe("CodexAdapterV2 post-settle continuation", () => { |
| 4686 | 4945 | assert.isFalse(yield* harness.hasPendingBackgroundWork); |
| 4687 | 4946 | }).pipe( |
| 4688 | 4947 | Effect.provide( |
| 4689 | Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), | |
| 4948 | Layer.mergeAll( | |
| 4949 | IdAllocator.layer, | |
| 4950 | McpProviderSessions.layer, | |
| 4951 | ScriptProgress.layerUnavailable, | |
| 4952 | NodeServices.layer, | |
| 4953 | ), | |
| 4690 | 4954 | ), |
| 4691 | 4955 | ), |
| 4692 | 4956 | ), |
| ... | ... | @@ -4845,7 +5109,12 @@ describe("CodexAdapterV2 post-settle continuation", () => { |
| 4845 | 5109 | assert.lengthOf(harness.continuationRequests, 0); |
| 4846 | 5110 | }).pipe( |
| 4847 | 5111 | Effect.provide( |
| 4848 | Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), | |
| 5112 | Layer.mergeAll( | |
| 5113 | IdAllocator.layer, | |
| 5114 | McpProviderSessions.layer, | |
| 5115 | ScriptProgress.layerUnavailable, | |
| 5116 | NodeServices.layer, | |
| 5117 | ), | |
| 4849 | 5118 | ), |
| 4850 | 5119 | ), |
| 4851 | 5120 | ); |
| ... | ... | @@ -5209,7 +5478,12 @@ describe("CodexAdapterV2 post-settle continuation", () => { |
| 5209 | 5478 | assert.lengthOf(harness.terminalEvents(), 1); |
| 5210 | 5479 | }).pipe( |
| 5211 | 5480 | Effect.provide( |
| 5212 | Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), | |
| 5481 | Layer.mergeAll( | |
| 5482 | IdAllocator.layer, | |
| 5483 | McpProviderSessions.layer, | |
| 5484 | ScriptProgress.layerUnavailable, | |
| 5485 | NodeServices.layer, | |
| 5486 | ), | |
| 5213 | 5487 | ), |
| 5214 | 5488 | ), |
| 5215 | 5489 | ), |
| ... | ... | @@ -5502,7 +5776,12 @@ describe("CodexAdapterV2 post-settle continuation", () => { |
| 5502 | 5776 | assert.equal(commandUpdates[commandUpdates.length - 1]?.turnItem.status, "interrupted"); |
| 5503 | 5777 | }).pipe( |
| 5504 | 5778 | Effect.provide( |
| 5505 | Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), | |
| 5779 | Layer.mergeAll( | |
| 5780 | IdAllocator.layer, | |
| 5781 | McpProviderSessions.layer, | |
| 5782 | ScriptProgress.layerUnavailable, | |
| 5783 | NodeServices.layer, | |
| 5784 | ), | |
| 5506 | 5785 | ), |
| 5507 | 5786 | ), |
| 5508 | 5787 | ), |
| ... | ... | @@ -5734,7 +6013,12 @@ describe("CodexAdapterV2 post-settle continuation", () => { |
| 5734 | 6013 | assert.isFalse(yield* harness.hasPendingBackgroundWork); |
| 5735 | 6014 | }).pipe( |
| 5736 | 6015 | Effect.provide( |
| 5737 | Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), | |
| 6016 | Layer.mergeAll( | |
| 6017 | IdAllocator.layer, | |
| 6018 | McpProviderSessions.layer, | |
| 6019 | ScriptProgress.layerUnavailable, | |
| 6020 | NodeServices.layer, | |
| 6021 | ), | |
| 5738 | 6022 | ), |
| 5739 | 6023 | ), |
| 5740 | 6024 | ), |
| ... | ... | @@ -5823,7 +6107,12 @@ describe("CodexAdapterV2 post-settle continuation", () => { |
| 5823 | 6107 | assert.isFalse(yield* harness.hasPendingBackgroundWork); |
| 5824 | 6108 | }).pipe( |
| 5825 | 6109 | Effect.provide( |
| 5826 | Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), | |
| 6110 | Layer.mergeAll( | |
| 6111 | IdAllocator.layer, | |
| 6112 | McpProviderSessions.layer, | |
| 6113 | ScriptProgress.layerUnavailable, | |
| 6114 | NodeServices.layer, | |
| 6115 | ), | |
| 5827 | 6116 | ), |
| 5828 | 6117 | ), |
| 5829 | 6118 | ), |
| ... | ... | @@ -5915,7 +6204,12 @@ describe("CodexAdapterV2 post-settle continuation", () => { |
| 5915 | 6204 | assert.isFalse(yield* harness.hasPendingBackgroundWork); |
| 5916 | 6205 | }).pipe( |
| 5917 | 6206 | Effect.provide( |
| 5918 | Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), | |
| 6207 | Layer.mergeAll( | |
| 6208 | IdAllocator.layer, | |
| 6209 | McpProviderSessions.layer, | |
| 6210 | ScriptProgress.layerUnavailable, | |
| 6211 | NodeServices.layer, | |
| 6212 | ), | |
| 5919 | 6213 | ), |
| 5920 | 6214 | ), |
| 5921 | 6215 | ), |
| ... | ... | @@ -6030,7 +6324,12 @@ describe("CodexAdapterV2 post-settle continuation", () => { |
| 6030 | 6324 | assert.isFalse(yield* harness.hasPendingBackgroundWork); |
| 6031 | 6325 | }).pipe( |
| 6032 | 6326 | Effect.provide( |
| 6033 | Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), | |
| 6327 | Layer.mergeAll( | |
| 6328 | IdAllocator.layer, | |
| 6329 | McpProviderSessions.layer, | |
| 6330 | ScriptProgress.layerUnavailable, | |
| 6331 | NodeServices.layer, | |
| 6332 | ), | |
| 6034 | 6333 | ), |
| 6035 | 6334 | ), |
| 6036 | 6335 | ), |
| ... | ... | @@ -6199,7 +6498,12 @@ describe("CodexAdapterV2 post-settle continuation", () => { |
| 6199 | 6498 | assert.isFalse(yield* harness.hasPendingBackgroundWork); |
| 6200 | 6499 | }).pipe( |
| 6201 | 6500 | Effect.provide( |
| 6202 | Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), | |
| 6501 | Layer.mergeAll( | |
| 6502 | IdAllocator.layer, | |
| 6503 | McpProviderSessions.layer, | |
| 6504 | ScriptProgress.layerUnavailable, | |
| 6505 | NodeServices.layer, | |
| 6506 | ), | |
| 6203 | 6507 | ), |
| 6204 | 6508 | ), |
| 6205 | 6509 | ), |
| ... | ... | @@ -6468,7 +6772,12 @@ describe("CodexAdapterV2 post-settle continuation", () => { |
| 6468 | 6772 | ); |
| 6469 | 6773 | }).pipe( |
| 6470 | 6774 | Effect.provide( |
| 6471 | Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), | |
| 6775 | Layer.mergeAll( | |
| 6776 | IdAllocator.layer, | |
| 6777 | McpProviderSessions.layer, | |
| 6778 | ScriptProgress.layerUnavailable, | |
| 6779 | NodeServices.layer, | |
| 6780 | ), | |
| 6472 | 6781 | ), |
| 6473 | 6782 | ), |
| 6474 | 6783 | ), |
| ... | ... | @@ -6579,7 +6888,12 @@ describe("CodexAdapterV2 post-settle continuation", () => { |
| 6579 | 6888 | assert.isFalse(yield* harness.hasPendingBackgroundWork); |
| 6580 | 6889 | }).pipe( |
| 6581 | 6890 | Effect.provide( |
| 6582 | Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), | |
| 6891 | Layer.mergeAll( | |
| 6892 | IdAllocator.layer, | |
| 6893 | McpProviderSessions.layer, | |
| 6894 | ScriptProgress.layerUnavailable, | |
| 6895 | NodeServices.layer, | |
| 6896 | ), | |
| 6583 | 6897 | ), |
| 6584 | 6898 | ), |
| 6585 | 6899 | ), |
| ... | ... | @@ -6819,7 +7133,12 @@ describe("CodexAdapterV2 post-settle continuation", () => { |
| 6819 | 7133 | if (scenario.name === "retry") assert.equal(terminal.retry?.attempt, 1); |
| 6820 | 7134 | }).pipe( |
| 6821 | 7135 | Effect.provide( |
| 6822 | Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), | |
| 7136 | Layer.mergeAll( | |
| 7137 | IdAllocator.layer, | |
| 7138 | McpProviderSessions.layer, | |
| 7139 | ScriptProgress.layerUnavailable, | |
| 7140 | NodeServices.layer, | |
| 7141 | ), | |
| 6823 | 7142 | ), |
| 6824 | 7143 | ), |
| 6825 | 7144 | ), |
| ... | ... | @@ -6921,7 +7240,12 @@ describe("CodexAdapterV2 post-settle continuation", () => { |
| 6921 | 7240 | assert.lengthOf(harness.continuationRequests, 0); |
| 6922 | 7241 | }).pipe( |
| 6923 | 7242 | Effect.provide( |
| 6924 | Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), | |
| 7243 | Layer.mergeAll( | |
| 7244 | IdAllocator.layer, | |
| 7245 | McpProviderSessions.layer, | |
| 7246 | ScriptProgress.layerUnavailable, | |
| 7247 | NodeServices.layer, | |
| 7248 | ), | |
| 6925 | 7249 | ), |
| 6926 | 7250 | ), |
| 6927 | 7251 | ), |
| ... | ... | @@ -7086,7 +7410,12 @@ describe("CodexAdapterV2 post-settle continuation", () => { |
| 7086 | 7410 | assert.isFalse(yield* harness.hasPendingBackgroundWork); |
| 7087 | 7411 | }).pipe( |
| 7088 | 7412 | Effect.provide( |
| 7089 | Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), | |
| 7413 | Layer.mergeAll( | |
| 7414 | IdAllocator.layer, | |
| 7415 | McpProviderSessions.layer, | |
| 7416 | ScriptProgress.layerUnavailable, | |
| 7417 | NodeServices.layer, | |
| 7418 | ), | |
| 7090 | 7419 | ), |
| 7091 | 7420 | ), |
| 7092 | 7421 | ), |
| ... | ... | @@ -7176,7 +7505,12 @@ describe("CodexAdapterV2 post-settle continuation", () => { |
| 7176 | 7505 | assert.isFalse(yield* harness.hasPendingBackgroundWork); |
| 7177 | 7506 | }).pipe( |
| 7178 | 7507 | Effect.provide( |
| 7179 | Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), | |
| 7508 | Layer.mergeAll( | |
| 7509 | IdAllocator.layer, | |
| 7510 | McpProviderSessions.layer, | |
| 7511 | ScriptProgress.layerUnavailable, | |
| 7512 | NodeServices.layer, | |
| 7513 | ), | |
| 7180 | 7514 | ), |
| 7181 | 7515 | ), |
| 7182 | 7516 | ), |
| ... | ... | @@ -7422,7 +7756,12 @@ describe("CodexAdapterV2 post-settle continuation", () => { |
| 7422 | 7756 | assert.isFalse(yield* harness.hasPendingBackgroundWork); |
| 7423 | 7757 | }).pipe( |
| 7424 | 7758 | Effect.provide( |
| 7425 | Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), | |
| 7759 | Layer.mergeAll( | |
| 7760 | IdAllocator.layer, | |
| 7761 | McpProviderSessions.layer, | |
| 7762 | ScriptProgress.layerUnavailable, | |
| 7763 | NodeServices.layer, | |
| 7764 | ), | |
| 7426 | 7765 | ), |
| 7427 | 7766 | ), |
| 7428 | 7767 | ), |
| ... | ... | @@ -7660,7 +7999,12 @@ describe("CodexAdapterV2 post-settle continuation", () => { |
| 7660 | 7999 | assert.equal(metadataRequests, name === "current Codex Sol" ? 1 : 2); |
| 7661 | 8000 | }).pipe( |
| 7662 | 8001 | Effect.provide( |
| 7663 | Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), | |
| 8002 | Layer.mergeAll( | |
| 8003 | IdAllocator.layer, | |
| 8004 | McpProviderSessions.layer, | |
| 8005 | ScriptProgress.layerUnavailable, | |
| 8006 | NodeServices.layer, | |
| 8007 | ), | |
| 7664 | 8008 | ), |
| 7665 | 8009 | ), |
| 7666 | 8010 | ), |
| ... | ... | @@ -7721,7 +8065,12 @@ describe("CodexAdapterV2 post-settle continuation", () => { |
| 7721 | 8065 | assert.deepEqual(metadataRequests, ["thread/read", "thread/resume"]); |
| 7722 | 8066 | }).pipe( |
| 7723 | 8067 | Effect.provide( |
| 7724 | Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), | |
| 8068 | Layer.mergeAll( | |
| 8069 | IdAllocator.layer, | |
| 8070 | McpProviderSessions.layer, | |
| 8071 | ScriptProgress.layerUnavailable, | |
| 8072 | NodeServices.layer, | |
| 8073 | ), | |
| 7725 | 8074 | ), |
| 7726 | 8075 | ), |
| 7727 | 8076 | ), |
| ... | ... | @@ -7824,7 +8173,12 @@ describe("CodexAdapterV2 post-settle continuation", () => { |
| 7824 | 8173 | ]); |
| 7825 | 8174 | }).pipe( |
| 7826 | 8175 | Effect.provide( |
| 7827 | Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), | |
| 8176 | Layer.mergeAll( | |
| 8177 | IdAllocator.layer, | |
| 8178 | McpProviderSessions.layer, | |
| 8179 | ScriptProgress.layerUnavailable, | |
| 8180 | NodeServices.layer, | |
| 8181 | ), | |
| 7828 | 8182 | ), |
| 7829 | 8183 | ), |
| 7830 | 8184 | ), |
| ... | ... | @@ -7890,7 +8244,12 @@ describe("CodexAdapterV2 post-settle continuation", () => { |
| 7890 | 8244 | assert.lengthOf(harness.continuationRequests, 0); |
| 7891 | 8245 | }).pipe( |
| 7892 | 8246 | Effect.provide( |
| 7893 | Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), | |
| 8247 | Layer.mergeAll( | |
| 8248 | IdAllocator.layer, | |
| 8249 | McpProviderSessions.layer, | |
| 8250 | ScriptProgress.layerUnavailable, | |
| 8251 | NodeServices.layer, | |
| 8252 | ), | |
| 7894 | 8253 | ), |
| 7895 | 8254 | ), |
| 7896 | 8255 | ), |
| ... | ... | @@ -7974,7 +8333,12 @@ describe("CodexAdapterV2 post-settle continuation", () => { |
| 7974 | 8333 | assert.equal(DateTime.toEpochMillis(row!.startedAt!), 1782622470000); |
| 7975 | 8334 | }).pipe( |
| 7976 | 8335 | Effect.provide( |
| 7977 | Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), | |
| 8336 | Layer.mergeAll( | |
| 8337 | IdAllocator.layer, | |
| 8338 | McpProviderSessions.layer, | |
| 8339 | ScriptProgress.layerUnavailable, | |
| 8340 | NodeServices.layer, | |
| 8341 | ), | |
| 7978 | 8342 | ), |
| 7979 | 8343 | ), |
| 7980 | 8344 | ), |
| ... | ... | @@ -8140,7 +8504,12 @@ describe("CodexAdapterV2 post-settle continuation", () => { |
| 8140 | 8504 | } |
| 8141 | 8505 | }).pipe( |
| 8142 | 8506 | Effect.provide( |
| 8143 | Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), | |
| 8507 | Layer.mergeAll( | |
| 8508 | IdAllocator.layer, | |
| 8509 | McpProviderSessions.layer, | |
| 8510 | ScriptProgress.layerUnavailable, | |
| 8511 | NodeServices.layer, | |
| 8512 | ), | |
| 8144 | 8513 | ), |
| 8145 | 8514 | ), |
| 8146 | 8515 | ), |
| ... | ... | @@ -8293,7 +8662,12 @@ describe("CodexAdapterV2 post-settle continuation", () => { |
| 8293 | 8662 | }).pipe( |
| 8294 | 8663 | Effect.scoped, |
| 8295 | 8664 | Effect.provide( |
| 8296 | Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), | |
| 8665 | Layer.mergeAll( | |
| 8666 | IdAllocator.layer, | |
| 8667 | McpProviderSessions.layer, | |
| 8668 | ScriptProgress.layerUnavailable, | |
| 8669 | NodeServices.layer, | |
| 8670 | ), | |
| 8297 | 8671 | ), |
| 8298 | 8672 | ), |
| 8299 | 8673 | ); |
| ... | ... | @@ -8437,7 +8811,12 @@ describe("CodexAdapterV2 post-settle continuation", () => { |
| 8437 | 8811 | }).pipe( |
| 8438 | 8812 | Effect.scoped, |
| 8439 | 8813 | Effect.provide( |
| 8440 | Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), | |
| 8814 | Layer.mergeAll( | |
| 8815 | IdAllocator.layer, | |
| 8816 | McpProviderSessions.layer, | |
| 8817 | ScriptProgress.layerUnavailable, | |
| 8818 | NodeServices.layer, | |
| 8819 | ), | |
| 8441 | 8820 | ), |
| 8442 | 8821 | ), |
| 8443 | 8822 | ); |
| ... | ... | @@ -8544,7 +8923,12 @@ describe("CodexAdapterV2 post-settle continuation", () => { |
| 8544 | 8923 | }).pipe( |
| 8545 | 8924 | Effect.scoped, |
| 8546 | 8925 | Effect.provide( |
| 8547 | Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), | |
| 8926 | Layer.mergeAll( | |
| 8927 | IdAllocator.layer, | |
| 8928 | McpProviderSessions.layer, | |
| 8929 | ScriptProgress.layerUnavailable, | |
| 8930 | NodeServices.layer, | |
| 8931 | ), | |
| 8548 | 8932 | ), |
| 8549 | 8933 | ), |
| 8550 | 8934 | ); |
| ... | ... | @@ -8605,7 +8989,12 @@ describe("CodexAdapterV2 post-settle continuation", () => { |
| 8605 | 8989 | }).pipe( |
| 8606 | 8990 | Effect.scoped, |
| 8607 | 8991 | Effect.provide( |
| 8608 | Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), | |
| 8992 | Layer.mergeAll( | |
| 8993 | IdAllocator.layer, | |
| 8994 | McpProviderSessions.layer, | |
| 8995 | ScriptProgress.layerUnavailable, | |
| 8996 | NodeServices.layer, | |
| 8997 | ), | |
| 8609 | 8998 | ), |
| 8610 | 8999 | ), |
| 8611 | 9000 | ); |
| ... | ... | @@ -8774,7 +9163,12 @@ describe("CodexAdapterV2 post-settle continuation", () => { |
| 8774 | 9163 | }).pipe( |
| 8775 | 9164 | Effect.scoped, |
| 8776 | 9165 | Effect.provide( |
| 8777 | Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), | |
| 9166 | Layer.mergeAll( | |
| 9167 | IdAllocator.layer, | |
| 9168 | McpProviderSessions.layer, | |
| 9169 | ScriptProgress.layerUnavailable, | |
| 9170 | NodeServices.layer, | |
| 9171 | ), | |
| 8778 | 9172 | ), |
| 8779 | 9173 | ), |
| 8780 | 9174 | ); |
| ... | ... | @@ -8823,7 +9217,12 @@ describe("CodexAdapterV2 post-settle continuation", () => { |
| 8823 | 9217 | }).pipe( |
| 8824 | 9218 | Effect.scoped, |
| 8825 | 9219 | Effect.provide( |
| 8826 | Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), | |
| 9220 | Layer.mergeAll( | |
| 9221 | IdAllocator.layer, | |
| 9222 | McpProviderSessions.layer, | |
| 9223 | ScriptProgress.layerUnavailable, | |
| 9224 | NodeServices.layer, | |
| 9225 | ), | |
| 8827 | 9226 | ), |
| 8828 | 9227 | ), |
| 8829 | 9228 | ); |
| ... | ... | @@ -8919,7 +9318,12 @@ describe("CodexAdapterV2 post-settle continuation", () => { |
| 8919 | 9318 | }).pipe( |
| 8920 | 9319 | Effect.scoped, |
| 8921 | 9320 | Effect.provide( |
| 8922 | Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), | |
| 9321 | Layer.mergeAll( | |
| 9322 | IdAllocator.layer, | |
| 9323 | McpProviderSessions.layer, | |
| 9324 | ScriptProgress.layerUnavailable, | |
| 9325 | NodeServices.layer, | |
| 9326 | ), | |
| 8923 | 9327 | ), |
| 8924 | 9328 | ), |
| 8925 | 9329 | ); |
| ... | ... | @@ -9003,7 +9407,12 @@ describe("CodexAdapterV2 post-settle continuation", () => { |
| 9003 | 9407 | }).pipe( |
| 9004 | 9408 | Effect.scoped, |
| 9005 | 9409 | Effect.provide( |
| 9006 | Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), | |
| 9410 | Layer.mergeAll( | |
| 9411 | IdAllocator.layer, | |
| 9412 | McpProviderSessions.layer, | |
| 9413 | ScriptProgress.layerUnavailable, | |
| 9414 | NodeServices.layer, | |
| 9415 | ), | |
| 9007 | 9416 | ), |
| 9008 | 9417 | ), |
| 9009 | 9418 | ); |
| ... | ... | @@ -9110,7 +9519,12 @@ describe("CodexAdapterV2 post-settle continuation", () => { |
| 9110 | 9519 | }).pipe( |
| 9111 | 9520 | Effect.scoped, |
| 9112 | 9521 | Effect.provide( |
| 9113 | Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), | |
| 9522 | Layer.mergeAll( | |
| 9523 | IdAllocator.layer, | |
| 9524 | McpProviderSessions.layer, | |
| 9525 | ScriptProgress.layerUnavailable, | |
| 9526 | NodeServices.layer, | |
| 9527 | ), | |
| 9114 | 9528 | ), |
| 9115 | 9529 | ), |
| 9116 | 9530 | ); |
| ... | ... | @@ -9197,7 +9611,12 @@ describe("CodexAdapterV2 post-settle continuation", () => { |
| 9197 | 9611 | }).pipe( |
| 9198 | 9612 | Effect.scoped, |
| 9199 | 9613 | Effect.provide( |
| 9200 | Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), | |
| 9614 | Layer.mergeAll( | |
| 9615 | IdAllocator.layer, | |
| 9616 | McpProviderSessions.layer, | |
| 9617 | ScriptProgress.layerUnavailable, | |
| 9618 | NodeServices.layer, | |
| 9619 | ), | |
| 9201 | 9620 | ), |
| 9202 | 9621 | ), |
| 9203 | 9622 | ); |
| ... | ... | @@ -9299,7 +9718,12 @@ describe("CodexAdapterV2 post-settle continuation", () => { |
| 9299 | 9718 | }).pipe( |
| 9300 | 9719 | Effect.scoped, |
| 9301 | 9720 | Effect.provide( |
| 9302 | Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), | |
| 9721 | Layer.mergeAll( | |
| 9722 | IdAllocator.layer, | |
| 9723 | McpProviderSessions.layer, | |
| 9724 | ScriptProgress.layerUnavailable, | |
| 9725 | NodeServices.layer, | |
| 9726 | ), | |
| 9303 | 9727 | ), |
| 9304 | 9728 | ), |
| 9305 | 9729 | ); |
| ... | ... | @@ -9417,7 +9841,12 @@ describe("CodexAdapterV2 post-settle continuation", () => { |
| 9417 | 9841 | }).pipe( |
| 9418 | 9842 | Effect.scoped, |
| 9419 | 9843 | Effect.provide( |
| 9420 | Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), | |
| 9844 | Layer.mergeAll( | |
| 9845 | IdAllocator.layer, | |
| 9846 | McpProviderSessions.layer, | |
| 9847 | ScriptProgress.layerUnavailable, | |
| 9848 | NodeServices.layer, | |
| 9849 | ), | |
| 9421 | 9850 | ), |
| 9422 | 9851 | ), |
| 9423 | 9852 | ); |
| ... | ... | @@ -9486,7 +9915,12 @@ describe("CodexAdapterV2 post-settle continuation", () => { |
| 9486 | 9915 | }).pipe( |
| 9487 | 9916 | Effect.scoped, |
| 9488 | 9917 | Effect.provide( |
| 9489 | Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), | |
| 9918 | Layer.mergeAll( | |
| 9919 | IdAllocator.layer, | |
| 9920 | McpProviderSessions.layer, | |
| 9921 | ScriptProgress.layerUnavailable, | |
| 9922 | NodeServices.layer, | |
| 9923 | ), | |
| 9490 | 9924 | ), |
| 9491 | 9925 | ), |
| 9492 | 9926 | ); |
apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts+51-8| ... | ... | @@ -1,3 +1,5 @@ |
| 1 | import { codexScriptProgressConfig } from "./codexScriptProgress.ts"; | |
| 2 | import * as ScriptProgress from "@t3tools/provider-core/server/ScriptProgress"; | |
| 1 | 3 | import { revertCodexThread } from "../../provider/CodexThreadRevert.ts"; |
| 2 | 4 | import { historyResponseItems } from "@t3tools/provider-core/server/handoffBudget"; |
| 3 | 5 | import { makeProviderTextDeltaCoalescer } from "./ProviderTextDeltaCoalescer.ts"; |
| ... | ... | @@ -1332,6 +1334,7 @@ export function codexThreadRuntimeParams(input: { |
| 1332 | 1334 | readonly mcpSession: McpProviderSession.McpProviderSessionConfig | undefined; |
| 1333 | 1335 | readonly modelSelection?: { readonly model: string }; |
| 1334 | 1336 | readonly runtimePolicy?: ProviderAdapter.ProviderAdapterV2RuntimePolicy; |
| 1337 | readonly scriptProgressConfig?: Readonly<Record<string, Schema.Json>>; | |
| 1335 | 1338 | }): { |
| 1336 | 1339 | readonly cwd?: string; |
| 1337 | 1340 | readonly model?: string; |
| ... | ... | @@ -1343,6 +1346,7 @@ export function codexThreadRuntimeParams(input: { |
| 1343 | 1346 | ...(input.modelSelection === undefined ? {} : { model: input.modelSelection.model }), |
| 1344 | 1347 | config: { |
| 1345 | 1348 | ...CODEX_THREAD_CONFIG, |
| 1349 | ...input.scriptProgressConfig, | |
| 1346 | 1350 | ...(mcpSession === undefined |
| 1347 | 1351 | ? {} |
| 1348 | 1352 | : { |
| ... | ... | @@ -1592,6 +1596,7 @@ export type CodexAdapterV2DriverEnv = |
| 1592 | 1596 | | Crypto.Crypto |
| 1593 | 1597 | | FileSystem.FileSystem |
| 1594 | 1598 | | IdAllocator.IdAllocatorV2 |
| 1599 | | ScriptProgress.ScriptProgress | |
| 1595 | 1600 | | McpProviderSessions.McpProviderSessions |
| 1596 | 1601 | | Path.Path |
| 1597 | 1602 | | ServerConfig; |
| ... | ... | @@ -1657,6 +1662,7 @@ const layer: Layer.Layer< |
| 1657 | 1662 | | Crypto.Crypto |
| 1658 | 1663 | | FileSystem.FileSystem |
| 1659 | 1664 | | IdAllocator.IdAllocatorV2 |
| 1665 | | ScriptProgress.ScriptProgress | |
| 1660 | 1666 | | McpProviderSessions.McpProviderSessions |
| 1661 | 1667 | | ServerConfig |
| 1662 | 1668 | > = Layer.effect( |
| ... | ... | @@ -1716,6 +1722,7 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( |
| 1716 | 1722 | adapterOptions: CodexAdapterV2Options, |
| 1717 | 1723 | ) { |
| 1718 | 1724 | const mcpSessions = yield* McpProviderSessions.McpProviderSessions; |
| 1725 | const scriptProgress = yield* ScriptProgress.ScriptProgress; | |
| 1719 | 1726 | const readMcpSession = (threadId: ThreadId | null) => |
| 1720 | 1727 | threadId === null ? Effect.succeed(undefined) : mcpSessions.read(threadId); |
| 1721 | 1728 | const { clientFactory, crypto, fileSystem, idAllocator, serverConfig } = adapterOptions; |
| ... | ... | @@ -1827,6 +1834,24 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( |
| 1827 | 1834 | yield* Ref.set(initialized, true); |
| 1828 | 1835 | }), |
| 1829 | 1836 | ); |
| 1837 | const readScriptProgressConfig = Effect.fnUntraced(function* ( | |
| 1838 | threadId: ThreadId | null, | |
| 1839 | cwd: string | null | undefined, | |
| 1840 | ) { | |
| 1841 | if (threadId === null) return {}; | |
| 1842 | const environment = yield* scriptProgress.environmentForThread(threadId); | |
| 1843 | if (Object.keys(environment).length === 0) return {}; | |
| 1844 | yield* ensureInitialized; | |
| 1845 | const { config } = yield* client.request("config/read", { | |
| 1846 | ...(cwd == null ? {} : { cwd }), | |
| 1847 | includeLayers: false, | |
| 1848 | }); | |
| 1849 | return codexScriptProgressConfig( | |
| 1850 | config.shell_environment_policy, | |
| 1851 | environment, | |
| 1852 | (resolvedRuntime?.environment ?? adapterOptions.environment).NODE_OPTIONS, | |
| 1853 | ); | |
| 1854 | }); | |
| 1830 | 1855 | const now = yield* DateTime.now; |
| 1831 | 1856 | const session = providerSession({ |
| 1832 | 1857 | providerSessionId: input.providerSessionId, |
| ... | ... | @@ -6567,14 +6592,20 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( |
| 6567 | 6592 | ensureInitialized.pipe( |
| 6568 | 6593 | Effect.andThen(mcpSessions.read(threadInput.threadId)), |
| 6569 | 6594 | Effect.flatMap((mcpSession) => |
| 6570 | client.request( | |
| 6571 | "thread/start", | |
| 6572 | codexThreadRuntimeParams({ | |
| 6573 | mcpSession, | |
| 6574 | modelSelection: threadInput.modelSelection, | |
| 6575 | runtimePolicy: threadInput.runtimePolicy, | |
| 6576 | }), | |
| 6577 | ), | |
| 6595 | Effect.gen(function* () { | |
| 6596 | return yield* client.request( | |
| 6597 | "thread/start", | |
| 6598 | codexThreadRuntimeParams({ | |
| 6599 | mcpSession, | |
| 6600 | scriptProgressConfig: yield* readScriptProgressConfig( | |
| 6601 | threadInput.threadId, | |
| 6602 | threadInput.runtimePolicy?.cwd, | |
| 6603 | ), | |
| 6604 | modelSelection: threadInput.modelSelection, | |
| 6605 | runtimePolicy: threadInput.runtimePolicy, | |
| 6606 | }), | |
| 6607 | ); | |
| 6608 | }), | |
| 6578 | 6609 | ), |
| 6579 | 6610 | Effect.map((response): OrchestrationV2ProviderThread => |
| 6580 | 6611 | providerThreadFromCodexThread({ |
| ... | ... | @@ -6606,6 +6637,10 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( |
| 6606 | 6637 | mcpSession: yield* readMcpSession( |
| 6607 | 6638 | threadInput.threadId ?? threadInput.providerThread.appThreadId, |
| 6608 | 6639 | ), |
| 6640 | scriptProgressConfig: yield* readScriptProgressConfig( | |
| 6641 | threadInput.threadId ?? threadInput.providerThread.appThreadId, | |
| 6642 | threadInput.runtimePolicy?.cwd ?? input.runtimePolicy.cwd, | |
| 6643 | ), | |
| 6609 | 6644 | ...(threadInput.modelSelection === undefined |
| 6610 | 6645 | ? {} |
| 6611 | 6646 | : { modelSelection: threadInput.modelSelection }), |
| ... | ... | @@ -7335,6 +7370,10 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( |
| 7335 | 7370 | excludeTurns: true, |
| 7336 | 7371 | ...codexThreadRuntimeParams({ |
| 7337 | 7372 | mcpSession: yield* readMcpSession(threadInput.providerThread.appThreadId), |
| 7373 | scriptProgressConfig: yield* readScriptProgressConfig( | |
| 7374 | threadInput.providerThread.appThreadId, | |
| 7375 | input.runtimePolicy.cwd, | |
| 7376 | ), | |
| 7338 | 7377 | modelSelection: input.modelSelection, |
| 7339 | 7378 | runtimePolicy: input.runtimePolicy, |
| 7340 | 7379 | }), |
| ... | ... | @@ -7384,6 +7423,10 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( |
| 7384 | 7423 | : { lastTurnId: boundary.lastTurnId }), |
| 7385 | 7424 | ...codexThreadRuntimeParams({ |
| 7386 | 7425 | mcpSession: yield* mcpSessions.read(threadInput.targetThreadId), |
| 7426 | scriptProgressConfig: yield* readScriptProgressConfig( | |
| 7427 | threadInput.targetThreadId, | |
| 7428 | threadInput.runtimePolicy?.cwd ?? input.runtimePolicy.cwd, | |
| 7429 | ), | |
| 7387 | 7430 | ...(threadInput.modelSelection === undefined |
| 7388 | 7431 | ? {} |
| 7389 | 7432 | : { modelSelection: threadInput.modelSelection }), |
apps/server/src/orchestration-v2/Adapters/GrokAdapterV2.test.ts+2| ... | ... | @@ -1,3 +1,4 @@ |
| 1 | import * as ScriptProgress from "@t3tools/provider-core/server/ScriptProgress"; | |
| 1 | 2 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 2 | 3 | import { |
| 3 | 4 | ProjectId, |
| ... | ... | @@ -326,6 +327,7 @@ describe("Grok launch permission mode", () => { |
| 326 | 327 | NodeServices.layer, |
| 327 | 328 | IdAllocator.layer, |
| 328 | 329 | McpProviderSessions.layer, |
| 330 | ScriptProgress.layerUnavailable, | |
| 329 | 331 | layerHost, |
| 330 | 332 | ); |
| 331 | 333 |
apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts+6-3| ... | ... | @@ -1,3 +1,4 @@ |
| 1 | import * as ScriptProgress from "@t3tools/provider-core/server/ScriptProgress"; | |
| 1 | 2 | /** |
| 2 | 3 | * Failures and settings a live OpenCode 2 server cannot produce on demand, |
| 3 | 4 | * driven through the real adapter and `@opencode/client` against a replayed |
| ... | ... | @@ -423,7 +424,9 @@ const history = { |
| 423 | 424 | cursor: {}, |
| 424 | 425 | }; |
| 425 | 426 | |
| 426 | it.layer(McpProviderSessions.layer)("OpenCode2 adapter", (it) => { | |
| 427 | const layerThreadServices = Layer.merge(McpProviderSessions.layer, ScriptProgress.layerUnavailable); | |
| 428 | ||
| 429 | it.layer(layerThreadServices)("OpenCode2 adapter", (it) => { | |
| 427 | 430 | it.effect("switches the session's model and variant before a turn that changed them", () => |
| 428 | 431 | Effect.gen(function* () { |
| 429 | 432 | const { runtime, thread } = yield* resumed([ |
| ... | ... | @@ -5243,7 +5246,7 @@ describe("OpenCode2 adapter server connection", () => { |
| 5243 | 5246 | assert.equal(borrowers.current, 1); |
| 5244 | 5247 | }).pipe(Effect.scoped); |
| 5245 | 5248 | assert.equal(borrowers.current, 0); |
| 5246 | }).pipe(Effect.provide(McpProviderSessions.layer)), | |
| 5249 | }).pipe(Effect.provide(layerThreadServices)), | |
| 5247 | 5250 | ); |
| 5248 | 5251 | }); |
| 5249 | 5252 | |
| ... | ... | @@ -5256,7 +5259,7 @@ const providerTurnId = Effect.gen(function* () { |
| 5256 | 5259 | }); |
| 5257 | 5260 | }).pipe(Effect.provide(IdAllocator.layer)); |
| 5258 | 5261 | |
| 5259 | it.layer(McpProviderSessions.layer)("OpenCode reported model variants", (it) => { | |
| 5262 | it.layer(layerThreadServices)("OpenCode reported model variants", (it) => { | |
| 5260 | 5263 | it.effect( |
| 5261 | 5264 | "updates reported variants from selected-model and step events without duplicate updates", |
| 5262 | 5265 | () => |
apps/server/src/orchestration-v2/Adapters/codexScriptProgress.test.ts created+65| ... | ... | @@ -0,0 +1,65 @@ |
| 1 | import { describe, expect, it } from "vite-plus/test"; | |
| 2 | import { codexScriptProgressConfig } from "./codexScriptProgress.ts"; | |
| 3 | ||
| 4 | const progress = { | |
| 5 | NODE_OPTIONS: '--import="/private/progress.mjs"', | |
| 6 | T3_SCRIPT_PROGRESS_ENDPOINT: "http://127.0.0.1:1234/ingest", | |
| 7 | T3_SCRIPT_PROGRESS_TOKEN: "thread-one", | |
| 8 | }; | |
| 9 | ||
| 10 | describe("Codex script progress environment", () => { | |
| 11 | it("preserves user overrides and Node options without weakening exclusions", () => { | |
| 12 | const policy = { | |
| 13 | inherit: "core", | |
| 14 | ignore_default_excludes: false, | |
| 15 | exclude: ["PRIVATE_*"], | |
| 16 | include_only: ["PATH", "MY_SETTING"], | |
| 17 | set: { MY_SETTING: "keep", NODE_OPTIONS: "--trace-warnings" }, | |
| 18 | }; | |
| 19 | expect(codexScriptProgressConfig(policy, progress, "--max-old-space-size=2048")).toEqual({ | |
| 20 | "shell_environment_policy.set": { | |
| 21 | MY_SETTING: "keep", | |
| 22 | ...progress, | |
| 23 | NODE_OPTIONS: '--import="/private/progress.mjs" --max-old-space-size=2048 --trace-warnings', | |
| 24 | }, | |
| 25 | "shell_environment_policy.include_only": [ | |
| 26 | "PATH", | |
| 27 | "MY_SETTING", | |
| 28 | "NODE_OPTIONS", | |
| 29 | "T3_SCRIPT_PROGRESS_ENDPOINT", | |
| 30 | "T3_SCRIPT_PROGRESS_TOKEN", | |
| 31 | ], | |
| 32 | }); | |
| 33 | expect(policy.set.NODE_OPTIONS).toBe("--trace-warnings"); | |
| 34 | expect(policy.include_only).toEqual(["PATH", "MY_SETTING"]); | |
| 35 | }); | |
| 36 | ||
| 37 | it("accepts null optional fields returned by Codex config/read", () => { | |
| 38 | expect( | |
| 39 | codexScriptProgressConfig( | |
| 40 | { | |
| 41 | inherit: null, | |
| 42 | ignore_default_excludes: null, | |
| 43 | exclude: null, | |
| 44 | set: {}, | |
| 45 | include_only: null, | |
| 46 | filters: null, | |
| 47 | experimental_use_profile: null, | |
| 48 | }, | |
| 49 | progress, | |
| 50 | ), | |
| 51 | ).toEqual({ "shell_environment_policy.set": progress }); | |
| 52 | expect(codexScriptProgressConfig({ set: null, include_only: null }, progress)).toEqual({ | |
| 53 | "shell_environment_policy.set": progress, | |
| 54 | }); | |
| 55 | }); | |
| 56 | ||
| 57 | it("does not create an allowlist when the user inherits the normal environment", () => { | |
| 58 | expect(codexScriptProgressConfig(undefined, progress)).toEqual({ | |
| 59 | "shell_environment_policy.set": progress, | |
| 60 | }); | |
| 61 | expect(codexScriptProgressConfig({ include_only: [] }, progress)).toEqual({ | |
| 62 | "shell_environment_policy.set": progress, | |
| 63 | }); | |
| 64 | }); | |
| 65 | }); |
apps/server/src/orchestration-v2/Adapters/codexScriptProgress.ts created+36| ... | ... | @@ -0,0 +1,36 @@ |
| 1 | import * as ScriptProgress from "@t3tools/provider-core/server/ScriptProgress"; | |
| 2 | import * as Schema from "effect/Schema"; | |
| 3 | ||
| 4 | const ShellEnvironmentPolicy = Schema.StructWithRest( | |
| 5 | Schema.Struct({ | |
| 6 | set: Schema.optionalKey(Schema.NullOr(Schema.Record(Schema.String, Schema.String))), | |
| 7 | include_only: Schema.optionalKey(Schema.NullOr(Schema.Array(Schema.String))), | |
| 8 | }), | |
| 9 | [Schema.Record(Schema.String, Schema.Json)], | |
| 10 | ); | |
| 11 | ||
| 12 | const decodeShellEnvironmentPolicy = Schema.decodeUnknownSync(ShellEnvironmentPolicy); | |
| 13 | ||
| 14 | export function codexScriptProgressConfig( | |
| 15 | policy: Schema.Json | undefined, | |
| 16 | environment: Readonly<Record<string, string>>, | |
| 17 | inheritedNodeOptions?: string, | |
| 18 | ): Readonly<Record<string, Schema.Json>> { | |
| 19 | const current = decodeShellEnvironmentPolicy(policy ?? {}); | |
| 20 | const nodeOptions = [inheritedNodeOptions, current.set?.NODE_OPTIONS].filter(Boolean).join(" "); | |
| 21 | const shellEnvironment = { | |
| 22 | ...current.set, | |
| 23 | ...(nodeOptions ? { NODE_OPTIONS: nodeOptions } : {}), | |
| 24 | }; | |
| 25 | const merged = ScriptProgress.withScriptProgressEnvironment(shellEnvironment, environment); | |
| 26 | return { | |
| 27 | "shell_environment_policy.set": merged, | |
| 28 | ...(current.include_only == null || current.include_only.length === 0 | |
| 29 | ? {} | |
| 30 | : { | |
| 31 | "shell_environment_policy.include_only": [ | |
| 32 | ...new Set([...current.include_only, ...Object.keys(environment)]), | |
| 33 | ], | |
| 34 | }), | |
| 35 | }; | |
| 36 | } |
apps/server/src/orchestration-v2/ClaudeAutomaticDelivery.integration.test.ts+7-1| ... | ... | @@ -1,3 +1,4 @@ |
| 1 | import * as ScriptProgress from "@t3tools/provider-core/server/ScriptProgress"; | |
| 1 | 2 | import type { SDKMessage, SDKUserMessage } from "@anthropic-ai/claude-agent-sdk"; |
| 2 | 3 | import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; |
| 3 | 4 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| ... | ... | @@ -434,7 +435,12 @@ it.effect.each(["child completion", "scheduled message", "user steering"] as con |
| 434 | 435 | ); |
| 435 | 436 | }).pipe( |
| 436 | 437 | Effect.provide( |
| 437 | Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), | |
| 438 | Layer.mergeAll( | |
| 439 | IdAllocator.layer, | |
| 440 | McpProviderSessions.layer, | |
| 441 | ScriptProgress.layerUnavailable, | |
| 442 | NodeServices.layer, | |
| 443 | ), | |
| 438 | 444 | ), |
| 439 | 445 | ), |
| 440 | 446 | ), |
apps/server/src/orchestration-v2/CursorOrchestratorV2.live.test.ts+2-1| ... | ... | @@ -1,3 +1,4 @@ |
| 1 | import * as ScriptProgress from "@t3tools/provider-core/server/ScriptProgress"; | |
| 1 | 2 | import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; |
| 2 | 3 | import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts"; |
| 3 | 4 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| ... | ... | @@ -125,7 +126,7 @@ const layerLive = RuntimeLayer.layer.pipe( |
| 125 | 126 | Layer.provide(ResetCreditCoordinator.layer), |
| 126 | 127 | Layer.provide(layerBackgroundPolicy), |
| 127 | 128 | Layer.provide(layerPlatformTest), |
| 128 | Layer.provide(McpProviderSessions.layer), | |
| 129 | Layer.provide(Layer.merge(McpProviderSessions.layer, ScriptProgress.layerUnavailable)), | |
| 129 | 130 | ); |
| 130 | 131 | |
| 131 | 132 | const waitForIdle = Effect.fn("CursorOrchestratorV2Live.waitForIdle")(function* ( |
apps/server/src/orchestration-v2/DelegatedCompletionDelivery.test.ts+2-1| ... | ... | @@ -1,3 +1,4 @@ |
| 1 | import * as ScriptProgress from "@t3tools/provider-core/server/ScriptProgress"; | |
| 1 | 2 | import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts"; |
| 2 | 3 | import * as McpProviderSessions from "@t3tools/provider-core/server/McpProviderSessions"; |
| 3 | 4 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| ... | ... | @@ -133,7 +134,7 @@ const layerTest = Layer.mergeAll(RuntimeLayer.layer, RuntimeLayer.layerEventSink |
| 133 | 134 | }), |
| 134 | 135 | ), |
| 135 | 136 | Layer.provide(McpSessionRegistryTestkit.layer), |
| 136 | Layer.provide(McpProviderSessions.layer), | |
| 137 | Layer.provide(Layer.merge(McpProviderSessions.layer, ScriptProgress.layerUnavailable)), | |
| 137 | 138 | Layer.provide(SqlitePersistence.layerMemory), |
| 138 | 139 | Layer.provide(layerCheckpointStoreTest), |
| 139 | 140 | Layer.provide(layerServerConfig), |
apps/server/src/orchestration-v2/GrokOrchestratorV2.live.test.ts+2-1| ... | ... | @@ -1,3 +1,4 @@ |
| 1 | import * as ScriptProgress from "@t3tools/provider-core/server/ScriptProgress"; | |
| 1 | 2 | import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; |
| 2 | 3 | import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts"; |
| 3 | 4 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| ... | ... | @@ -124,7 +125,7 @@ const layerLive = RuntimeLayer.layer.pipe( |
| 124 | 125 | Layer.provide(ResetCreditCoordinator.layer), |
| 125 | 126 | Layer.provide(layerBackgroundPolicy), |
| 126 | 127 | Layer.provide(layerPlatformTest), |
| 127 | Layer.provide(McpProviderSessions.layer), | |
| 128 | Layer.provide(Layer.merge(McpProviderSessions.layer, ScriptProgress.layerUnavailable)), | |
| 128 | 129 | ); |
| 129 | 130 | |
| 130 | 131 | const waitForIdle = Effect.fn("GrokOrchestratorV2Live.waitForIdle")(function* (threadId: ThreadId) { |
apps/server/src/orchestration-v2/OpenCode2OrchestratorV2.live.test.ts+2-1| ... | ... | @@ -1,3 +1,4 @@ |
| 1 | import * as ScriptProgress from "@t3tools/provider-core/server/ScriptProgress"; | |
| 1 | 2 | /** |
| 2 | 3 | * Runs OpenCode 2 through the whole orchestrator with the real driver: the |
| 3 | 4 | * driver probes the binary, spawns `opencode serve`, and routes to the 2.x |
| ... | ... | @@ -218,7 +219,7 @@ const layerOrchestration = RuntimeLayer.layer.pipe( |
| 218 | 219 | Layer.provide(ResetCreditCoordinator.layer), |
| 219 | 220 | Layer.provide(layerBackgroundPolicy), |
| 220 | 221 | Layer.provide(layerPlatformTest), |
| 221 | Layer.provide(McpProviderSessions.layer), | |
| 222 | Layer.provide(Layer.merge(McpProviderSessions.layer, ScriptProgress.layerUnavailable)), | |
| 222 | 223 | ); |
| 223 | 224 | |
| 224 | 225 | // Starts the continuation run a provider wake asks for, as the production layer does. |
apps/server/src/orchestration-v2/ProjectSettingsUpgrade.integration.test.ts+2-1| ... | ... | @@ -1,3 +1,4 @@ |
| 1 | import * as ScriptProgress from "@t3tools/provider-core/server/ScriptProgress"; | |
| 1 | 2 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 2 | 3 | import * as McpProviderSessions from "@t3tools/provider-core/server/McpProviderSessions"; |
| 3 | 4 | import { assert, it } from "@effect/vitest"; |
| ... | ... | @@ -153,7 +154,7 @@ const layerRuntime = (dbPath: string) => { |
| 153 | 154 | }), |
| 154 | 155 | ), |
| 155 | 156 | Layer.provide(McpSessionRegistryTestkit.layer), |
| 156 | Layer.provide(McpProviderSessions.layer), | |
| 157 | Layer.provide(Layer.merge(McpProviderSessions.layer, ScriptProgress.layerUnavailable)), | |
| 157 | 158 | Layer.provideMerge(SqlitePersistence.layerFromPath(dbPath)), |
| 158 | 159 | Layer.provide(layerCheckpointStore), |
| 159 | 160 | Layer.provide(layerServerConfig), |
apps/server/src/orchestration-v2/ProviderSessionManager.test.ts+7-1| ... | ... | @@ -1,3 +1,4 @@ |
| 1 | import * as ScriptProgress from "@t3tools/provider-core/server/ScriptProgress"; | |
| 1 | 2 | import * as NetAddress from "effect/net/NetAddress"; |
| 2 | 3 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 3 | 4 | import { assert, it } from "@effect/vitest"; |
| ... | ... | @@ -553,13 +554,18 @@ function layerTest(input: { |
| 553 | 554 | IdAllocator.layer, |
| 554 | 555 | layerConfiguredMcpRegistry, |
| 555 | 556 | McpProviderSessions.layer, |
| 557 | ScriptProgress.layerUnavailable, | |
| 556 | 558 | ProviderSessionManager.layerWithOptions({ |
| 557 | 559 | idleTimeoutMs: input.idleTimeoutMs, |
| 558 | 560 | ...(input.maxIdlePinMs === undefined ? {} : { maxIdlePinMs: input.maxIdlePinMs }), |
| 559 | 561 | }).pipe( |
| 560 | 562 | Layer.provide( |
| 561 | 563 | Layer.mergeAll( |
| 562 | layerRegistry.pipe(Layer.provideMerge(McpProviderSessions.layer)), | |
| 564 | layerRegistry.pipe( | |
| 565 | Layer.provideMerge( | |
| 566 | Layer.merge(McpProviderSessions.layer, ScriptProgress.layerUnavailable), | |
| 567 | ), | |
| 568 | ), | |
| 563 | 569 | layerConfiguredEventSink, |
| 564 | 570 | IdAllocator.layer, |
| 565 | 571 | layerProviderEventIngestorTest, |
apps/server/src/orchestration-v2/runtimeLayer.test.ts+6-5| ... | ... | @@ -1,3 +1,4 @@ |
| 1 | import * as ScriptProgress from "@t3tools/provider-core/server/ScriptProgress"; | |
| 1 | 2 | import { SourceControlProviderKind } from "@t3tools/contracts"; |
| 2 | 3 | import { limitRecoveryCommand } from "./UsageLimitRecoveryWorker.ts"; |
| 3 | 4 | import * as McpProviderSessions from "@t3tools/provider-core/server/McpProviderSessions"; |
| ... | ... | @@ -274,7 +275,7 @@ const layerTest = Layer.mergeAll( |
| 274 | 275 | ThreadCommandExecutor.layer, |
| 275 | 276 | ).pipe( |
| 276 | 277 | Layer.provide(McpSessionRegistryTestkit.layer), |
| 277 | Layer.provide(McpProviderSessions.layer), | |
| 278 | Layer.provide(Layer.merge(McpProviderSessions.layer, ScriptProgress.layerUnavailable)), | |
| 278 | 279 | Layer.provide(SqlitePersistence.layerMemory), |
| 279 | 280 | Layer.provide(layerCheckpointStoreTest), |
| 280 | 281 | Layer.provide(layerServerConfig), |
| ... | ... | @@ -287,7 +288,7 @@ const layerTest = Layer.mergeAll( |
| 287 | 288 | |
| 288 | 289 | const layerLegacyImportTest = RuntimeLayer.layer.pipe( |
| 289 | 290 | Layer.provide(McpSessionRegistryTestkit.layer), |
| 290 | Layer.provide(McpProviderSessions.layer), | |
| 291 | Layer.provide(Layer.merge(McpProviderSessions.layer, ScriptProgress.layerUnavailable)), | |
| 291 | 292 | Layer.provideMerge(SqlitePersistence.layerMemory), |
| 292 | 293 | Layer.provide(layerCheckpointStoreTest), |
| 293 | 294 | Layer.provide(layerServerConfig), |
| ... | ... | @@ -327,7 +328,7 @@ const layerProjectDeletionTest = Layer.mergeAll( |
| 327 | 328 | }), |
| 328 | 329 | ), |
| 329 | 330 | Layer.provide(McpSessionRegistryTestkit.layer), |
| 330 | Layer.provide(McpProviderSessions.layer), | |
| 331 | Layer.provide(Layer.merge(McpProviderSessions.layer, ScriptProgress.layerUnavailable)), | |
| 331 | 332 | Layer.provide(SqlitePersistence.layerMemory), |
| 332 | 333 | Layer.provide(layerCheckpointStoreTest), |
| 333 | 334 | Layer.provide(layerServerConfig), |
| ... | ... | @@ -472,7 +473,7 @@ const layerSharedApplicationDataPlaneTest = Layer.mergeAll( |
| 472 | 473 | }), |
| 473 | 474 | ), |
| 474 | 475 | Layer.provide(McpSessionRegistryTestkit.layer), |
| 475 | Layer.provide(McpProviderSessions.layer), | |
| 476 | Layer.provide(Layer.merge(McpProviderSessions.layer, ScriptProgress.layerUnavailable)), | |
| 476 | 477 | Layer.provideMerge(SqlitePersistence.layerMemory), |
| 477 | 478 | Layer.provide(layerCheckpointStoreTest), |
| 478 | 479 | Layer.provide(layerServerConfig), |
| ... | ... | @@ -5710,7 +5711,7 @@ const layerQueueResumeDisabledInstanceTest = Layer.mergeAll( |
| 5710 | 5711 | ThreadCommandExecutor.layer, |
| 5711 | 5712 | ).pipe( |
| 5712 | 5713 | Layer.provide(McpSessionRegistryTestkit.layer), |
| 5713 | Layer.provide(McpProviderSessions.layer), | |
| 5714 | Layer.provide(Layer.merge(McpProviderSessions.layer, ScriptProgress.layerUnavailable)), | |
| 5714 | 5715 | Layer.provide(SqlitePersistence.layerMemory), |
| 5715 | 5716 | Layer.provide(layerCheckpointStoreTest), |
| 5716 | 5717 | Layer.provide(layerServerConfig), |
apps/server/src/orchestration-v2/testkit/ProviderReplayHarness.ts+11-3| ... | ... | @@ -1,3 +1,4 @@ |
| 1 | import * as ScriptProgress from "@t3tools/provider-core/server/ScriptProgress"; | |
| 1 | 2 | import * as OtelEnvironment from "@t3tools/shared/otelEnvironment"; |
| 2 | 3 | import { DEFAULT_SIGNAL_EXPORT } from "@t3tools/shared/observability"; |
| 3 | 4 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| ... | ... | @@ -174,7 +175,7 @@ export interface OrchestratorV2ProviderReplayHarness< |
| 174 | 175 | ) => Layer.Layer< |
| 175 | 176 | ProviderAdapterRegistry.ProviderAdapterRegistryV2, |
| 176 | 177 | Error, |
| 177 | McpProviderSessions.McpProviderSessions | |
| 178 | McpProviderSessions.McpProviderSessions | ScriptProgress.ScriptProgress | |
| 178 | 179 | >; |
| 179 | 180 | } |
| 180 | 181 | |
| ... | ... | @@ -260,7 +261,7 @@ export function layerWithRegistry<Error>( |
| 260 | 261 | registryLayer: Layer.Layer< |
| 261 | 262 | ProviderAdapterRegistry.ProviderAdapterRegistryV2, |
| 262 | 263 | Error, |
| 263 | McpProviderSessions.McpProviderSessions | |
| 264 | McpProviderSessions.McpProviderSessions | ScriptProgress.ScriptProgress | |
| 264 | 265 | >, |
| 265 | 266 | options: { |
| 266 | 267 | readonly databaseLayer?: Layer.Layer< |
| ... | ... | @@ -299,7 +300,13 @@ export function layerWithRegistry<Error>( |
| 299 | 300 | const layerContinuationRequests = |
| 300 | 301 | options.runContinuationWorker === true ? ProviderContinuationRequests.layer : Layer.empty; |
| 301 | 302 | const layerProvidedRegistry = registryLayer.pipe( |
| 302 | Layer.provide(Layer.merge(layerContinuationRequests, McpProviderSessions.layer)), | |
| 303 | Layer.provide( | |
| 304 | Layer.mergeAll( | |
| 305 | layerContinuationRequests, | |
| 306 | McpProviderSessions.layer, | |
| 307 | ScriptProgress.layerUnavailable, | |
| 308 | ), | |
| 309 | ), | |
| 303 | 310 | ); |
| 304 | 311 | const layerServerSettings = ServerSettings.layerTest({ |
| 305 | 312 | responseStreamingMode: "turn", |
| ... | ... | @@ -362,6 +369,7 @@ export function layerWithRegistry<Error>( |
| 362 | 369 | layerEventSinkProvided, |
| 363 | 370 | IdAllocator.layer, |
| 364 | 371 | McpProviderSessions.layer, |
| 372 | ScriptProgress.layerUnavailable, | |
| 365 | 373 | McpSessionRegistryTestkit.layer, |
| 366 | 374 | layerProviderEventIngestorProvided, |
| 367 | 375 | layerStores, |
apps/server/src/progress/ScriptProgress.test.ts created+537| ... | ... | @@ -0,0 +1,537 @@ |
| 1 | // @effect-diagnostics nodeBuiltinImport:off -- A native child validates NODE_OPTIONS before application code runs. | |
| 2 | import * as NodeNet from "node:net"; | |
| 3 | import * as NodeChildProcess from "node:child_process"; | |
| 4 | import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 5 | import { expect, it } from "@effect/vitest"; | |
| 6 | import { Root, decodeEventStream, type StreamEvent } from "@clo/lib/progress"; | |
| 7 | import { ThreadId, type ScriptProgressEvent } from "@t3tools/contracts"; | |
| 8 | import * as ScriptProgress from "@t3tools/provider-core/server/ScriptProgress"; | |
| 9 | import * as Effect from "effect/Effect"; | |
| 10 | import * as FileSystem from "effect/FileSystem"; | |
| 11 | import * as Layer from "effect/Layer"; | |
| 12 | import * as Queue from "effect/Queue"; | |
| 13 | import * as Stream from "effect/Stream"; | |
| 14 | ||
| 15 | import * as ServerConfig from "../config.ts"; | |
| 16 | import * as Capture from "./ScriptProgress.ts"; | |
| 17 | ||
| 18 | const threadId = ThreadId.make("progress-thread"); | |
| 19 | const otherThreadId = ThreadId.make("other-progress-thread"); | |
| 20 | const testLayer = Capture.layer.pipe( | |
| 21 | Layer.provide(ServerConfig.layerTest("/tmp", { prefix: "t3 script progress " })), | |
| 22 | Layer.provideMerge(NodeServices.layer), | |
| 23 | ); | |
| 24 | ||
| 25 | const connect = (endpoint: string) => | |
| 26 | Effect.acquireRelease( | |
| 27 | Effect.callback<NodeNet.Socket>((resume) => { | |
| 28 | const socket = NodeNet.connect({ host: "127.0.0.1", port: Number(endpoint.split(":")[1]) }); | |
| 29 | socket.once("connect", () => resume(Effect.succeed(socket))); | |
| 30 | socket.once("error", (error) => resume(Effect.die(error))); | |
| 31 | return Effect.sync(() => socket.destroy()); | |
| 32 | }), | |
| 33 | (socket) => Effect.sync(() => socket.destroy()), | |
| 34 | ); | |
| 35 | ||
| 36 | const closed = (socket: NodeNet.Socket) => | |
| 37 | Effect.callback<void>((resume) => { | |
| 38 | if (socket.closed) return resume(Effect.void); | |
| 39 | socket.once("close", () => resume(Effect.void)); | |
| 40 | }); | |
| 41 | ||
| 42 | const write = (socket: NodeNet.Socket, value: unknown) => | |
| 43 | Effect.sync(() => { | |
| 44 | socket.write(`${JSON.stringify(value)}\n`); | |
| 45 | }); | |
| 46 | ||
| 47 | const until = <E, R>( | |
| 48 | pull: Effect.Effect<ScriptProgressEvent, E, R>, | |
| 49 | predicate: (event: ScriptProgressEvent) => boolean, | |
| 50 | ) => | |
| 51 | Effect.gen(function* () { | |
| 52 | while (true) { | |
| 53 | const event = yield* pull; | |
| 54 | if (predicate(event)) return event; | |
| 55 | } | |
| 56 | }); | |
| 57 | ||
| 58 | it.effect("materializes an external preload and gives each thread an isolated capture token", () => | |
| 59 | Effect.gen(function* () { | |
| 60 | const progress = yield* ScriptProgress.ScriptProgress; | |
| 61 | const first = yield* progress.environmentForThread(threadId); | |
| 62 | const again = yield* progress.environmentForThread(threadId); | |
| 63 | const other = yield* progress.environmentForThread(otherThreadId); | |
| 64 | expect(first).toEqual(again); | |
| 65 | expect(first.T3_SCRIPT_PROGRESS_TOKEN).not.toBe(other.T3_SCRIPT_PROGRESS_TOKEN); | |
| 66 | expect(first.T3_SCRIPT_PROGRESS_ENDPOINT).toMatch(/^127\.0\.0\.1:\d+$/); | |
| 67 | const fs = yield* FileSystem.FileSystem; | |
| 68 | const preloadPath: unknown = JSON.parse(first.NODE_OPTIONS!.slice("--require=".length)); | |
| 69 | if (typeof preloadPath !== "string") throw new Error("Expected preload path"); | |
| 70 | const stat = yield* fs.stat(preloadPath); | |
| 71 | expect(Number(stat.size)).toBeGreaterThan(100); | |
| 72 | expect(preloadPath).not.toContain(".asar/"); | |
| 73 | expect(preloadPath).toContain("t3 script progress "); | |
| 74 | expect(preloadPath).toMatch(/\.cjs$/); | |
| 75 | const environment = ScriptProgress.withScriptProgressEnvironment( | |
| 76 | { | |
| 77 | ...process.env, | |
| 78 | NODE_OPTIONS: "--max-old-space-size=128", | |
| 79 | }, | |
| 80 | first, | |
| 81 | ); | |
| 82 | expect(ScriptProgress.withScriptProgressEnvironment(environment, first)).toEqual(environment); | |
| 83 | const result = yield* Effect.callback<{ code: number | null; stdout: string; stderr: string }>( | |
| 84 | (resume) => { | |
| 85 | const child = NodeChildProcess.spawn( | |
| 86 | process.execPath, | |
| 87 | ["-e", "process.stdout.write('preload-loaded')"], | |
| 88 | { env: environment }, | |
| 89 | ); | |
| 90 | let stdout = ""; | |
| 91 | let stderr = ""; | |
| 92 | child.stdout.on("data", (chunk: Buffer) => { | |
| 93 | stdout += chunk.toString(); | |
| 94 | }); | |
| 95 | child.stderr.on("data", (chunk: Buffer) => { | |
| 96 | stderr += chunk.toString(); | |
| 97 | }); | |
| 98 | child.once("error", (error) => resume(Effect.die(error))); | |
| 99 | child.once("close", (code) => resume(Effect.succeed({ code, stdout, stderr }))); | |
| 100 | return Effect.sync(() => { | |
| 101 | child.kill(); | |
| 102 | }); | |
| 103 | }, | |
| 104 | ); | |
| 105 | expect(result).toEqual({ code: 0, stdout: "preload-loaded", stderr: "" }); | |
| 106 | }).pipe(Effect.scoped, Effect.provide(testLayer)), | |
| 107 | ); | |
| 108 | ||
| 109 | it.effect( | |
| 110 | "late subscribers get a current reset snapshot with nested nodes and subsequent zero deltas", | |
| 111 | () => | |
| 112 | Effect.gen(function* () { | |
| 113 | const progress = yield* ScriptProgress.ScriptProgress; | |
| 114 | const env = yield* progress.environmentForThread(threadId); | |
| 115 | const queue = yield* Stream.toQueue(progress.subscribe(threadId), { capacity: "unbounded" }); | |
| 116 | const pull = Queue.take(queue); | |
| 117 | expect(yield* pull).toEqual({ type: "snapshot", streams: [] }); | |
| 118 | const socket = yield* connect(env.T3_SCRIPT_PROGRESS_ENDPOINT!); | |
| 119 | yield* write(socket, { | |
| 120 | type: "hello", | |
| 121 | producerId: "fixture", | |
| 122 | token: env.T3_SCRIPT_PROGRESS_TOKEN, | |
| 123 | pid: 123, | |
| 124 | label: "build.ts", | |
| 125 | }); | |
| 126 | yield* write(socket, { | |
| 127 | type: "progress", | |
| 128 | streamId: "global", | |
| 129 | event: [ | |
| 130 | -1, | |
| 131 | { k: 1, t: "Build", v: 2, e: 10, c: [2] }, | |
| 132 | { k: 2, t: "Assets", v: 1, e: 3 }, | |
| 133 | [1], | |
| 134 | ], | |
| 135 | }); | |
| 136 | yield* until(pull, (event) => event.type === "progress" && event.summary[0]?.value === 2); | |
| 137 | yield* write(socket, { type: "progress", streamId: "global", event: [0, { k: 1, v: 7 }] }); | |
| 138 | yield* until(pull, (event) => event.type === "progress" && event.summary[0]?.value === 7); | |
| 139 | ||
| 140 | const lateQueue = yield* Stream.toQueue(progress.subscribe(threadId), { | |
| 141 | capacity: "unbounded", | |
| 142 | }); | |
| 143 | const late = Queue.take(lateQueue); | |
| 144 | const snapshot = yield* late; | |
| 145 | expect(snapshot).toMatchObject({ | |
| 146 | type: "snapshot", | |
| 147 | streams: [ | |
| 148 | { | |
| 149 | pid: 123, | |
| 150 | label: "build.ts", | |
| 151 | status: "connected", | |
| 152 | summary: [{ text: "Build", value: 7, total: 10 }], | |
| 153 | }, | |
| 154 | ], | |
| 155 | }); | |
| 156 | const reset = yield* until(late, (event) => event.type === "progress"); | |
| 157 | if (reset.type !== "progress") throw new Error("Expected progress"); | |
| 158 | expect(reset.event[0]).toBe(-1); | |
| 159 | const root = new Root(); | |
| 160 | let input!: ReadableStreamDefaultController<StreamEvent>; | |
| 161 | const decoder = decodeEventStream( | |
| 162 | new ReadableStream({ | |
| 163 | start(controller) { | |
| 164 | input = controller; | |
| 165 | }, | |
| 166 | }), | |
| 167 | root, | |
| 168 | ); | |
| 169 | yield* Effect.addFinalizer(() => Effect.sync(() => decoder.cancel())); | |
| 170 | const decoded = new Promise<void>((resolve) => | |
| 171 | root.on("node-start", () => { | |
| 172 | if (root.active[0]?.children.length === 1) resolve(); | |
| 173 | }), | |
| 174 | ); | |
| 175 | input.enqueue(reset.event as StreamEvent); | |
| 176 | yield* Effect.promise(() => decoded); | |
| 177 | expect(root.active[0]?.value).toBe(7); | |
| 178 | expect(root.active[0]?.children[0]?.text).toBe("Assets"); | |
| 179 | yield* write(socket, { type: "progress", streamId: "global", event: [0, { k: 1, v: 0 }] }); | |
| 180 | yield* until(late, (event) => event.type === "progress" && event.summary[0]?.value === 0); | |
| 181 | yield* write(socket, { type: "progress", streamId: "global", event: [0, ["end", null]] }); | |
| 182 | yield* until(late, (event) => event.type === "remove"); | |
| 183 | }).pipe(Effect.scoped, Effect.provide(testLayer)), | |
| 184 | ); | |
| 185 | ||
| 186 | it.effect("rejects unknown tokens and malformed producers without affecting another thread", () => | |
| 187 | Effect.gen(function* () { | |
| 188 | const progress = yield* ScriptProgress.ScriptProgress; | |
| 189 | const env = yield* progress.environmentForThread(threadId); | |
| 190 | const wrong = yield* connect(env.T3_SCRIPT_PROGRESS_ENDPOINT!); | |
| 191 | yield* write(wrong, { type: "hello", producerId: "fixture", token: "wrong", pid: 12 }); | |
| 192 | yield* closed(wrong); | |
| 193 | const malformed = yield* connect(env.T3_SCRIPT_PROGRESS_ENDPOINT!); | |
| 194 | yield* Effect.sync(() => malformed.write("not json\n")); | |
| 195 | yield* closed(malformed); | |
| 196 | const valid = yield* connect(env.T3_SCRIPT_PROGRESS_ENDPOINT!); | |
| 197 | const queue = yield* Stream.toQueue(progress.subscribe(threadId), { capacity: "unbounded" }); | |
| 198 | const pull = Queue.take(queue); | |
| 199 | yield* pull; | |
| 200 | yield* write(valid, { | |
| 201 | type: "hello", | |
| 202 | producerId: "fixture", | |
| 203 | token: env.T3_SCRIPT_PROGRESS_TOKEN, | |
| 204 | pid: 13, | |
| 205 | }); | |
| 206 | yield* write(valid, { | |
| 207 | type: "progress", | |
| 208 | streamId: "global", | |
| 209 | event: [-1, { k: 1, t: "Good" }, [1]], | |
| 210 | }); | |
| 211 | yield* until(pull, (event) => event.type === "progress" && event.summary[0]?.text === "Good"); | |
| 212 | const otherQueue = yield* Stream.toQueue(progress.subscribe(otherThreadId), { | |
| 213 | capacity: "unbounded", | |
| 214 | }); | |
| 215 | const other = Queue.take(otherQueue); | |
| 216 | expect(yield* other).toEqual({ type: "snapshot", streams: [] }); | |
| 217 | }).pipe(Effect.scoped, Effect.provide(testLayer)), | |
| 218 | ); | |
| 219 | ||
| 220 | it.effect( | |
| 221 | "coalesces a burst into current progress and labels disconnected producers as unknown", | |
| 222 | () => | |
| 223 | Effect.gen(function* () { | |
| 224 | const progress = yield* ScriptProgress.ScriptProgress; | |
| 225 | const env = yield* progress.environmentForThread(threadId); | |
| 226 | const queue = yield* Stream.toQueue(progress.subscribe(threadId), { capacity: "unbounded" }); | |
| 227 | const pull = Queue.take(queue); | |
| 228 | yield* pull; | |
| 229 | const socket = yield* connect(env.T3_SCRIPT_PROGRESS_ENDPOINT!); | |
| 230 | yield* write(socket, { | |
| 231 | type: "hello", | |
| 232 | producerId: "fixture", | |
| 233 | token: env.T3_SCRIPT_PROGRESS_TOKEN, | |
| 234 | pid: 14, | |
| 235 | }); | |
| 236 | yield* write(socket, { | |
| 237 | type: "progress", | |
| 238 | streamId: "global", | |
| 239 | event: [-1, { k: 1, t: "Burst", e: 1000 }, [1]], | |
| 240 | }); | |
| 241 | yield* until( | |
| 242 | pull, | |
| 243 | (event) => event.type === "progress" && event.summary[0]?.text === "Burst", | |
| 244 | ); | |
| 245 | yield* Effect.sync(() => { | |
| 246 | socket.write( | |
| 247 | Array.from({ length: 100 }, (_, i) => | |
| 248 | JSON.stringify({ | |
| 249 | type: "progress", | |
| 250 | streamId: "global", | |
| 251 | event: [0, { k: 1, v: i + 1 }], | |
| 252 | }), | |
| 253 | ).join("\n") + "\n", | |
| 254 | ); | |
| 255 | }); | |
| 256 | yield* until(pull, (event) => event.type === "progress" && event.summary[0]?.value === 100); | |
| 257 | yield* Effect.sync(() => socket.destroy()); | |
| 258 | const event = yield* until( | |
| 259 | pull, | |
| 260 | (event) => event.type === "upsert" && event.stream.status === "disconnected", | |
| 261 | ); | |
| 262 | expect(event).toMatchObject({ type: "upsert", stream: { status: "disconnected" } }); | |
| 263 | const lateQueue = yield* Stream.toQueue(progress.subscribe(threadId), { | |
| 264 | capacity: "unbounded", | |
| 265 | }); | |
| 266 | const late = Queue.take(lateQueue); | |
| 267 | expect(yield* late).toMatchObject({ | |
| 268 | type: "snapshot", | |
| 269 | streams: [{ status: "disconnected", summary: [{ text: "Burst", value: 100 }] }], | |
| 270 | }); | |
| 271 | }).pipe(Effect.scoped, Effect.provide(testLayer)), | |
| 272 | ); | |
| 273 | ||
| 274 | it.effect("destroys oversized input without retaining a capture", () => | |
| 275 | Effect.gen(function* () { | |
| 276 | const progress = yield* ScriptProgress.ScriptProgress; | |
| 277 | const env = yield* progress.environmentForThread(threadId); | |
| 278 | const socket = yield* connect(env.T3_SCRIPT_PROGRESS_ENDPOINT!); | |
| 279 | yield* write(socket, { | |
| 280 | type: "hello", | |
| 281 | producerId: "fixture", | |
| 282 | token: env.T3_SCRIPT_PROGRESS_TOKEN, | |
| 283 | pid: 15, | |
| 284 | }); | |
| 285 | yield* Effect.sync(() => socket.write("x".repeat(1024 * 1024 + 1))); | |
| 286 | yield* closed(socket); | |
| 287 | const queue = yield* Stream.toQueue(progress.subscribe(threadId), { capacity: "unbounded" }); | |
| 288 | const pull = Queue.take(queue); | |
| 289 | expect(yield* pull).toEqual({ type: "snapshot", streams: [] }); | |
| 290 | }).pipe(Effect.scoped, Effect.provide(testLayer)), | |
| 291 | ); | |
| 292 | ||
| 293 | it.effect("bounds accumulated state without treating a broken producer as completed", () => | |
| 294 | Effect.gen(function* () { | |
| 295 | const progress = yield* ScriptProgress.ScriptProgress; | |
| 296 | const env = yield* progress.environmentForThread(threadId); | |
| 297 | const queue = yield* Stream.toQueue(progress.subscribe(threadId), { capacity: "unbounded" }); | |
| 298 | const pull = Queue.take(queue); | |
| 299 | yield* pull; | |
| 300 | const socket = yield* connect(env.T3_SCRIPT_PROGRESS_ENDPOINT!); | |
| 301 | yield* write(socket, { | |
| 302 | type: "hello", | |
| 303 | producerId: "fixture", | |
| 304 | token: env.T3_SCRIPT_PROGRESS_TOKEN, | |
| 305 | pid: 16, | |
| 306 | }); | |
| 307 | yield* write(socket, { | |
| 308 | type: "progress", | |
| 309 | streamId: "global", | |
| 310 | event: [-1, { k: 1, t: "a".repeat(400_000) }, [1]], | |
| 311 | }); | |
| 312 | yield* until(pull, (event) => event.type === "progress" && event.summary.length === 1); | |
| 313 | yield* write(socket, { | |
| 314 | type: "progress", | |
| 315 | streamId: "global", | |
| 316 | event: [ | |
| 317 | 0, | |
| 318 | { k: 1, c: [2, 3] }, | |
| 319 | { k: 2, t: "b".repeat(400_000) }, | |
| 320 | { k: 3, t: "c".repeat(400_000) }, | |
| 321 | ], | |
| 322 | }); | |
| 323 | yield* until(pull, (event) => event.type === "remove"); | |
| 324 | yield* closed(socket); | |
| 325 | const lateQueue = yield* Stream.toQueue(progress.subscribe(threadId), { | |
| 326 | capacity: "unbounded", | |
| 327 | }); | |
| 328 | expect(yield* Queue.take(lateQueue)).toEqual({ type: "snapshot", streams: [] }); | |
| 329 | }).pipe(Effect.scoped, Effect.provide(testLayer)), | |
| 330 | ); | |
| 331 | ||
| 332 | it.effect("retains only the most recent sixteen disconnected roots for a thread", () => | |
| 333 | Effect.gen(function* () { | |
| 334 | const progress = yield* ScriptProgress.ScriptProgress; | |
| 335 | const env = yield* progress.environmentForThread(threadId); | |
| 336 | const queue = yield* Stream.toQueue(progress.subscribe(threadId), { capacity: "unbounded" }); | |
| 337 | const pull = Queue.take(queue); | |
| 338 | yield* pull; | |
| 339 | const producers: NodeNet.Socket[] = []; | |
| 340 | for (let i = 0; i < 17; i++) { | |
| 341 | const socket = yield* connect(env.T3_SCRIPT_PROGRESS_ENDPOINT!); | |
| 342 | yield* write(socket, { | |
| 343 | type: "hello", | |
| 344 | producerId: `producer-${i}`, | |
| 345 | token: env.T3_SCRIPT_PROGRESS_TOKEN, | |
| 346 | pid: 100 + i, | |
| 347 | }); | |
| 348 | yield* write(socket, { | |
| 349 | type: "progress", | |
| 350 | streamId: "global", | |
| 351 | event: [-1, { k: 1, t: "Retained" }, [1]], | |
| 352 | }); | |
| 353 | yield* until(pull, (event) => event.type === "upsert" && event.stream.status === "connected"); | |
| 354 | producers.push(socket); | |
| 355 | } | |
| 356 | for (const socket of producers) { | |
| 357 | yield* Effect.sync(() => socket.destroy()); | |
| 358 | yield* until( | |
| 359 | pull, | |
| 360 | (event) => event.type === "upsert" && event.stream.status === "disconnected", | |
| 361 | ); | |
| 362 | } | |
| 363 | const lateQueue = yield* Stream.toQueue(progress.subscribe(threadId), { | |
| 364 | capacity: "unbounded", | |
| 365 | }); | |
| 366 | const snapshot = yield* Queue.take(lateQueue); | |
| 367 | if (snapshot.type !== "snapshot") throw new Error("Expected snapshot"); | |
| 368 | expect(snapshot.streams).toHaveLength(16); | |
| 369 | expect(snapshot.streams.map((capture) => capture.pid)).toEqual( | |
| 370 | Array.from({ length: 16 }, (_, i) => 101 + i), | |
| 371 | ); | |
| 372 | }).pipe(Effect.scoped, Effect.provide(testLayer)), | |
| 373 | ); | |
| 374 | ||
| 375 | it.effect("closes the loopback listener when its application scope ends", () => | |
| 376 | Effect.gen(function* () { | |
| 377 | const endpoint = yield* Effect.gen(function* () { | |
| 378 | const progress = yield* ScriptProgress.ScriptProgress; | |
| 379 | return (yield* progress.environmentForThread(threadId)).T3_SCRIPT_PROGRESS_ENDPOINT!; | |
| 380 | }).pipe(Effect.provide(testLayer)); | |
| 381 | const code = yield* Effect.callback<string | undefined>((resume) => { | |
| 382 | const socket = NodeNet.connect({ host: "127.0.0.1", port: Number(endpoint.split(":")[1]) }); | |
| 383 | socket.once("connect", () => resume(Effect.die("Progress listener outlived its scope"))); | |
| 384 | socket.once("error", (error: NodeJS.ErrnoException) => resume(Effect.succeed(error.code))); | |
| 385 | return Effect.sync(() => socket.destroy()); | |
| 386 | }); | |
| 387 | expect(code).toBe("ECONNREFUSED"); | |
| 388 | }), | |
| 389 | ); | |
| 390 | ||
| 391 | it.effect( | |
| 392 | "replaces a reconnected root while preserving another producer with the same PID and another thread", | |
| 393 | () => | |
| 394 | Effect.gen(function* () { | |
| 395 | const progress = yield* ScriptProgress.ScriptProgress; | |
| 396 | const env = yield* progress.environmentForThread(threadId); | |
| 397 | const otherEnv = yield* progress.environmentForThread(otherThreadId); | |
| 398 | const queue = yield* Stream.toQueue(progress.subscribe(threadId), { capacity: "unbounded" }); | |
| 399 | const pull = Queue.take(queue); | |
| 400 | yield* pull; | |
| 401 | const original = yield* connect(env.T3_SCRIPT_PROGRESS_ENDPOINT!); | |
| 402 | yield* write(original, { | |
| 403 | type: "hello", | |
| 404 | token: env.T3_SCRIPT_PROGRESS_TOKEN, | |
| 405 | producerId: "producer-a", | |
| 406 | pid: 42, | |
| 407 | }); | |
| 408 | yield* write(original, { | |
| 409 | type: "progress", | |
| 410 | streamId: "root", | |
| 411 | event: [-1, { k: 1, t: "Original", v: 7 }, [1]], | |
| 412 | }); | |
| 413 | const originalEvent = yield* until( | |
| 414 | pull, | |
| 415 | (event) => event.type === "progress" && event.summary[0]?.text === "Original", | |
| 416 | ); | |
| 417 | if (originalEvent.type !== "progress") throw new Error("Expected original progress"); | |
| 418 | ||
| 419 | const worker = yield* connect(env.T3_SCRIPT_PROGRESS_ENDPOINT!); | |
| 420 | yield* write(worker, { | |
| 421 | type: "hello", | |
| 422 | token: env.T3_SCRIPT_PROGRESS_TOKEN, | |
| 423 | producerId: "producer-b", | |
| 424 | pid: 42, | |
| 425 | }); | |
| 426 | yield* write(worker, { | |
| 427 | type: "progress", | |
| 428 | streamId: "root", | |
| 429 | event: [-1, { k: 1, t: "Worker" }, [1]], | |
| 430 | }); | |
| 431 | yield* until( | |
| 432 | pull, | |
| 433 | (event) => event.type === "progress" && event.summary[0]?.text === "Worker", | |
| 434 | ); | |
| 435 | const otherQueue = yield* Stream.toQueue(progress.subscribe(otherThreadId), { | |
| 436 | capacity: "unbounded", | |
| 437 | }); | |
| 438 | const otherPull = Queue.take(otherQueue); | |
| 439 | yield* otherPull; | |
| 440 | const other = yield* connect(otherEnv.T3_SCRIPT_PROGRESS_ENDPOINT!); | |
| 441 | yield* write(other, { | |
| 442 | type: "hello", | |
| 443 | token: otherEnv.T3_SCRIPT_PROGRESS_TOKEN, | |
| 444 | producerId: "producer-a", | |
| 445 | pid: 42, | |
| 446 | }); | |
| 447 | yield* write(other, { | |
| 448 | type: "progress", | |
| 449 | streamId: "root", | |
| 450 | event: [-1, { k: 1, t: "Other thread" }, [1]], | |
| 451 | }); | |
| 452 | yield* until( | |
| 453 | otherPull, | |
| 454 | (event) => event.type === "progress" && event.summary[0]?.text === "Other thread", | |
| 455 | ); | |
| 456 | ||
| 457 | yield* Effect.sync(() => original.destroy()); | |
| 458 | yield* until( | |
| 459 | pull, | |
| 460 | (event) => | |
| 461 | event.type === "upsert" && | |
| 462 | event.stream.id === originalEvent.id && | |
| 463 | event.stream.status === "disconnected", | |
| 464 | ); | |
| 465 | const reconnected = yield* connect(env.T3_SCRIPT_PROGRESS_ENDPOINT!); | |
| 466 | yield* write(reconnected, { | |
| 467 | type: "hello", | |
| 468 | token: env.T3_SCRIPT_PROGRESS_TOKEN, | |
| 469 | producerId: "producer-a", | |
| 470 | pid: 42, | |
| 471 | }); | |
| 472 | yield* write(reconnected, { | |
| 473 | type: "progress", | |
| 474 | streamId: "root", | |
| 475 | event: [-1, { k: 1, t: "Reconnected", v: 8 }, [1]], | |
| 476 | }); | |
| 477 | yield* until( | |
| 478 | pull, | |
| 479 | (event) => event.type === "progress" && event.summary[0]?.text === "Reconnected", | |
| 480 | ); | |
| 481 | const late = yield* Stream.toQueue(progress.subscribe(threadId), { capacity: "unbounded" }); | |
| 482 | const snapshot = yield* Queue.take(late); | |
| 483 | if (snapshot.type !== "snapshot") throw new Error("Expected snapshot"); | |
| 484 | expect(snapshot.streams).toHaveLength(2); | |
| 485 | expect(snapshot.streams.every((stream) => stream.status === "connected")).toBe(true); | |
| 486 | expect(snapshot.streams.map((stream) => stream.summary[0]?.text)).toEqual([ | |
| 487 | "Worker", | |
| 488 | "Reconnected", | |
| 489 | ]); | |
| 490 | expect(snapshot.streams.some((stream) => stream.id === originalEvent.id)).toBe(false); | |
| 491 | const otherLate = yield* Stream.toQueue(progress.subscribe(otherThreadId), { | |
| 492 | capacity: "unbounded", | |
| 493 | }); | |
| 494 | expect(yield* Queue.take(otherLate)).toMatchObject({ | |
| 495 | type: "snapshot", | |
| 496 | streams: [{ status: "connected", summary: [{ text: "Other thread" }] }], | |
| 497 | }); | |
| 498 | }).pipe(Effect.scoped, Effect.provide(testLayer)), | |
| 499 | ); | |
| 500 | ||
| 501 | it.effect("uses the library visibility rule for native summaries", () => | |
| 502 | Effect.gen(function* () { | |
| 503 | const progress = yield* ScriptProgress.ScriptProgress; | |
| 504 | const env = yield* progress.environmentForThread(threadId); | |
| 505 | const queue = yield* Stream.toQueue(progress.subscribe(threadId), { capacity: "unbounded" }); | |
| 506 | const pull = Queue.take(queue); | |
| 507 | yield* pull; | |
| 508 | const socket = yield* connect(env.T3_SCRIPT_PROGRESS_ENDPOINT!); | |
| 509 | yield* write(socket, { | |
| 510 | type: "hello", | |
| 511 | token: env.T3_SCRIPT_PROGRESS_TOKEN, | |
| 512 | producerId: "visibility", | |
| 513 | pid: 45, | |
| 514 | }); | |
| 515 | yield* write(socket, { | |
| 516 | type: "progress", | |
| 517 | streamId: "root", | |
| 518 | event: [ | |
| 519 | -1, | |
| 520 | { k: 1, t: "Empty group", p: true }, | |
| 521 | { k: 2, t: "Hidden", h: true }, | |
| 522 | { k: 3, t: "Visible", v: 2, e: 5 }, | |
| 523 | [1, 2, 3], | |
| 524 | ], | |
| 525 | }); | |
| 526 | const event = yield* until( | |
| 527 | pull, | |
| 528 | (event) => event.type === "progress" && event.summary.length > 0, | |
| 529 | ); | |
| 530 | expect(event).toMatchObject({ | |
| 531 | type: "progress", | |
| 532 | summary: [{ text: "Visible", value: 2, total: 5 }], | |
| 533 | }); | |
| 534 | if (event.type !== "progress") throw new Error("Expected progress"); | |
| 535 | expect(event.summary).toHaveLength(1); | |
| 536 | }).pipe(Effect.scoped, Effect.provide(testLayer)), | |
| 537 | ); |
apps/server/src/progress/ScriptProgress.ts created+374| ... | ... | @@ -0,0 +1,374 @@ |
| 1 | // @effect-diagnostics nodeBuiltinImport:off -- Socket callbacks allocate opaque capture identifiers synchronously. | |
| 2 | import * as NodeNet from "node:net"; | |
| 3 | import * as NodeCrypto from "node:crypto"; | |
| 4 | import { | |
| 5 | Root, | |
| 6 | decodeEventStream, | |
| 7 | encodeEventStream, | |
| 8 | isVisible, | |
| 9 | type StreamEvent, | |
| 10 | } from "@clo/lib/progress"; | |
| 11 | import { | |
| 12 | ScriptProgressStreamError, | |
| 13 | type ScriptProgressEvent, | |
| 14 | type ScriptProgressStream, | |
| 15 | type ThreadId, | |
| 16 | } from "@t3tools/contracts"; | |
| 17 | import * as ScriptProgress from "@t3tools/provider-core/server/ScriptProgress"; | |
| 18 | import { progressPreloadSource } from "@t3tools/shared/progressPreload"; | |
| 19 | import * as Cause from "effect/Cause"; | |
| 20 | import * as Effect from "effect/Effect"; | |
| 21 | import * as FileSystem from "effect/FileSystem"; | |
| 22 | import * as Layer from "effect/Layer"; | |
| 23 | import * as Path from "effect/Path"; | |
| 24 | import * as Queue from "effect/Queue"; | |
| 25 | import * as Schema from "effect/Schema"; | |
| 26 | import * as Stream from "effect/Stream"; | |
| 27 | ||
| 28 | import * as ServerConfig from "../config.ts"; | |
| 29 | ||
| 30 | export class ScriptProgressSetupError extends Schema.TaggedError<ScriptProgressSetupError>()( | |
| 31 | "ScriptProgressSetupError", | |
| 32 | { operation: Schema.Literals(["preload", "listen"]), cause: Schema.Defect() }, | |
| 33 | ) { | |
| 34 | override get message(): string { | |
| 35 | return "Unable to prepare script progress capture."; | |
| 36 | } | |
| 37 | } | |
| 38 | ||
| 39 | const Hello = Schema.Struct({ | |
| 40 | type: Schema.Literal("hello"), | |
| 41 | token: Schema.String.check(Schema.isMaxLength(128)), | |
| 42 | pid: Schema.Int.check(Schema.isGreaterThan(0)), | |
| 43 | producerId: Schema.String.check(Schema.isMinLength(1)).check(Schema.isMaxLength(128)), | |
| 44 | label: Schema.optional(Schema.String.check(Schema.isMaxLength(256))), | |
| 45 | }); | |
| 46 | const Progress = Schema.Struct({ | |
| 47 | type: Schema.Literal("progress"), | |
| 48 | streamId: Schema.String.check(Schema.isMaxLength(128)), | |
| 49 | event: Schema.Array(Schema.Json).check(Schema.isMaxLength(4096)), | |
| 50 | }); | |
| 51 | const decodeHello = Schema.decodeUnknownSync(Hello); | |
| 52 | const decodeProgress = Schema.decodeUnknownSync(Progress); | |
| 53 | const decodePacket = Schema.decodeSync(Schema.fromJsonString(Schema.Array(Schema.Json))); | |
| 54 | const encodePreloadPath = Schema.encodeSync(Schema.fromJsonString(Schema.String)); | |
| 55 | ||
| 56 | type Capture = { | |
| 57 | readonly id: string; | |
| 58 | readonly threadId: ThreadId; | |
| 59 | readonly pid: number; | |
| 60 | readonly producerId: string; | |
| 61 | readonly streamId: string; | |
| 62 | readonly socket: NodeNet.Socket; | |
| 63 | readonly label: string; | |
| 64 | readonly root: Root; | |
| 65 | readonly input: ReadableStreamDefaultController<StreamEvent>; | |
| 66 | readonly decoder: ReturnType<typeof decodeEventStream<void>>; | |
| 67 | status: "connected" | "disconnected"; | |
| 68 | }; | |
| 69 | ||
| 70 | type Subscriber = { | |
| 71 | readonly threadId: ThreadId; | |
| 72 | readonly send: (event: ScriptProgressEvent) => boolean; | |
| 73 | readonly attach: (capture: Capture) => void; | |
| 74 | readonly detach: (id: string) => void; | |
| 75 | }; | |
| 76 | ||
| 77 | const describe = (capture: Capture): ScriptProgressStream => ({ | |
| 78 | id: capture.id, | |
| 79 | pid: capture.pid, | |
| 80 | label: capture.label, | |
| 81 | status: capture.status, | |
| 82 | summary: capture.root.active.filter(isVisible).map((node) => ({ | |
| 83 | text: node.text, | |
| 84 | value: node.value, | |
| 85 | total: node.total, | |
| 86 | detail: node.showTotal ? node.valueFormatter(node.value, node.total || null) : "", | |
| 87 | })), | |
| 88 | }); | |
| 89 | ||
| 90 | const make = Effect.gen(function* () { | |
| 91 | const fileSystem = yield* FileSystem.FileSystem; | |
| 92 | const path = yield* Path.Path; | |
| 93 | const config = yield* ServerConfig.ServerConfig; | |
| 94 | const runtimeDirectory = path.join(config.stateDir, "runtime"); | |
| 95 | const preloadPath = path.join(runtimeDirectory, "script-progress-preload.cjs"); | |
| 96 | yield* fileSystem.makeDirectory(runtimeDirectory, { recursive: true }).pipe( | |
| 97 | Effect.andThen(fileSystem.writeFileString(preloadPath, progressPreloadSource, { mode: 0o600 })), | |
| 98 | Effect.mapError((cause) => new ScriptProgressSetupError({ operation: "preload", cause })), | |
| 99 | ); | |
| 100 | ||
| 101 | const tokens = new Map<ThreadId, string>(); | |
| 102 | const tokenThreads = new Map<string, ThreadId>(); | |
| 103 | const captures = new Map<string, Capture>(); | |
| 104 | const subscribers = new Set<Subscriber>(); | |
| 105 | const sockets = new Set<NodeNet.Socket>(); | |
| 106 | let closed = false; | |
| 107 | ||
| 108 | const forget = (capture: Capture) => { | |
| 109 | captures.delete(capture.id); | |
| 110 | for (const subscriber of subscribers) { | |
| 111 | if (subscriber.threadId !== capture.threadId) continue; | |
| 112 | subscriber.detach(capture.id); | |
| 113 | subscriber.send({ type: "remove", id: capture.id }); | |
| 114 | } | |
| 115 | capture.decoder.cancel(); | |
| 116 | }; | |
| 117 | ||
| 118 | const server = NodeNet.createServer((socket) => { | |
| 119 | if (closed || sockets.size >= 256) { | |
| 120 | socket.destroy(); | |
| 121 | return; | |
| 122 | } | |
| 123 | sockets.add(socket); | |
| 124 | socket.setEncoding("utf8"); | |
| 125 | socket.setTimeout(10_000); | |
| 126 | let pending = ""; | |
| 127 | let hello: typeof Hello.Type | undefined; | |
| 128 | let threadId: ThreadId | undefined; | |
| 129 | let pendingPackets = 0; | |
| 130 | const roots = new Map<string, Capture>(); | |
| 131 | socket.on("error", () => socket.destroy()); | |
| 132 | socket.on("timeout", () => socket.destroy()); | |
| 133 | socket.on("close", () => { | |
| 134 | sockets.delete(socket); | |
| 135 | if (closed) return; | |
| 136 | for (const capture of roots.values()) { | |
| 137 | if (!captures.has(capture.id)) continue; | |
| 138 | capture.status = "disconnected"; | |
| 139 | captures.delete(capture.id); | |
| 140 | captures.set(capture.id, capture); | |
| 141 | for (const subscriber of subscribers) { | |
| 142 | if (subscriber.threadId === capture.threadId) { | |
| 143 | subscriber.send({ type: "upsert", stream: describe(capture) }); | |
| 144 | } | |
| 145 | } | |
| 146 | } | |
| 147 | const disconnected = [...captures.values()].filter( | |
| 148 | (capture) => capture.threadId === threadId && capture.status === "disconnected", | |
| 149 | ); | |
| 150 | for (const capture of disconnected.slice(0, -16)) forget(capture); | |
| 151 | }); | |
| 152 | socket.on("data", (chunk: string) => { | |
| 153 | if (socket.destroyed) return; | |
| 154 | pending += chunk; | |
| 155 | if (Buffer.byteLength(pending) > 1024 * 1024) { | |
| 156 | socket.destroy(); | |
| 157 | return; | |
| 158 | } | |
| 159 | try { | |
| 160 | let newline: number; | |
| 161 | while ((newline = pending.indexOf("\n")) !== -1) { | |
| 162 | const line = pending.slice(0, newline); | |
| 163 | pending = pending.slice(newline + 1); | |
| 164 | const message: unknown = JSON.parse(line); | |
| 165 | if (!hello) { | |
| 166 | hello = decodeHello(message); | |
| 167 | threadId = tokenThreads.get(hello.token); | |
| 168 | if (!threadId) throw new Error("Unknown progress token"); | |
| 169 | socket.setTimeout(0); | |
| 170 | continue; | |
| 171 | } | |
| 172 | const packet = decodeProgress(message); | |
| 173 | if (!threadId || ++pendingPackets > 256) throw new Error("Progress input overflow"); | |
| 174 | let capture = roots.get(packet.streamId); | |
| 175 | if (!capture) { | |
| 176 | if (roots.size >= 16) throw new Error("Too many progress roots"); | |
| 177 | for (const previous of captures.values()) { | |
| 178 | if ( | |
| 179 | previous.threadId !== threadId || | |
| 180 | previous.producerId !== hello.producerId || | |
| 181 | previous.streamId !== packet.streamId | |
| 182 | ) | |
| 183 | continue; | |
| 184 | forget(previous); | |
| 185 | previous.socket.destroy(); | |
| 186 | } | |
| 187 | const threadCaptures = [...captures.values()].filter( | |
| 188 | (item) => item.threadId === threadId, | |
| 189 | ); | |
| 190 | if (threadCaptures.filter((item) => item.status === "connected").length >= 64) { | |
| 191 | throw new Error("Too many active progress roots"); | |
| 192 | } | |
| 193 | if (captures.size >= 512) { | |
| 194 | const oldest = [...captures.values()].find((item) => item.status === "disconnected"); | |
| 195 | if (!oldest) throw new Error("Too many progress captures"); | |
| 196 | forget(oldest); | |
| 197 | } | |
| 198 | const root = new Root(); | |
| 199 | let input!: ReadableStreamDefaultController<StreamEvent>; | |
| 200 | const source = new ReadableStream<StreamEvent>({ | |
| 201 | start(controller) { | |
| 202 | input = controller; | |
| 203 | }, | |
| 204 | pull() { | |
| 205 | pendingPackets = Math.max(0, pendingPackets - 1); | |
| 206 | }, | |
| 207 | }); | |
| 208 | const decoder = decodeEventStream(source, root); | |
| 209 | capture = { | |
| 210 | id: NodeCrypto.randomUUID(), | |
| 211 | threadId, | |
| 212 | pid: hello.pid, | |
| 213 | producerId: hello.producerId, | |
| 214 | streamId: packet.streamId, | |
| 215 | socket, | |
| 216 | label: hello.label ?? "node", | |
| 217 | root, | |
| 218 | input, | |
| 219 | decoder, | |
| 220 | status: "connected", | |
| 221 | }; | |
| 222 | const activeCapture = capture; | |
| 223 | const checkRetainedState = () => { | |
| 224 | const stack = [...root.active]; | |
| 225 | let count = 0; | |
| 226 | let bytes = 0; | |
| 227 | while (stack.length > 0) { | |
| 228 | const node = stack.pop(); | |
| 229 | if (!node) break; | |
| 230 | bytes += | |
| 231 | Buffer.byteLength(node.text) + | |
| 232 | Buffer.byteLength(JSON.stringify(node.logs)) + | |
| 233 | Buffer.byteLength(node.valueFormatter(node.value, node.total || null)); | |
| 234 | if (++count > 4096 || bytes > 1024 * 1024) { | |
| 235 | forget(activeCapture); | |
| 236 | roots.delete(packet.streamId); | |
| 237 | socket.destroy(); | |
| 238 | return; | |
| 239 | } | |
| 240 | stack.push(...node.children); | |
| 241 | } | |
| 242 | }; | |
| 243 | root.on("change", checkRetainedState); | |
| 244 | void decoder | |
| 245 | .then(() => { | |
| 246 | roots.delete(packet.streamId); | |
| 247 | forget(activeCapture); | |
| 248 | }) | |
| 249 | .catch(() => { | |
| 250 | if (activeCapture.status === "connected") socket.destroy(); | |
| 251 | }); | |
| 252 | roots.set(packet.streamId, capture); | |
| 253 | captures.set(capture.id, capture); | |
| 254 | for (const subscriber of subscribers) { | |
| 255 | if (subscriber.threadId !== threadId) continue; | |
| 256 | subscriber.send({ type: "upsert", stream: describe(capture) }); | |
| 257 | subscriber.attach(capture); | |
| 258 | } | |
| 259 | } | |
| 260 | // The library decoder owns protocol validation and state transitions. | |
| 261 | capture.input.enqueue(packet.event as StreamEvent); | |
| 262 | } | |
| 263 | } catch { | |
| 264 | socket.destroy(); | |
| 265 | } | |
| 266 | }); | |
| 267 | }); | |
| 268 | ||
| 269 | const address = yield* Effect.acquireRelease( | |
| 270 | Effect.callback<NodeNet.AddressInfo, ScriptProgressSetupError>((resume) => { | |
| 271 | const onError = (cause: Error) => | |
| 272 | resume(Effect.fail(new ScriptProgressSetupError({ operation: "listen", cause }))); | |
| 273 | server.once("error", onError); | |
| 274 | server.listen(0, "127.0.0.1", () => { | |
| 275 | server.off("error", onError); | |
| 276 | const address = server.address(); | |
| 277 | if (address && typeof address !== "string") resume(Effect.succeed(address)); | |
| 278 | }); | |
| 279 | return Effect.sync(() => server.close()); | |
| 280 | }), | |
| 281 | () => | |
| 282 | Effect.sync(() => { | |
| 283 | closed = true; | |
| 284 | for (const socket of sockets) socket.destroy(); | |
| 285 | for (const capture of captures.values()) capture.decoder.cancel(); | |
| 286 | tokens.clear(); | |
| 287 | tokenThreads.clear(); | |
| 288 | server.close(); | |
| 289 | }), | |
| 290 | ); | |
| 291 | ||
| 292 | return ScriptProgress.ScriptProgress.of({ | |
| 293 | environmentForThread: (threadId) => | |
| 294 | Effect.sync(() => { | |
| 295 | let token = tokens.get(threadId); | |
| 296 | if (!token) { | |
| 297 | token = NodeCrypto.randomBytes(32).toString("hex"); | |
| 298 | tokens.set(threadId, token); | |
| 299 | tokenThreads.set(token, threadId); | |
| 300 | } | |
| 301 | return { | |
| 302 | T3_SCRIPT_PROGRESS_ENDPOINT: `127.0.0.1:${address.port}`, | |
| 303 | T3_SCRIPT_PROGRESS_TOKEN: token, | |
| 304 | NODE_OPTIONS: `--require=${encodePreloadPath(preloadPath)}`, | |
| 305 | }; | |
| 306 | }), | |
| 307 | subscribe: (threadId) => | |
| 308 | Stream.callback<ScriptProgressEvent, ScriptProgressStreamError>( | |
| 309 | (queue) => | |
| 310 | Effect.acquireRelease( | |
| 311 | Effect.sync(() => { | |
| 312 | const readers = new Map<string, ReadableStreamDefaultReader<StreamEvent>>(); | |
| 313 | let disposed = false; | |
| 314 | const send = (event: ScriptProgressEvent) => { | |
| 315 | if (disposed) return false; | |
| 316 | if (Queue.offerUnsafe(queue, event)) return true; | |
| 317 | Queue.failCauseUnsafe(queue, Cause.fail(new ScriptProgressStreamError({}))); | |
| 318 | return false; | |
| 319 | }; | |
| 320 | const subscriber: Subscriber = { | |
| 321 | threadId, | |
| 322 | send, | |
| 323 | attach: (capture) => { | |
| 324 | const reader = encodeEventStream(capture.root, { throttleMs: 100 }).getReader(); | |
| 325 | readers.set(capture.id, reader); | |
| 326 | void (async () => { | |
| 327 | try { | |
| 328 | while (readers.get(capture.id) === reader) { | |
| 329 | const result = await reader.read(); | |
| 330 | if (result.done) break; | |
| 331 | if ( | |
| 332 | !send({ | |
| 333 | type: "progress", | |
| 334 | id: capture.id, | |
| 335 | event: decodePacket(JSON.stringify(result.value)), | |
| 336 | summary: describe(capture).summary, | |
| 337 | }) | |
| 338 | ) | |
| 339 | break; | |
| 340 | } | |
| 341 | } catch { | |
| 342 | // Disconnect is represented by the descriptor, never success. | |
| 343 | } finally { | |
| 344 | await reader.cancel().catch(() => {}); | |
| 345 | reader.releaseLock(); | |
| 346 | } | |
| 347 | })(); | |
| 348 | }, | |
| 349 | detach: (id) => { | |
| 350 | const reader = readers.get(id); | |
| 351 | readers.delete(id); | |
| 352 | void reader?.cancel().catch(() => {}); | |
| 353 | }, | |
| 354 | }; | |
| 355 | subscribers.add(subscriber); | |
| 356 | const existing = [...captures.values()].filter( | |
| 357 | (capture) => capture.threadId === threadId, | |
| 358 | ); | |
| 359 | send({ type: "snapshot", streams: existing.map(describe) }); | |
| 360 | for (const capture of existing) subscriber.attach(capture); | |
| 361 | return () => { | |
| 362 | disposed = true; | |
| 363 | subscribers.delete(subscriber); | |
| 364 | for (const id of readers.keys()) subscriber.detach(id); | |
| 365 | }; | |
| 366 | }), | |
| 367 | (dispose) => Effect.sync(dispose), | |
| 368 | ), | |
| 369 | { bufferSize: 256 }, | |
| 370 | ), | |
| 371 | }); | |
| 372 | }); | |
| 373 | ||
| 374 | export const layer = Layer.effect(ScriptProgress.ScriptProgress, make); |
apps/server/src/provider/Drivers/AntigravityDriver.test.ts+2-1| ... | ... | @@ -1,3 +1,4 @@ |
| 1 | import * as ScriptProgress from "@t3tools/provider-core/server/ScriptProgress"; | |
| 1 | 2 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 2 | 3 | import { expect, it } from "@effect/vitest"; |
| 3 | 4 | import { |
| ... | ... | @@ -267,7 +268,7 @@ const layerDeps = ServerConfig.layerTest(process.cwd(), { |
| 267 | 268 | ), |
| 268 | 269 | Layer.provideMerge(ModelManifest.layerTest), |
| 269 | 270 | Layer.provideMerge(IdAllocator.layer), |
| 270 | Layer.provideMerge(McpProviderSessions.layer), | |
| 271 | Layer.provideMerge(Layer.merge(McpProviderSessions.layer, ScriptProgress.layerUnavailable)), | |
| 271 | 272 | ); |
| 272 | 273 | const layerTest = ProviderHostLive.layer.pipe( |
| 273 | 274 | Layer.provideMerge(ServerSecretStore.layer), |
apps/server/src/provider/Drivers/AntigravityDriver.ts+6-1| ... | ... | @@ -1,3 +1,4 @@ |
| 1 | import * as ScriptProgress from "@t3tools/provider-core/server/ScriptProgress"; | |
| 1 | 2 | import { withAgentDeviceEnvironment } from "@t3tools/provider-core/server/mcpSession"; |
| 2 | 3 | import * as McpProviderSessions from "@t3tools/provider-core/server/McpProviderSessions"; |
| 3 | 4 | import { AntigravitySettings, ProviderDriverKind, ProviderSetupError } from "@t3tools/contracts"; |
| ... | ... | @@ -78,6 +79,7 @@ export type AntigravityDriverEnv = |
| 78 | 79 | | FileSystem.FileSystem |
| 79 | 80 | | HttpClient.HttpClient |
| 80 | 81 | | IdAllocator.IdAllocatorV2 |
| 82 | | ScriptProgress.ScriptProgress | |
| 81 | 83 | | McpProviderSessions.McpProviderSessions |
| 82 | 84 | | ModelCatalog.ModelCatalog |
| 83 | 85 | | Path.Path |
| ... | ... | @@ -249,7 +251,10 @@ export const AntigravityDriver: ProviderDriver< |
| 249 | 251 | installation: executable, |
| 250 | 252 | profile, |
| 251 | 253 | cwd: input.cwd, |
| 252 | baseEnv: withAgentDeviceEnvironment(processEnvironment, input), | |
| 254 | baseEnv: ScriptProgress.withScriptProgressEnvironment( | |
| 255 | withAgentDeviceEnvironment(processEnvironment, input), | |
| 256 | input.processEnvironment ?? {}, | |
| 257 | ), | |
| 253 | 258 | auth, |
| 254 | 259 | runtimeTempDirectory, |
| 255 | 260 | }), |
apps/server/src/provider/Drivers/CodexDriver.test.ts+2-1| ... | ... | @@ -1,3 +1,4 @@ |
| 1 | import * as ScriptProgress from "@t3tools/provider-core/server/ScriptProgress"; | |
| 1 | 2 | import * as CodexInstallation from "../CodexInstallation.ts"; |
| 2 | 3 | import * as ServerSecretStore from "../../auth/ServerSecretStore.ts"; |
| 3 | 4 | import * as ServerEnvironment from "../../environment/ServerEnvironment.ts"; |
| ... | ... | @@ -46,7 +47,7 @@ const layerDeps = ServerConfig.layerTest(process.cwd(), { |
| 46 | 47 | }).pipe( |
| 47 | 48 | Layer.provideMerge(NodeServices.layer), |
| 48 | 49 | Layer.provideMerge(IdAllocator.layer), |
| 49 | Layer.provideMerge(McpProviderSessions.layer), | |
| 50 | Layer.provideMerge(Layer.merge(McpProviderSessions.layer, ScriptProgress.layerUnavailable)), | |
| 50 | 51 | Layer.provideMerge( |
| 51 | 52 | Layer.mock(CodexAdapterV2.CodexAppServerClientFactory)({ |
| 52 | 53 | open: () => Effect.die("Maintenance resolution must not open a Codex session"), |
apps/server/src/provider/ProviderInstanceRegistry.test.ts+3-2| ... | ... | @@ -1,3 +1,4 @@ |
| 1 | import * as ScriptProgress from "@t3tools/provider-core/server/ScriptProgress"; | |
| 1 | 2 | import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; |
| 2 | 3 | import * as CodexInstallation from "./CodexInstallation.ts"; |
| 3 | 4 | import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; |
| ... | ... | @@ -233,7 +234,7 @@ describe("ProviderInstanceRegistry — multi-instance codex slice", () => { |
| 233 | 234 | ), |
| 234 | 235 | ), |
| 235 | 236 | Layer.provideMerge(ProviderLatestVersions.layer), |
| 236 | Layer.provideMerge(McpProviderSessions.layer), | |
| 237 | Layer.provideMerge(Layer.merge(McpProviderSessions.layer, ScriptProgress.layerUnavailable)), | |
| 237 | 238 | Layer.provideMerge(ModelManifest.layerTest), |
| 238 | 239 | Layer.provideMerge(ResetCreditCoordinator.layerTest), |
| 239 | 240 | ); |
| ... | ... | @@ -652,7 +653,7 @@ describe("ProviderInstanceRegistry — all drivers slice", () => { |
| 652 | 653 | ), |
| 653 | 654 | ), |
| 654 | 655 | Layer.provideMerge(ProviderLatestVersions.layer), |
| 655 | Layer.provideMerge(McpProviderSessions.layer), | |
| 656 | Layer.provideMerge(Layer.merge(McpProviderSessions.layer, ScriptProgress.layerUnavailable)), | |
| 656 | 657 | Layer.provideMerge(ModelManifest.layerTest), |
| 657 | 658 | Layer.provideMerge(ResetCreditCoordinator.layerTest), |
| 658 | 659 | ); |
apps/server/src/provider/ProviderRegistry.test.ts+13-4| ... | ... | @@ -1,3 +1,4 @@ |
| 1 | import * as ScriptProgress from "@t3tools/provider-core/server/ScriptProgress"; | |
| 1 | 2 | import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; |
| 2 | 3 | import * as CodexInstallation from "./CodexInstallation.ts"; |
| 3 | 4 | import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; |
| ... | ... | @@ -2762,7 +2763,9 @@ it.layer( |
| 2762 | 2763 | ), |
| 2763 | 2764 | ), |
| 2764 | 2765 | Layer.provideMerge(ProviderLatestVersions.layer), |
| 2765 | Layer.provideMerge(McpProviderSessions.layer), | |
| 2766 | Layer.provideMerge( | |
| 2767 | Layer.merge(McpProviderSessions.layer, ScriptProgress.layerUnavailable), | |
| 2768 | ), | |
| 2766 | 2769 | Layer.provideMerge(ModelManifest.layerTest), |
| 2767 | 2770 | Layer.provideMerge(ResetCreditCoordinator.layerTest), |
| 2768 | 2771 | Layer.provideMerge( |
| ... | ... | @@ -2867,7 +2870,9 @@ it.layer( |
| 2867 | 2870 | ), |
| 2868 | 2871 | ), |
| 2869 | 2872 | Layer.provideMerge(ProviderLatestVersions.layer), |
| 2870 | Layer.provideMerge(McpProviderSessions.layer), | |
| 2873 | Layer.provideMerge( | |
| 2874 | Layer.merge(McpProviderSessions.layer, ScriptProgress.layerUnavailable), | |
| 2875 | ), | |
| 2871 | 2876 | Layer.provideMerge(ModelManifest.layerTest), |
| 2872 | 2877 | Layer.provideMerge(ResetCreditCoordinator.layerTest), |
| 2873 | 2878 | Layer.provideMerge( |
| ... | ... | @@ -2987,7 +2992,9 @@ it.layer( |
| 2987 | 2992 | ), |
| 2988 | 2993 | ), |
| 2989 | 2994 | Layer.provideMerge(ProviderLatestVersions.layer), |
| 2990 | Layer.provideMerge(McpProviderSessions.layer), | |
| 2995 | Layer.provideMerge( | |
| 2996 | Layer.merge(McpProviderSessions.layer, ScriptProgress.layerUnavailable), | |
| 2997 | ), | |
| 2991 | 2998 | Layer.provideMerge(ModelManifest.layerTest), |
| 2992 | 2999 | Layer.provideMerge(ResetCreditCoordinator.layerTest), |
| 2993 | 3000 | Layer.provideMerge( |
| ... | ... | @@ -3047,7 +3054,9 @@ it.layer( |
| 3047 | 3054 | ), |
| 3048 | 3055 | ), |
| 3049 | 3056 | Layer.provideMerge(ProviderLatestVersions.layer), |
| 3050 | Layer.provideMerge(McpProviderSessions.layer), | |
| 3057 | Layer.provideMerge( | |
| 3058 | Layer.merge(McpProviderSessions.layer, ScriptProgress.layerUnavailable), | |
| 3059 | ), | |
| 3051 | 3060 | Layer.provideMerge(ModelManifest.layerTest), |
| 3052 | 3061 | Layer.provideMerge(ResetCreditCoordinator.layerTest), |
| 3053 | 3062 | Layer.provideMerge( |
apps/server/src/provider/acp/AntigravityAcpSupport.ts+1| ... | ... | @@ -36,6 +36,7 @@ export interface AntigravityAcpRuntimeInput extends Omit< |
| 36 | 36 | | "transformSessionUpdate" |
| 37 | 37 | | "transformStdout" |
| 38 | 38 | > { |
| 39 | readonly processEnvironment?: NodeJS.ProcessEnv; | |
| 39 | 40 | /** Device CLI environment supplied for this provider session. */ |
| 40 | 41 | readonly agentDeviceEnvironment?: Readonly<Record<string, string>>; |
| 41 | 42 | readonly childProcessSpawner: ChildProcessSpawner.ChildProcessSpawner["Service"]; |
apps/server/src/server.ts+2| ... | ... | @@ -1,3 +1,4 @@ |
| 1 | import * as ScriptProgress from "./progress/ScriptProgress.ts"; | |
| 1 | 2 | import * as ProviderEventLoggers from "@t3tools/provider-core/server/ProviderEventLoggers"; |
| 2 | 3 | import type { RelayManagedEndpointRuntimeConfig } from "@t3tools/contracts/relay"; |
| 3 | 4 | import * as Clock from "effect/Clock"; |
| ... | ... | @@ -1116,6 +1117,7 @@ const layerMakeServer = Layer.unwrap( |
| 1116 | 1117 | Layer.provide(VcsProcess.layer), |
| 1117 | 1118 | // Every agent and terminal spawn reads this, so it sits below everything. |
| 1118 | 1119 | Layer.provideMerge(AgentScopeLive.layer), |
| 1120 | Layer.provideMerge(ScriptProgress.layer), | |
| 1119 | 1121 | Layer.provideMerge(layerPlatformServices), |
| 1120 | 1122 | ); |
| 1121 | 1123 | }), |
apps/server/src/terminal/Manager.progress.test.ts created+114| ... | ... | @@ -0,0 +1,114 @@ |
| 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 2 | import { expect, it } from "@effect/vitest"; | |
| 3 | import { DEFAULT_TERMINAL_ID, ThreadId } from "@t3tools/contracts"; | |
| 4 | import * as ScriptProgress from "@t3tools/provider-core/server/ScriptProgress"; | |
| 5 | import * as PtyAdapter from "@t3tools/shared/PtyAdapter"; | |
| 6 | import * as Deferred from "effect/Deferred"; | |
| 7 | import * as Effect from "effect/Effect"; | |
| 8 | import * as FileSystem from "effect/FileSystem"; | |
| 9 | import * as Layer from "effect/Layer"; | |
| 10 | import * as Path from "effect/Path"; | |
| 11 | import * as Stream from "effect/Stream"; | |
| 12 | import * as ProcessRunner from "../processRunner.ts"; | |
| 13 | import * as Manager from "./Manager.ts"; | |
| 14 | import * as NodePtyAdapter from "./NodePtyAdapter.ts"; | |
| 15 | ||
| 16 | it.live( | |
| 17 | "injects progress after terminal filtering and keeps concurrent shells and their children isolated", | |
| 18 | () => | |
| 19 | Effect.gen(function* () { | |
| 20 | const fs = yield* FileSystem.FileSystem; | |
| 21 | const path = yield* Path.Path; | |
| 22 | const directory = yield* fs.makeTempDirectoryScoped({ prefix: "t3-progress-terminal-" }); | |
| 23 | const preload = path.join(directory, "progress preload.mjs"); | |
| 24 | const userPreload = path.join(directory, "user.mjs"); | |
| 25 | const script = path.join(directory, "probe.mjs"); | |
| 26 | yield* fs.writeFileString(preload, "globalThis.progressPreloaded = true;"); | |
| 27 | yield* fs.writeFileString(userPreload, "globalThis.userPreloaded = true;"); | |
| 28 | yield* fs.writeFileString( | |
| 29 | script, | |
| 30 | ` | |
| 31 | import { execFileSync } from 'node:child_process'; | |
| 32 | const child = execFileSync(process.execPath, ['--input-type=module', '-e', | |
| 33 | 'console.log(JSON.stringify({ token: process.env.T3_SCRIPT_PROGRESS_TOKEN, nativeThread: process.env.CODEX_THREAD_ID, progress: globalThis.progressPreloaded, user: globalThis.userPreloaded }))' | |
| 34 | ], { encoding: 'utf8' }); | |
| 35 | console.log('PROGRESS_RESULT:' + child.trim()); | |
| 36 | `, | |
| 37 | ); | |
| 38 | const originalOptions = process.env.NODE_OPTIONS; | |
| 39 | const completed = new Map([ | |
| 40 | ["progress-terminal-one", yield* Deferred.make<string>()], | |
| 41 | ["progress-terminal-two", yield* Deferred.make<string>()], | |
| 42 | ]); | |
| 43 | const output = new Map<string, string>(); | |
| 44 | const manager = yield* Manager.makeWithOptions({ | |
| 45 | logsDir: path.join(directory, "logs"), | |
| 46 | ptyAdapter: yield* PtyAdapter.PtyAdapter, | |
| 47 | shellResolver: () => "/bin/sh", | |
| 48 | env: { ...process.env, NODE_OPTIONS: `--import=${JSON.stringify(userPreload)}` }, | |
| 49 | subprocessInspector: () => | |
| 50 | Effect.succeed({ hasRunningSubprocess: false, childCommand: null, processIds: [] }), | |
| 51 | }).pipe( | |
| 52 | Effect.provideService(ScriptProgress.ScriptProgress, { | |
| 53 | environmentForThread: (threadId) => | |
| 54 | Effect.succeed({ | |
| 55 | NODE_OPTIONS: `--import=${JSON.stringify(preload)}`, | |
| 56 | T3_SCRIPT_PROGRESS_ENDPOINT: "http://127.0.0.1:1/progress", | |
| 57 | T3_SCRIPT_PROGRESS_TOKEN: threadId, | |
| 58 | }), | |
| 59 | subscribe: () => Stream.empty, | |
| 60 | }), | |
| 61 | ); | |
| 62 | const unsubscribe = yield* manager.subscribe((event) => { | |
| 63 | if (event.type !== "output") return Effect.void; | |
| 64 | const data = (output.get(event.threadId) ?? "") + event.data; | |
| 65 | output.set(event.threadId, data); | |
| 66 | const result = /PROGRESS_RESULT:([^\r\n]+)[\r\n]/.exec(data); | |
| 67 | const done = completed.get(event.threadId); | |
| 68 | return result && done | |
| 69 | ? Deferred.succeed(done, result[1]!).pipe(Effect.asVoid) | |
| 70 | : Effect.void; | |
| 71 | }); | |
| 72 | yield* Effect.addFinalizer(() => Effect.sync(unsubscribe)); | |
| 73 | yield* Effect.all( | |
| 74 | [...completed].map(([threadId, done]) => | |
| 75 | Effect.gen(function* () { | |
| 76 | yield* manager.open({ | |
| 77 | threadId, | |
| 78 | terminalId: DEFAULT_TERMINAL_ID, | |
| 79 | cwd: directory, | |
| 80 | cols: 120, | |
| 81 | rows: 24, | |
| 82 | env: { CODEX_THREAD_ID: "native-child" }, | |
| 83 | }); | |
| 84 | const quote = (value: string) => `'${value.replaceAll("'", "'\\''")}'`; | |
| 85 | yield* manager.write({ | |
| 86 | threadId, | |
| 87 | terminalId: DEFAULT_TERMINAL_ID, | |
| 88 | data: `${quote(process.execPath)} ${quote(script)}\n`, | |
| 89 | }); | |
| 90 | const result = JSON.parse(yield* Deferred.await(done)); | |
| 91 | expect(result).toEqual({ | |
| 92 | token: ThreadId.make(threadId), | |
| 93 | nativeThread: "native-child", | |
| 94 | progress: true, | |
| 95 | user: true, | |
| 96 | }); | |
| 97 | yield* manager.close({ threadId }); | |
| 98 | }), | |
| 99 | ), | |
| 100 | { concurrency: "unbounded" }, | |
| 101 | ); | |
| 102 | expect(process.env.NODE_OPTIONS).toBe(originalOptions); | |
| 103 | }).pipe( | |
| 104 | Effect.timeout("15 seconds"), | |
| 105 | Effect.scoped, | |
| 106 | Effect.provide( | |
| 107 | Layer.mergeAll( | |
| 108 | NodeServices.layer, | |
| 109 | NodePtyAdapter.layer.pipe(Layer.provide(NodeServices.layer)), | |
| 110 | ProcessRunner.layer.pipe(Layer.provide(NodeServices.layer)), | |
| 111 | ), | |
| 112 | ), | |
| 113 | ), | |
| 114 | ); |
apps/server/src/terminal/Manager.test.ts+2-1| ... | ... | @@ -1,3 +1,4 @@ |
| 1 | import * as ScriptProgress from "@t3tools/provider-core/server/ScriptProgress"; | |
| 1 | 2 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 2 | 3 | import { assert, it } from "@effect/vitest"; |
| 3 | 4 | import { |
| ... | ... | @@ -297,7 +298,7 @@ const createManager = ( |
| 297 | 298 | managedBinaryCacheDir: options.managedBinaryCacheDir, |
| 298 | 299 | managedBinaryToolsDir: options.managedBinaryToolsDir, |
| 299 | 300 | }), |
| 300 | }); | |
| 301 | }).pipe(Effect.provide(ScriptProgress.layerUnavailable)); | |
| 301 | 302 | const eventsRef = yield* Ref.make<ReadonlyArray<TerminalEvent>>([]); |
| 302 | 303 | const unsubscribe = yield* manager.subscribe((event) => |
| 303 | 304 | Ref.update(eventsRef, (events) => [...events, event]), |
apps/server/src/terminal/Manager.ts+11-5| ... | ... | @@ -1,3 +1,4 @@ |
| 1 | import * as ScriptProgress from "@t3tools/provider-core/server/ScriptProgress"; | |
| 1 | 2 | /** |
| 2 | 3 | * TerminalManager - Terminal session orchestration service interface. |
| 3 | 4 | * |
| ... | ... | @@ -9,6 +10,7 @@ |
| 9 | 10 | import { withWorkspaceLease } from "../workspace/workspaceLease.ts"; |
| 10 | 11 | import { |
| 11 | 12 | DEFAULT_TERMINAL_ID, |
| 13 | ThreadId, | |
| 12 | 14 | TerminalCwdError, |
| 13 | 15 | TerminalCwdNotDirectoryError, |
| 14 | 16 | TerminalCwdNotFoundError, |
| ... | ... | @@ -1491,6 +1493,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func |
| 1491 | 1493 | const platform = yield* HostProcess.Platform; |
| 1492 | 1494 | const architecture = yield* HostProcess.Architecture; |
| 1493 | 1495 | const agentScope = yield* AgentScope; |
| 1496 | const scriptProgress = yield* ScriptProgress.ScriptProgress; | |
| 1494 | 1497 | // Terminals must inherit the user's full environment (minus the blocklist |
| 1495 | 1498 | // applied in createTerminalSpawnEnv) — an allowlist here silently strips |
| 1496 | 1499 | // things like PSModulePath, DISPLAY, proxies, and toolchain variables. |
| ... | ... | @@ -2263,11 +2266,14 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func |
| 2263 | 2266 | Effect.andThen( |
| 2264 | 2267 | Effect.gen(function* () { |
| 2265 | 2268 | const shellCandidates = resolveShellCandidates(shellResolver, platform, baseEnv); |
| 2266 | const terminalEnv = createTerminalSpawnEnv( | |
| 2267 | baseEnv, | |
| 2268 | session.runtimeEnv, | |
| 2269 | platform, | |
| 2270 | yield* HostProcess.HomeDirectory, | |
| 2269 | const terminalEnv = ScriptProgress.withScriptProgressEnvironment( | |
| 2270 | createTerminalSpawnEnv( | |
| 2271 | baseEnv, | |
| 2272 | session.runtimeEnv, | |
| 2273 | platform, | |
| 2274 | yield* HostProcess.HomeDirectory, | |
| 2275 | ), | |
| 2276 | yield* scriptProgress.environmentForThread(ThreadId.make(session.threadId)), | |
| 2271 | 2277 | ); |
| 2272 | 2278 | // Append (never prepend) managed ACP agent install directories so |
| 2273 | 2279 | // `kimi login` and friends resolve by name without shadowing any |
apps/server/src/ws.ts+3| ... | ... | @@ -1,3 +1,4 @@ |
| 1 | import * as ScriptProgress from "@t3tools/provider-core/server/ScriptProgress"; | |
| 1 | 2 | import { OrchestrationDispatchCommandError } from "@t3tools/contracts"; |
| 2 | 3 | import * as Crypto from "effect/Crypto"; |
| 3 | 4 | import * as Orchestrator from "./orchestration-v2/Orchestrator.ts"; |
| ... | ... | @@ -1239,6 +1240,7 @@ const layerWsRpc = ( |
| 1239 | 1240 | const providerSessionManager = yield* ProviderSessionManager.ProviderSessionManagerV2; |
| 1240 | 1241 | const scheduledTasks = yield* ScheduledTasks.ScheduledTaskService; |
| 1241 | 1242 | const secretRequests = yield* SecretRequests.SecretRequests; |
| 1243 | const scriptProgress = yield* ScriptProgress.ScriptProgress; | |
| 1242 | 1244 | const pullRequests = yield* PullRequestService.PullRequestService; |
| 1243 | 1245 | const reviewGuides = yield* PullRequestReviewGuideService.PullRequestReviewGuideService; |
| 1244 | 1246 | const pullRequestSync = yield* PullRequestSyncReactor.PullRequestSyncReactor; |
| ... | ... | @@ -2902,6 +2904,7 @@ const layerWsRpc = ( |
| 2902 | 2904 | [WS_METHODS.reviewGetDiffPreview]: (input) => review.getDiffPreview(input), |
| 2903 | 2905 | [WS_METHODS.reviewGetDiffFileContents]: (input) => review.getDiffFileContents(input), |
| 2904 | 2906 | ...makeTerminalRpcHandlers(terminalManager), |
| 2907 | [WS_METHODS.subscribeScriptProgress]: (input) => scriptProgress.subscribe(input.threadId), | |
| 2905 | 2908 | [WS_METHODS.subscribeTerminalMetadata]: (_input) => |
| 2906 | 2909 | Stream.callback<TerminalMetadataStreamEvent>((queue) => |
| 2907 | 2910 | Effect.acquireRelease( |
apps/web/package.json+1| ... | ... | @@ -14,6 +14,7 @@ |
| 14 | 14 | "@base-ui/react": "^1.4.1", |
| 15 | 15 | "@clerk/electron": "catalog:", |
| 16 | 16 | "@clerk/react": "catalog:", |
| 17 | "@clo/lib": "workspace:*", | |
| 17 | 18 | "@clo/markdown-editor": "workspace:*", |
| 18 | 19 | "@clo/terminal": "workspace:*", |
| 19 | 20 | "@codemirror/commands": "^6.8.1", |
apps/web/src/components/ChatView.tsx+7| ... | ... | @@ -275,6 +275,7 @@ import { addBrowserSurface } from "./preview/addBrowserSurface"; |
| 275 | 275 | import { closePreviewSession } from "./preview/closePreviewSession"; |
| 276 | 276 | import { ThreadPreviewMiniPlayer } from "./preview/ThreadPreviewMiniPlayer"; |
| 277 | 277 | import { BrowserAudioIndicator } from "./preview/BrowserAudioIndicator"; |
| 278 | import { ScriptProgress } from "./chat/ScriptProgress"; | |
| 278 | 279 | import { usePreviewSession } from "./preview/usePreviewSession"; |
| 279 | 280 | import { subscribePreviewAction } from "./preview/previewActionBus"; |
| 280 | 281 | import { getConfiguredPreviewUrls } from "./preview/previewEmptyStateLogic"; |
| ... | ... | @@ -11751,6 +11752,12 @@ export default function ChatView(props: ChatViewProps) { |
| 11751 | 11752 | className="group/composer-stack pointer-events-auto relative z-10 mx-auto w-full max-w-(--chat-content-max-width)" |
| 11752 | 11753 | > |
| 11753 | 11754 | <BrowserAudioIndicator /> |
| 11755 | {!paintOnlyDisplayedTimeline ? ( | |
| 11756 | <ScriptProgress | |
| 11757 | environmentId={activeThread.environmentId} | |
| 11758 | threadId={activeThread.id} | |
| 11759 | /> | |
| 11760 | ) : null} | |
| 11754 | 11761 | {isDraftHeroState ? ( |
| 11755 | 11762 | <div className="absolute inset-x-0 bottom-full"> |
| 11756 | 11763 | <div |
apps/web/src/components/chat/ScriptProgress.test.tsx created+191| ... | ... | @@ -0,0 +1,191 @@ |
| 1 | // @vitest-environment jsdom | |
| 2 | ||
| 3 | import { act } from "react"; | |
| 4 | import { createRoot, type Root } from "react-dom/client"; | |
| 5 | import { AsyncResult, Atom } from "effect/reactivity"; | |
| 6 | import * as Stream from "effect/Stream"; | |
| 7 | import { | |
| 8 | EnvironmentId, | |
| 9 | ThreadId, | |
| 10 | type ScriptProgressEvent, | |
| 11 | type ScriptProgressStream, | |
| 12 | } from "@t3tools/contracts"; | |
| 13 | import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test"; | |
| 14 | ||
| 15 | import { appAtomRegistry } from "../../rpc/atomRegistry"; | |
| 16 | import { ScriptProgress } from "./ScriptProgress"; | |
| 17 | ||
| 18 | type ProgressResult = AsyncResult.AsyncResult<{ | |
| 19 | events: ReadonlyArray<ScriptProgressEvent>; | |
| 20 | streams: ReadonlyArray<ScriptProgressStream>; | |
| 21 | }>; | |
| 22 | ||
| 23 | const state = vi.hoisted(() => ({ atom: undefined as unknown })); | |
| 24 | vi.mock("../../state/scriptProgress", () => ({ | |
| 25 | scriptProgressEnvironment: { watch: () => state.atom }, | |
| 26 | })); | |
| 27 | ||
| 28 | let root: Root; | |
| 29 | let container: HTMLDivElement; | |
| 30 | let atom: Atom.Writable<ProgressResult, ProgressResult>; | |
| 31 | const descriptor: ScriptProgressStream = { | |
| 32 | id: "script", | |
| 33 | pid: 42, | |
| 34 | label: "render.ts", | |
| 35 | status: "connected", | |
| 36 | summary: [{ text: "Rendering", value: 4, total: 10, detail: "4/10" }], | |
| 37 | }; | |
| 38 | ||
| 39 | beforeEach(async () => { | |
| 40 | vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); | |
| 41 | const initial: ProgressResult = AsyncResult.success({ | |
| 42 | events: [{ type: "snapshot", streams: [] }], | |
| 43 | streams: [], | |
| 44 | }); | |
| 45 | atom = Atom.make<ProgressResult>(initial); | |
| 46 | state.atom = atom; | |
| 47 | container = document.createElement("div"); | |
| 48 | document.body.append(container); | |
| 49 | root = createRoot(container); | |
| 50 | await act(async () => { | |
| 51 | root.render( | |
| 52 | <ScriptProgress environmentId={EnvironmentId.make("test")} threadId={ThreadId.make("one")} />, | |
| 53 | ); | |
| 54 | }); | |
| 55 | }); | |
| 56 | ||
| 57 | afterEach(async () => { | |
| 58 | await act(async () => root.unmount()); | |
| 59 | container.remove(); | |
| 60 | vi.unstubAllGlobals(); | |
| 61 | }); | |
| 62 | ||
| 63 | async function emit(event: ScriptProgressEvent) { | |
| 64 | await act(async () => { | |
| 65 | appAtomRegistry.set(atom, AsyncResult.success({ events: [event], streams: [] })); | |
| 66 | }); | |
| 67 | } | |
| 68 | ||
| 69 | function progressbar() { | |
| 70 | const tree = container.querySelector("[data-progress-motion]"); | |
| 71 | return tree?.shadowRoot?.querySelector('[role="progressbar"]'); | |
| 72 | } | |
| 73 | ||
| 74 | it("renders actual progress values and resets to zero without resetting the tree", async () => { | |
| 75 | expect(container.querySelector("section")?.hidden).toBe(true); | |
| 76 | await emit({ type: "snapshot", streams: [descriptor] }); | |
| 77 | await emit({ | |
| 78 | type: "progress", | |
| 79 | id: descriptor.id, | |
| 80 | event: [-1, { k: 1, t: "Rendering", v: 4, e: 10 }, [1]], | |
| 81 | summary: descriptor.summary, | |
| 82 | }); | |
| 83 | await vi.waitFor(() => expect(progressbar()?.getAttribute("aria-valuenow")).toBe("4")); | |
| 84 | expect(progressbar()?.getAttribute("aria-valuemax")).toBe("10"); | |
| 85 | expect(container.querySelector("section")?.hidden).toBe(false); | |
| 86 | ||
| 87 | await emit({ | |
| 88 | type: "progress", | |
| 89 | id: descriptor.id, | |
| 90 | event: [0, { k: 1, v: 0 }], | |
| 91 | summary: [{ text: "Rendering", value: 0, total: 10, detail: "0/10" }], | |
| 92 | }); | |
| 93 | await vi.waitFor(() => expect(progressbar()?.getAttribute("aria-valuenow")).toBe("0")); | |
| 94 | }); | |
| 95 | ||
| 96 | it("removes the old chat's decoder and DOM before displaying another chat", async () => { | |
| 97 | await emit({ type: "snapshot", streams: [descriptor] }); | |
| 98 | await emit({ | |
| 99 | type: "progress", | |
| 100 | id: descriptor.id, | |
| 101 | event: [-1, { k: 1, t: "Rendering", v: 4, e: 10 }, [1]], | |
| 102 | summary: descriptor.summary, | |
| 103 | }); | |
| 104 | await vi.waitFor(() => expect(progressbar()).toBeTruthy()); | |
| 105 | await act(async () => { | |
| 106 | root.render( | |
| 107 | <ScriptProgress environmentId={EnvironmentId.make("test")} threadId={ThreadId.make("two")} />, | |
| 108 | ); | |
| 109 | }); | |
| 110 | expect(progressbar()).toBeUndefined(); | |
| 111 | expect(container.querySelector("section")?.hidden).toBe(true); | |
| 112 | }); | |
| 113 | ||
| 114 | it("requests a fresh tree when returning to a chat whose live atom cached a delta", async () => { | |
| 115 | let connections = 0; | |
| 116 | const snapshot: ScriptProgressEvent = { type: "snapshot", streams: [descriptor] }; | |
| 117 | const update: ScriptProgressEvent = { | |
| 118 | type: "progress", | |
| 119 | id: descriptor.id, | |
| 120 | event: [-1, { k: 1, t: "Rendering", v: 4, e: 10 }, [1]], | |
| 121 | summary: descriptor.summary, | |
| 122 | }; | |
| 123 | state.atom = Atom.make( | |
| 124 | Stream.suspend(() => { | |
| 125 | connections++; | |
| 126 | return Stream.concat( | |
| 127 | Stream.fromArray([{ events: [snapshot, update], streams: [descriptor] }]), | |
| 128 | Stream.never, | |
| 129 | ); | |
| 130 | }), | |
| 131 | ).pipe(Atom.keepAlive); | |
| 132 | ||
| 133 | await act(async () => { | |
| 134 | root.render( | |
| 135 | <ScriptProgress | |
| 136 | environmentId={EnvironmentId.make("test")} | |
| 137 | threadId={ThreadId.make("cached")} | |
| 138 | />, | |
| 139 | ); | |
| 140 | }); | |
| 141 | await vi.waitFor(() => expect(progressbar()?.getAttribute("aria-valuenow")).toBe("4")); | |
| 142 | const initialConnections = connections; | |
| 143 | await act(async () => root.unmount()); | |
| 144 | root = createRoot(container); | |
| 145 | await act(async () => { | |
| 146 | root.render( | |
| 147 | <ScriptProgress | |
| 148 | environmentId={EnvironmentId.make("test")} | |
| 149 | threadId={ThreadId.make("cached")} | |
| 150 | />, | |
| 151 | ); | |
| 152 | }); | |
| 153 | ||
| 154 | await vi.waitFor(() => expect(progressbar()?.getAttribute("aria-valuenow")).toBe("4")); | |
| 155 | expect(connections).toBeGreaterThan(initialConnections); | |
| 156 | }); | |
| 157 | ||
| 158 | it("hides the panel when a passive grouping node has no visible tasks left", async () => { | |
| 159 | await emit({ type: "snapshot", streams: [descriptor] }); | |
| 160 | await emit({ | |
| 161 | type: "progress", | |
| 162 | id: descriptor.id, | |
| 163 | event: [-1, { k: 1, t: "Group", p: true, c: [2] }, { k: 2, t: "Compiling", v: 1, e: 4 }, [1]], | |
| 164 | summary: descriptor.summary, | |
| 165 | }); | |
| 166 | await vi.waitFor(() => expect(container.querySelector("section")?.hidden).toBe(false)); | |
| 167 | await emit({ | |
| 168 | type: "progress", | |
| 169 | id: descriptor.id, | |
| 170 | event: [1, 2, { k: 1, c: [] }], | |
| 171 | summary: [], | |
| 172 | }); | |
| 173 | await vi.waitFor(() => expect(container.querySelector("section")?.hidden).toBe(true)); | |
| 174 | }); | |
| 175 | ||
| 176 | it("hides an empty disconnected script while another script is running", async () => { | |
| 177 | const empty = { ...descriptor, id: "empty", status: "disconnected" as const, summary: [] }; | |
| 178 | await emit({ type: "snapshot", streams: [empty, descriptor] }); | |
| 179 | await emit({ type: "progress", id: empty.id, event: [-1, []], summary: [] }); | |
| 180 | await emit({ | |
| 181 | type: "progress", | |
| 182 | id: descriptor.id, | |
| 183 | event: [-1, { k: 1, t: "Rendering", v: 4, e: 10 }, [1]], | |
| 184 | summary: descriptor.summary, | |
| 185 | }); | |
| 186 | ||
| 187 | const section = container.querySelector("section"); | |
| 188 | await vi.waitFor(() => expect(section?.hidden).toBe(false)); | |
| 189 | expect(section?.children[0]?.getAttribute("hidden")).toBe(""); | |
| 190 | expect(section?.children[1]?.hasAttribute("hidden")).toBe(false); | |
| 191 | }); |
apps/web/src/components/chat/ScriptProgress.tsx created+162| ... | ... | @@ -0,0 +1,162 @@ |
| 1 | import * as progress from "@clo/lib/progress"; | |
| 2 | import { attachToDocument } from "@clo/lib/progress/browser"; | |
| 3 | import type { | |
| 4 | EnvironmentId, | |
| 5 | ScriptProgressEvent, | |
| 6 | ScriptProgressStream, | |
| 7 | ThreadId, | |
| 8 | } from "@t3tools/contracts"; | |
| 9 | import { AsyncResult } from "effect/reactivity"; | |
| 10 | import { useEffect, useRef } from "react"; | |
| 11 | ||
| 12 | import { appAtomRegistry } from "../../rpc/atomRegistry"; | |
| 13 | import { scriptProgressEnvironment } from "../../state/scriptProgress"; | |
| 14 | ||
| 15 | export function ScriptProgress({ | |
| 16 | environmentId, | |
| 17 | threadId, | |
| 18 | }: { | |
| 19 | environmentId: EnvironmentId; | |
| 20 | threadId: ThreadId; | |
| 21 | }) { | |
| 22 | const container = useRef<HTMLElement>(null); | |
| 23 | ||
| 24 | useEffect(() => { | |
| 25 | const host = container.current; | |
| 26 | if (!host) return; | |
| 27 | ||
| 28 | const streams = new Map< | |
| 29 | string, | |
| 30 | { | |
| 31 | root: progress.Root; | |
| 32 | input: ReadableStreamDefaultController<progress.StreamEvent>; | |
| 33 | element: HTMLElement; | |
| 34 | status: HTMLElement; | |
| 35 | dispose: () => void; | |
| 36 | } | |
| 37 | >(); | |
| 38 | ||
| 39 | const updateVisibility = () => { | |
| 40 | let visible = false; | |
| 41 | for (const stream of streams.values()) { | |
| 42 | stream.element.hidden = !stream.root.active.some(progress.isVisible); | |
| 43 | visible ||= !stream.element.hidden; | |
| 44 | } | |
| 45 | host.hidden = !visible; | |
| 46 | }; | |
| 47 | ||
| 48 | const remove = (id: string) => { | |
| 49 | const stream = streams.get(id); | |
| 50 | if (!stream) return; | |
| 51 | streams.delete(id); | |
| 52 | stream.dispose(); | |
| 53 | stream.element.remove(); | |
| 54 | updateVisibility(); | |
| 55 | }; | |
| 56 | ||
| 57 | const upsert = (descriptor: ScriptProgressStream) => { | |
| 58 | let stream = streams.get(descriptor.id); | |
| 59 | if (!stream) { | |
| 60 | const root = new progress.Root(); | |
| 61 | const element = document.createElement("div"); | |
| 62 | element.hidden = true; | |
| 63 | const status = document.createElement("div"); | |
| 64 | status.className = "text-xs text-muted-foreground"; | |
| 65 | const tree = document.createElement("div"); | |
| 66 | tree.dataset.progressMotion = "static"; | |
| 67 | element.append(status, tree); | |
| 68 | host.append(element); | |
| 69 | ||
| 70 | const detach = attachToDocument(root, tree); | |
| 71 | const off = root.on("change", updateVisibility); | |
| 72 | let input: ReadableStreamDefaultController<progress.StreamEvent> | undefined; | |
| 73 | const readable = new ReadableStream<progress.StreamEvent>({ | |
| 74 | start(controller) { | |
| 75 | input = controller; | |
| 76 | }, | |
| 77 | }); | |
| 78 | if (!input) throw new Error("Progress stream did not initialize."); | |
| 79 | ||
| 80 | const decoder = progress.decodeEventStream(readable, root); | |
| 81 | void decoder.catch(() => { | |
| 82 | status.textContent = "Progress unavailable"; | |
| 83 | status.hidden = false; | |
| 84 | updateVisibility(); | |
| 85 | }); | |
| 86 | stream = { | |
| 87 | root, | |
| 88 | input, | |
| 89 | element, | |
| 90 | status, | |
| 91 | dispose: () => { | |
| 92 | off(); | |
| 93 | decoder.cancel(); | |
| 94 | detach(); | |
| 95 | }, | |
| 96 | }; | |
| 97 | streams.set(descriptor.id, stream); | |
| 98 | } | |
| 99 | ||
| 100 | stream.status.textContent = | |
| 101 | descriptor.status === "disconnected" ? `${descriptor.label} · Disconnected` : ""; | |
| 102 | stream.status.hidden = descriptor.status === "connected"; | |
| 103 | return stream; | |
| 104 | }; | |
| 105 | ||
| 106 | const subscription = scriptProgressEnvironment.watch({ | |
| 107 | environmentId, | |
| 108 | input: { threadId }, | |
| 109 | }); | |
| 110 | appAtomRegistry.refresh(subscription); | |
| 111 | let previousEvents: ReadonlyArray<ScriptProgressEvent> | undefined; | |
| 112 | const unsubscribe = appAtomRegistry.subscribe( | |
| 113 | subscription, | |
| 114 | (result) => { | |
| 115 | if (!AsyncResult.isSuccess(result)) { | |
| 116 | previousEvents = undefined; | |
| 117 | for (const id of streams.keys()) remove(id); | |
| 118 | return; | |
| 119 | } | |
| 120 | ||
| 121 | const { events } = result.value; | |
| 122 | if (events === previousEvents) return; | |
| 123 | previousEvents = events; | |
| 124 | for (const event of events) { | |
| 125 | switch (event.type) { | |
| 126 | case "snapshot": | |
| 127 | for (const id of streams.keys()) remove(id); | |
| 128 | for (const descriptor of event.streams) upsert(descriptor); | |
| 129 | break; | |
| 130 | case "upsert": | |
| 131 | upsert(event.stream); | |
| 132 | break; | |
| 133 | case "progress": { | |
| 134 | const stream = streams.get(event.id); | |
| 135 | // The shared decoder owns the stable wire format; RPC validates JSON at its boundary. | |
| 136 | stream?.input.enqueue(event.event as progress.StreamEvent); | |
| 137 | break; | |
| 138 | } | |
| 139 | case "remove": | |
| 140 | remove(event.id); | |
| 141 | break; | |
| 142 | } | |
| 143 | } | |
| 144 | }, | |
| 145 | { immediate: true }, | |
| 146 | ); | |
| 147 | ||
| 148 | return () => { | |
| 149 | unsubscribe(); | |
| 150 | for (const id of streams.keys()) remove(id); | |
| 151 | }; | |
| 152 | }, [environmentId, threadId]); | |
| 153 | ||
| 154 | return ( | |
| 155 | <section | |
| 156 | ref={container} | |
| 157 | hidden | |
| 158 | aria-label="Script progress" | |
| 159 | className="mb-2 max-h-56 space-y-2 overflow-auto rounded-xl border border-border bg-card p-3 [--progress-accent:var(--primary)] [--progress-background:var(--card)] [--progress-border:var(--border)] [--progress-foreground:var(--foreground)] [--progress-muted:var(--muted-foreground)] [--progress-secondary:var(--secondary)]" | |
| 160 | /> | |
| 161 | ); | |
| 162 | } |
apps/web/src/state/scriptProgress.ts created+6| ... | ... | @@ -0,0 +1,6 @@ |
| 1 | import { createScriptProgressEnvironmentAtoms } from "@t3tools/client-runtime/state/script-progress"; | |
| 2 | ||
| 3 | import { connectionAtomRuntime } from "../connection/runtime"; | |
| 4 | ||
| 5 | export const scriptProgressEnvironment = | |
| 6 | createScriptProgressEnvironmentAtoms(connectionAtomRuntime); |
apps/web/tsconfig.json+1-1| ... | ... | @@ -7,7 +7,7 @@ |
| 7 | 7 | "erasableSyntaxOnly": false, |
| 8 | 8 | "verbatimModuleSyntax": false, |
| 9 | 9 | "jsx": "react-jsx", |
| 10 | "lib": ["ES2023", "DOM", "DOM.Iterable"], | |
| 10 | "lib": ["ES2025", "DOM", "DOM.Iterable"], | |
| 11 | 11 | "paths": { |
| 12 | 12 | "~/*": ["./src/*"] |
| 13 | 13 | }, |
docs/user/activity-log.md+14| ... | ... | @@ -16,3 +16,17 @@ Failed calls do not count as successful messages or creations. Waiting on a thre |
| 16 | 16 | it finished, and an interrupt or cancellation request does not mean the thread stopped. When tool |
| 17 | 17 | details are unavailable, summaries use a broader description instead of guessing how many threads |
| 18 | 18 | were affected. |
| 19 | ||
| 20 | ## Script progress | |
| 21 | ||
| 22 | Node scripts using `@clo/lib/progress` show their live tasks above the composer | |
| 23 | when launched by Codex, Claude, Pi, Muse, an ACP agent, or the chat's terminal. | |
| 24 | Web and desktop show | |
| 25 | the nested task tree; mobile shows task counts. Command output stays available | |
| 26 | as usual. Progress remains visible when you reconnect to the chat remotely. | |
| 27 | ||
| 28 | Disconnected progress preserves the last reported values without claiming the | |
| 29 | command succeeded. The command's result determines whether it completed. | |
| 30 | ||
| 31 | Capture needs Node's synchronous module hooks and a separately loaded progress | |
| 32 | module. A progress library bundled into a script is not captured automatically. |
packages/client-runtime/package.json+4| ... | ... | @@ -418,6 +418,10 @@ |
| 418 | 418 | "./pull-request-markdown": { |
| 419 | 419 | "types": "./src/pullRequestMarkdown.ts", |
| 420 | 420 | "default": "./src/pullRequestMarkdown.ts" |
| 421 | }, | |
| 422 | "./state/script-progress": { | |
| 423 | "types": "./src/state/scriptProgress.ts", | |
| 424 | "default": "./src/state/scriptProgress.ts" | |
| 421 | 425 | } |
| 422 | 426 | }, |
| 423 | 427 | "scripts": { |
packages/client-runtime/src/rpc/client.ts+1| ... | ... | @@ -48,6 +48,7 @@ export type EnvironmentRpcTag = keyof WsRpcProtocolClient & string; |
| 48 | 48 | type RpcMethod<TTag extends EnvironmentRpcTag> = WsRpcProtocolClient[TTag]; |
| 49 | 49 | |
| 50 | 50 | export type EnvironmentSubscriptionRpcTag = |
| 51 | | typeof WS_METHODS.subscribeScriptProgress | |
| 51 | 52 | | typeof WS_METHODS.codexAuthCallbackSubscribe |
| 52 | 53 | | typeof WS_METHODS.providerAuthSubscribe |
| 53 | 54 | | typeof WS_METHODS.providerInstallSubscribe |
packages/client-runtime/src/state/scriptProgress.test.ts created+101| ... | ... | @@ -0,0 +1,101 @@ |
| 1 | import { describe, expect, vi } from "vite-plus/test"; | |
| 2 | import { it } from "@effect/vitest"; | |
| 3 | import { | |
| 4 | EnvironmentId, | |
| 5 | ThreadId, | |
| 6 | type ScriptProgressEvent, | |
| 7 | type ScriptProgressStream, | |
| 8 | } from "@t3tools/contracts"; | |
| 9 | import * as Effect from "effect/Effect"; | |
| 10 | import * as Layer from "effect/Layer"; | |
| 11 | import * as Stream from "effect/Stream"; | |
| 12 | import { Atom, AtomRegistry } from "effect/reactivity"; | |
| 13 | ||
| 14 | import { EnvironmentRegistry } from "../connection/registry.ts"; | |
| 15 | import { | |
| 16 | applyScriptProgressEvent, | |
| 17 | createScriptProgressEnvironmentAtoms, | |
| 18 | } from "./scriptProgress.ts"; | |
| 19 | ||
| 20 | const source = vi.hoisted(() => ({ stream: undefined as unknown })); | |
| 21 | vi.mock("../rpc/client.ts", async (original) => ({ | |
| 22 | ...(await original<typeof import("../rpc/client.ts")>()), | |
| 23 | subscribe: () => source.stream, | |
| 24 | })); | |
| 25 | ||
| 26 | const build: ScriptProgressStream = { | |
| 27 | id: "build", | |
| 28 | pid: 42, | |
| 29 | label: "build.ts", | |
| 30 | status: "connected", | |
| 31 | summary: [{ text: "Building", value: 4, total: 10, detail: "4/10" }], | |
| 32 | }; | |
| 33 | ||
| 34 | describe("script progress summaries", () => { | |
| 35 | it.effect("preserves every protocol event when RPC delivers a chunk in one atom update", () => | |
| 36 | Effect.scoped( | |
| 37 | Effect.gen(function* () { | |
| 38 | const zero = [{ text: "Building", value: 0, total: 10, detail: "0/10" }]; | |
| 39 | const events: ScriptProgressEvent[] = [ | |
| 40 | { type: "snapshot", streams: [] }, | |
| 41 | { type: "upsert", stream: build }, | |
| 42 | { | |
| 43 | type: "progress", | |
| 44 | id: build.id, | |
| 45 | event: [-1, { k: 1, t: "Building", v: 4, e: 10 }, [1]], | |
| 46 | summary: build.summary, | |
| 47 | }, | |
| 48 | { type: "progress", id: build.id, event: [0, { k: 1, v: 0 }], summary: zero }, | |
| 49 | ]; | |
| 50 | source.stream = Stream.fromArray(events); | |
| 51 | const runtime = Atom.runtime( | |
| 52 | Layer.succeed(EnvironmentRegistry, { | |
| 53 | followStream: (_id, stream) => stream, | |
| 54 | } as EnvironmentRegistry["Service"]), | |
| 55 | ); | |
| 56 | const progress = createScriptProgressEnvironmentAtoms(runtime); | |
| 57 | const registry = AtomRegistry.make(); | |
| 58 | yield* Effect.addFinalizer(() => Effect.sync(() => registry.dispose())); | |
| 59 | const result = yield* AtomRegistry.getResult( | |
| 60 | registry, | |
| 61 | progress.watch({ | |
| 62 | environmentId: EnvironmentId.make("chunk-proof"), | |
| 63 | input: { threadId: ThreadId.make("thread") }, | |
| 64 | }), | |
| 65 | ); | |
| 66 | expect(result.events).toEqual(events); | |
| 67 | expect(result.streams).toEqual([{ ...build, summary: zero }]); | |
| 68 | }), | |
| 69 | ), | |
| 70 | ); | |
| 71 | ||
| 72 | it("replaces a prior connection's streams with the reconnect snapshot", () => { | |
| 73 | expect(applyScriptProgressEvent([build], { type: "snapshot", streams: [] })).toEqual([]); | |
| 74 | }); | |
| 75 | ||
| 76 | it("keeps independent scripts and accepts progress resets to zero", () => { | |
| 77 | const other = { ...build, id: "other", pid: 43 }; | |
| 78 | const summaries = applyScriptProgressEvent([build], { type: "upsert", stream: other }); | |
| 79 | const zero = [{ text: "Building", value: 0, total: 10, detail: "0/10" }]; | |
| 80 | ||
| 81 | expect( | |
| 82 | applyScriptProgressEvent(summaries, { | |
| 83 | type: "progress", | |
| 84 | id: build.id, | |
| 85 | event: [0, { k: 1, v: 0 }], | |
| 86 | summary: zero, | |
| 87 | }), | |
| 88 | ).toEqual([{ ...build, summary: zero }, other]); | |
| 89 | }); | |
| 90 | ||
| 91 | it("preserves the last summary when disconnected without reporting success", () => { | |
| 92 | const disconnected = { ...build, status: "disconnected" as const }; | |
| 93 | const summaries = applyScriptProgressEvent([build], { | |
| 94 | type: "upsert", | |
| 95 | stream: disconnected, | |
| 96 | }); | |
| 97 | ||
| 98 | expect(summaries).toEqual([disconnected]); | |
| 99 | expect(applyScriptProgressEvent(summaries, { type: "remove", id: build.id })).toEqual([]); | |
| 100 | }); | |
| 101 | }); |
packages/client-runtime/src/state/scriptProgress.ts created+54| ... | ... | @@ -0,0 +1,54 @@ |
| 1 | import { | |
| 2 | type ScriptProgressEvent, | |
| 3 | type ScriptProgressStream, | |
| 4 | WS_METHODS, | |
| 5 | } from "@t3tools/contracts"; | |
| 6 | import * as Stream from "effect/Stream"; | |
| 7 | import { Atom } from "effect/reactivity"; | |
| 8 | ||
| 9 | import type { EnvironmentRegistry } from "../connection/registry.ts"; | |
| 10 | import { createEnvironmentRpcSubscriptionAtomFamily } from "./runtime.ts"; | |
| 11 | ||
| 12 | export function applyScriptProgressEvent( | |
| 13 | streams: ReadonlyArray<ScriptProgressStream>, | |
| 14 | event: ScriptProgressEvent, | |
| 15 | ): ReadonlyArray<ScriptProgressStream> { | |
| 16 | switch (event.type) { | |
| 17 | case "snapshot": | |
| 18 | return event.streams; | |
| 19 | case "upsert": { | |
| 20 | const index = streams.findIndex((stream) => stream.id === event.stream.id); | |
| 21 | return index < 0 | |
| 22 | ? [...streams, event.stream] | |
| 23 | : streams.map((stream) => (stream.id === event.stream.id ? event.stream : stream)); | |
| 24 | } | |
| 25 | case "progress": | |
| 26 | return streams.map((stream) => | |
| 27 | stream.id === event.id ? { ...stream, summary: event.summary } : stream, | |
| 28 | ); | |
| 29 | case "remove": | |
| 30 | return streams.filter((stream) => stream.id !== event.id); | |
| 31 | } | |
| 32 | } | |
| 33 | ||
| 34 | export function createScriptProgressEnvironmentAtoms<R, E>( | |
| 35 | runtime: Atom.AtomRuntime<EnvironmentRegistry | R, E>, | |
| 36 | ) { | |
| 37 | return { | |
| 38 | watch: createEnvironmentRpcSubscriptionAtomFamily(runtime, { | |
| 39 | label: "environment-data:script-progress:watch", | |
| 40 | tag: WS_METHODS.subscribeScriptProgress, | |
| 41 | idleTtlMs: 0, | |
| 42 | transform: (stream) => | |
| 43 | Stream.suspend(() => { | |
| 44 | let streams: ReadonlyArray<ScriptProgressStream> = []; | |
| 45 | return stream.pipe( | |
| 46 | Stream.mapArray((events) => { | |
| 47 | for (const event of events) streams = applyScriptProgressEvent(streams, event); | |
| 48 | return [{ events, streams }]; | |
| 49 | }), | |
| 50 | ); | |
| 51 | }), | |
| 52 | }), | |
| 53 | }; | |
| 54 | } |
packages/clo-lib/Events.ts created+107| ... | ... | @@ -0,0 +1,107 @@ |
| 1 | /** | |
| 2 | * type-safe, browser compatible, mini event emitter. | |
| 3 | * this module is intended to be imported via the main class. | |
| 4 | * | |
| 5 | * ```ts | |
| 6 | * import { Events } from '@clo/lib/Events'; | |
| 7 | * const emitter = new Events<{ change: [string] }>(); | |
| 8 | * ``` | |
| 9 | * | |
| 10 | * @module | |
| 11 | */ | |
| 12 | ||
| 13 | /** | |
| 14 | * minimal event emitter supporting typed events. Unlike `EventTarget` and | |
| 15 | * `node:events`, this supports an `onAny`, which is used by `lib/progress.ts` | |
| 16 | * | |
| 17 | * beware that the type parameter cannot be an `interface`. | |
| 18 | */ | |
| 19 | export class Events<Map extends Events.Map> { | |
| 20 | #channels: { [C in keyof Map]?: Array<(...args: Map[C]) => void> } = {}; | |
| 21 | #global: Array<(channel: keyof Map, args: unknown[]) => void> = []; | |
| 22 | ||
| 23 | /** | |
| 24 | * Listen for events on the specified channel | |
| 25 | * ```ts | |
| 26 | * using dispose = events.on("change", () => { ... }); | |
| 27 | * // disposable will remove the listener on scope exit. | |
| 28 | * // alternatively, can use `const` and call `dispose()`. | |
| 29 | * ``` | |
| 30 | */ | |
| 31 | on<C extends keyof Map>(channel: C, cb: Events.Handler<Map[C]>): ts.Dispose { | |
| 32 | const list = (this.#channels[channel] ??= []); | |
| 33 | list.push(cb); | |
| 34 | return ts.defer(() => remove(list, cb)); | |
| 35 | } | |
| 36 | /** | |
| 37 | * listen for one event on the specified channel | |
| 38 | * ```ts | |
| 39 | * // can be awaited | |
| 40 | * const [data] = await events.once("something"); | |
| 41 | * // can be given a callback | |
| 42 | * events.once("something", (data) => { ... }); | |
| 43 | * // can be cancelled | |
| 44 | * const cancelable = events.once("something"); | |
| 45 | * cancelable.then(() => assert(false)); | |
| 46 | * cancelable.cancel(); | |
| 47 | * ``` | |
| 48 | */ | |
| 49 | once<C extends keyof Map>(channel: C, cb?: Events.Handler<Map[C]>): async.Cancelable<Map[C]> { | |
| 50 | let disposeEvent: ts.Dispose | null = null; | |
| 51 | let disposeError: ts.Dispose | null = null; | |
| 52 | function dispose() { | |
| 53 | if (!disposeEvent) return; | |
| 54 | disposeEvent?.(); | |
| 55 | UNWRAP(disposeError)(); | |
| 56 | disposeEvent = disposeError = null; | |
| 57 | } | |
| 58 | return async.makeCancelable( | |
| 59 | new Promise((resolve, reject) => { | |
| 60 | disposeEvent = this.on(channel, (...args) => { | |
| 61 | dispose(); | |
| 62 | resolve(args); | |
| 63 | cb?.(...args); | |
| 64 | }); | |
| 65 | disposeError = this.on(channel, (...args) => { | |
| 66 | dispose(); | |
| 67 | reject(args); | |
| 68 | cb?.(...args); | |
| 69 | }); | |
| 70 | }), | |
| 71 | () => dispose(), | |
| 72 | ); | |
| 73 | } | |
| 74 | /** listen for any event */ | |
| 75 | onAny(cb: (channel: keyof Map, args: unknown[]) => void): ts.Dispose { | |
| 76 | this.#global.push(cb); | |
| 77 | return ts.defer(() => remove(this.#global, cb)); | |
| 78 | } | |
| 79 | /** emit an event on the specified channel */ | |
| 80 | emit<C extends keyof Map>(channel: C, ...args: Map[C]): void { | |
| 81 | const handlers = this.#channels[channel]; | |
| 82 | if (handlers) handlers.forEach((cb) => cb(...args)); | |
| 83 | else if (channel === "error") { | |
| 84 | this.#global?.forEach((cb) => cb(channel, args)); | |
| 85 | throw args.length === 0 | |
| 86 | ? new Error("Error event emitted") | |
| 87 | : args.length === 1 | |
| 88 | ? args[0] | |
| 89 | : new AggregateError(args); | |
| 90 | } | |
| 91 | this.#global?.forEach((cb) => cb(channel, args)); | |
| 92 | } | |
| 93 | } | |
| 94 | ||
| 95 | function remove<T>(list: T[], item: T) { | |
| 96 | const i = list.indexOf(item); | |
| 97 | if (i !== -1) list.splice(i, 1); | |
| 98 | } | |
| 99 | ||
| 100 | export namespace Events { | |
| 101 | export type Map = { [key: string]: unknown[] }; | |
| 102 | export type Handler<Args extends unknown[]> = (...args: Args) => void; | |
| 103 | } | |
| 104 | ||
| 105 | import { UNWRAP } from "./assert.ts"; | |
| 106 | import * as async from "./async.ts"; | |
| 107 | import * as ts from "./ts.ts"; |
packages/clo-lib/assert.ts created+50| ... | ... | @@ -0,0 +1,50 @@ |
| 1 | /** | |
| 2 | * assertions and type narrowing helpers. intended to be imported per symbol. | |
| 3 | * functions are capitalized to make them stand out in a codebase. | |
| 4 | * | |
| 5 | * @module | |
| 6 | */ | |
| 7 | /* node:coverage disable */ | |
| 8 | ||
| 9 | /** assert that `condition` is truthy. */ | |
| 10 | export function ASSERT(condition: unknown, msg?: Message): asserts condition { | |
| 11 | if (!condition) { | |
| 12 | throw new Error(msg ? messageToString(msg) : "Assertion Error"); | |
| 13 | } | |
| 14 | } | |
| 15 | ||
| 16 | /** assert that `nullable` is non-null, returning a non-nullable T. */ | |
| 17 | export function UNWRAP<T>(nullable: T | null | undefined, msg?: Message): T { | |
| 18 | if (nullable == null) { | |
| 19 | throw new Error(`Unexpected ${nullable}${msg ? `: ${messageToString(msg)}` : ""}`); | |
| 20 | } | |
| 21 | return nullable; | |
| 22 | } | |
| 23 | ||
| 24 | /** mark an incomplete feature. */ | |
| 25 | export function TODO(msg?: Message): never { | |
| 26 | throw new Error(`TODO${msg ? `: ${messageToString(msg)}` : ""}`); | |
| 27 | } | |
| 28 | ||
| 29 | /** assert that a `default` case in a switch is never reached. */ | |
| 30 | export function unreachableCase(value: never): never { | |
| 31 | throw new Error(`Unexpected value in switch: ${inspect(value as unknown)}`); | |
| 32 | } | |
| 33 | ||
| 34 | /** message called if function; then string printed as is or util inspectified */ | |
| 35 | type Message = string | (() => string | unknown) | unknown; | |
| 36 | ||
| 37 | function messageToString(msg: Message) { | |
| 38 | if (typeof msg === "function") msg = (msg as () => Message)(); | |
| 39 | return typeof msg === "string" ? msg : inspect(msg); | |
| 40 | } | |
| 41 | ||
| 42 | function inspect(object: unknown) { | |
| 43 | try { | |
| 44 | return node.builtin("util")?.inspect(object) ?? typeof object; | |
| 45 | } catch { | |
| 46 | return typeof object; | |
| 47 | } | |
| 48 | } | |
| 49 | ||
| 50 | import * as node from "./node.ts"; |
packages/clo-lib/async.ts created+297| ... | ... | @@ -0,0 +1,297 @@ |
| 1 | // @effect-diagnostics globalTimers:off - Vendored @clo/lib has no Effect dependency. | |
| 2 | /** | |
| 3 | * helpers to deal with promises and asynchronous execution. | |
| 4 | * | |
| 5 | * @module | |
| 6 | */ | |
| 7 | ||
| 8 | /*** @deprecated */ | |
| 9 | interface ARCEValue<T> { | |
| 10 | value: T; | |
| 11 | [Symbol.dispose]: () => void; | |
| 12 | } | |
| 13 | ||
| 14 | /*** @deprecated */ | |
| 15 | export function RefCountedExpirable<T>( | |
| 16 | init: () => Promise<T>, | |
| 17 | deinit: (value: T) => void, | |
| 18 | expire: number = 5 * 60 * 1000, | |
| 19 | ): () => Promise<ARCEValue<T>> { | |
| 20 | let refs = 0; | |
| 21 | let item: ARCEValue<T> | null = null; | |
| 22 | let loading: Promise<ARCEValue<T>> | null = null; | |
| 23 | let timer: ReturnType<typeof setTimeout> | null = null; | |
| 24 | ||
| 25 | function deref() { | |
| 26 | ASSERT(item !== null); | |
| 27 | if (--refs !== 0) return; | |
| 28 | ASSERT(timer === null); | |
| 29 | timer = setTimeout(() => { | |
| 30 | ASSERT(refs === 0); | |
| 31 | ASSERT(loading === null); | |
| 32 | ASSERT(item !== null); | |
| 33 | deinit(item.value); | |
| 34 | item = null; | |
| 35 | timer = null; | |
| 36 | }, expire); | |
| 37 | } | |
| 38 | ||
| 39 | return async function (): Promise<ARCEValue<T>> { | |
| 40 | if (timer !== null) { | |
| 41 | clearTimeout(timer); | |
| 42 | timer = null; | |
| 43 | } | |
| 44 | if (item !== null) { | |
| 45 | refs++; | |
| 46 | return item; | |
| 47 | } | |
| 48 | if (loading !== null) { | |
| 49 | refs++; | |
| 50 | return loading; | |
| 51 | } | |
| 52 | const p = Promise.withResolvers<ARCEValue<T>>(); | |
| 53 | loading = p.promise; | |
| 54 | try { | |
| 55 | const value = await init(); | |
| 56 | item = { value, [Symbol.dispose]: deref }; | |
| 57 | refs++; | |
| 58 | p.resolve(item); | |
| 59 | return item; | |
| 60 | } catch (e) { | |
| 61 | p.reject(e); | |
| 62 | throw e; | |
| 63 | } finally { | |
| 64 | loading = null; | |
| 65 | } | |
| 66 | }; | |
| 67 | } | |
| 68 | ||
| 69 | /** evaluates `fn` one time, caching the result forever. */ | |
| 70 | export function once<T>(fn: () => Promise<T>): () => Promise<T> { | |
| 71 | let result: T | Promise<T> | null = null; | |
| 72 | return async () => { | |
| 73 | if (result) return result; | |
| 74 | try { | |
| 75 | result = await (result = fn()); | |
| 76 | } catch (err) { | |
| 77 | result = null; | |
| 78 | throw err; | |
| 79 | } | |
| 80 | return result; | |
| 81 | }; | |
| 82 | } | |
| 83 | ||
| 84 | /** a variable that can be watched for changes */ | |
| 85 | export class Watch<T> { | |
| 86 | #value: T; | |
| 87 | #observers = new Set<WatchCallback<T>>(); | |
| 88 | constructor(value: T) { | |
| 89 | this.#value = value; | |
| 90 | } | |
| 91 | ||
| 92 | on(cb: WatchCallback<T>): () => void { | |
| 93 | this.#observers.add(cb); | |
| 94 | return () => void this.#observers.delete(cb); | |
| 95 | } | |
| 96 | once(cb: WatchCallback<T>): void { | |
| 97 | const once = (n: T, p: T) => (cb(n, p), this.#observers.delete(once)); | |
| 98 | this.#observers.add(once); | |
| 99 | } | |
| 100 | ||
| 101 | next(): Promise<T> { | |
| 102 | return new Promise((resolve) => this.once(resolve)); | |
| 103 | } | |
| 104 | ||
| 105 | until(condition: (v: T) => boolean): Promise<T> { | |
| 106 | if (condition(this.#value)) return Promise.resolve(this.#value); | |
| 107 | return new Promise<T>((resolve) => { | |
| 108 | function check(next: T) { | |
| 109 | if (condition(next)) (resolve(next), release()); | |
| 110 | } | |
| 111 | const release = this.on(check); | |
| 112 | }); | |
| 113 | } | |
| 114 | ||
| 115 | get value(): T { | |
| 116 | return this.#value; | |
| 117 | } | |
| 118 | set value(next: T) { | |
| 119 | const prev = this.#value; | |
| 120 | if (prev !== next) { | |
| 121 | this.#value = next; | |
| 122 | this.#observers.forEach((cb) => cb(next, prev)); | |
| 123 | } | |
| 124 | } | |
| 125 | } | |
| 126 | export type WatchCallback<T> = (next: T, prev: T) => void; | |
| 127 | ||
| 128 | /** | |
| 129 | * when two requests with the same args come in at the same time, the response | |
| 130 | * from them is shared. after returning, the data is no longer stored. | |
| 131 | */ | |
| 132 | export class DedupeConcurrent<Args extends unknown[], Ret> { | |
| 133 | pending: Map<unknown, Promise<Ret>> = new Map(); | |
| 134 | fn: (...args: Args) => Promise<Ret>; | |
| 135 | keyFn: (args: Args) => unknown = JSON.stringify; | |
| 136 | ||
| 137 | constructor( | |
| 138 | fn: (...args: Args) => Promise<Ret>, | |
| 139 | keyFn: (args: Args) => unknown = JSON.stringify, | |
| 140 | ) { | |
| 141 | this.fn = fn; | |
| 142 | this.keyFn = keyFn; | |
| 143 | } | |
| 144 | ||
| 145 | isPending(...args: Args): boolean { | |
| 146 | return this.pending.has(this.keyFn(args)); | |
| 147 | } | |
| 148 | ||
| 149 | /** Unbound to allow easily exporting this callback */ | |
| 150 | get = (...args: Args): Promise<Ret> => { | |
| 151 | const k = this.keyFn(args); | |
| 152 | let promise = this.pending.get(k); | |
| 153 | if (promise) return promise; | |
| 154 | promise = this.fn(...args); | |
| 155 | this.pending.set(k, promise); | |
| 156 | void promise.finally(() => void this.pending.delete(k)).catch(() => {}); | |
| 157 | return promise; | |
| 158 | }; | |
| 159 | } | |
| 160 | ||
| 161 | /** | |
| 162 | * when two requests with the same args come, the result | |
| 163 | * of the first is memoized. | |
| 164 | */ | |
| 165 | export class OnceMap<Args extends unknown[], Ret> { | |
| 166 | cache: Map<unknown, Promise<Ret>> = new Map(); | |
| 167 | fn: (...args: Args) => Promise<Ret>; | |
| 168 | keyFn: (args: Args) => unknown = JSON.stringify; | |
| 169 | ||
| 170 | constructor( | |
| 171 | fn: (...args: Args) => Promise<Ret>, | |
| 172 | keyFn: (args: Args) => unknown = JSON.stringify, | |
| 173 | ) { | |
| 174 | this.fn = fn; | |
| 175 | this.keyFn = keyFn; | |
| 176 | } | |
| 177 | ||
| 178 | has(...args: Args): boolean { | |
| 179 | return this.cache.has(this.keyFn(args)); | |
| 180 | } | |
| 181 | ||
| 182 | /** Unbound to allow easily exporting this callback */ | |
| 183 | getOrRun = (...args: Args): Promise<Ret> => { | |
| 184 | const k = this.keyFn(args); | |
| 185 | let promise = this.cache.get(k); | |
| 186 | if (promise) return promise; | |
| 187 | promise = this.fn(...args); | |
| 188 | this.cache.set(k, promise); | |
| 189 | return promise; | |
| 190 | }; | |
| 191 | } | |
| 192 | ||
| 193 | export class PromiseAggregator { | |
| 194 | failures: unknown[] = []; | |
| 195 | promises: Set<Promise<unknown>> = new Set(); | |
| 196 | ||
| 197 | push(p: Promise<unknown>) { | |
| 198 | p = p.then( | |
| 199 | () => { | |
| 200 | this.promises.delete(p); | |
| 201 | }, | |
| 202 | (err) => { | |
| 203 | this.failures.push(err); | |
| 204 | }, | |
| 205 | ); | |
| 206 | this.promises.add(p); | |
| 207 | } | |
| 208 | ||
| 209 | async all() { | |
| 210 | while (this.promises.size > 0) { | |
| 211 | const wait = [...this.promises]; | |
| 212 | this.promises.clear(); | |
| 213 | await Promise.all(wait); | |
| 214 | } | |
| 215 | if (this.failures.length > 0) { | |
| 216 | const agg = new AggregateError(this.failures); | |
| 217 | this.failures = []; | |
| 218 | throw agg; | |
| 219 | } | |
| 220 | } | |
| 221 | ||
| 222 | [Symbol.asyncDispose](): Promise<void> { | |
| 223 | return this.all(); | |
| 224 | } | |
| 225 | } | |
| 226 | ||
| 227 | /** a promise that can be cancelled. */ | |
| 228 | export type Cancelable<T> = Promise<T> & | |
| 229 | Disposable & { | |
| 230 | cancel: (reason?: unknown) => void; | |
| 231 | }; | |
| 232 | ||
| 233 | /** make an existing promise cancelable. */ | |
| 234 | export function makeCancelable<T>( | |
| 235 | promise: Promise<T>, | |
| 236 | cancel: (reason?: unknown) => void, | |
| 237 | ): Cancelable<T> { | |
| 238 | const p = promise as Cancelable<T>; | |
| 239 | p[Symbol.dispose] = cancel; | |
| 240 | p.cancel = cancel; | |
| 241 | return p; | |
| 242 | } | |
| 243 | ||
| 244 | /** wait `ms` milliseconds, then resolve. can be cancelled. */ | |
| 245 | export function delay(ms: number): Cancelable<void> { | |
| 246 | if (ms === Infinity) return makeCancelable(new Promise(() => {}), () => {}); | |
| 247 | let t: ts.Timer | null = null; | |
| 248 | const maxTimer = 0x7fffffff; | |
| 249 | return makeCancelable( | |
| 250 | new Promise(function tick(resolve) { | |
| 251 | if (ms <= maxTimer) { | |
| 252 | t = setTimeout(() => ((t = null), resolve()), ms); | |
| 253 | } else { | |
| 254 | t = setTimeout(() => { | |
| 255 | ms -= maxTimer; | |
| 256 | tick(resolve); | |
| 257 | }, maxTimer); | |
| 258 | } | |
| 259 | }), | |
| 260 | () => { | |
| 261 | if (t != null) clearTimeout(t); | |
| 262 | }, | |
| 263 | ); | |
| 264 | } | |
| 265 | ||
| 266 | export interface Deferred<T> extends AsyncDisposable, PromiseLike<T> {} | |
| 267 | ||
| 268 | /** | |
| 269 | * Return something that can be awaited later without worry of an unhandled | |
| 270 | * rejection. By disposing it, the promise exception will always be reported. | |
| 271 | * | |
| 272 | * ```ts | |
| 273 | * // incorrect, possible unhandled rejection between a and b. | |
| 274 | * const a = promise(); | |
| 275 | * const b = await promise(); | |
| 276 | * const c = await promise(b, await a); | |
| 277 | * // correct | |
| 278 | * await using a = async.deferred(promise()); | |
| 279 | * const b = await promise(); | |
| 280 | * const c = await promise(b, await a); | |
| 281 | * ``` | |
| 282 | */ | |
| 283 | export function deferred<T>(promise: Promise<T>): Deferred<T> { | |
| 284 | let handled = false; | |
| 285 | promise.catch(() => {}); // potential error is re-thrown on asyncDispose | |
| 286 | return { | |
| 287 | async [Symbol.asyncDispose]() { | |
| 288 | if (!handled) await promise; | |
| 289 | }, | |
| 290 | then(onFufilled, onRejected) { | |
| 291 | return promise.finally(() => (handled = true)).then(onFufilled, onRejected); | |
| 292 | }, | |
| 293 | }; | |
| 294 | } | |
| 295 | ||
| 296 | import { ASSERT } from "./assert.ts"; | |
| 297 | import * as ts from "./ts.ts"; |
packages/clo-lib/bytes.ts created+12| ... | ... | @@ -0,0 +1,12 @@ |
| 1 | /** | |
| 2 | * helpers to deal with `Uint8Array` and other `ArrayBufferView`s | |
| 3 | * | |
| 4 | * @module | |
| 5 | */ | |
| 6 | ||
| 7 | export function eql<T extends ArrayBufferView & { [n: number]: number }>(a: T, b: T): boolean { | |
| 8 | const l = a.byteLength; | |
| 9 | if (l !== b.byteLength) return false; | |
| 10 | for (let i = 0; i < l; i++) if (a[i] !== b[i]) return false; | |
| 11 | return true; | |
| 12 | } |
packages/clo-lib/exception.ts created+51| ... | ... | @@ -0,0 +1,51 @@ |
| 1 | /** | |
| 2 | * helper functions for dealing with the `unknown` type, mainly in `catch` | |
| 3 | * blocks or promise rejection callbacks. | |
| 4 | * | |
| 5 | * @module | |
| 6 | */ | |
| 7 | ||
| 8 | /** extra fields on errors are extremely helpful for debugging. */ | |
| 9 | export interface Error extends globalThis.Error { | |
| 10 | [metadata: PropertyKey]: unknown; | |
| 11 | } | |
| 12 | ||
| 13 | /** retrieve an error message from any value */ | |
| 14 | export function message(error: unknown): string { | |
| 15 | let message = (error as { message: unknown })?.message ?? error; | |
| 16 | try { | |
| 17 | message = | |
| 18 | typeof message === "string" | |
| 19 | ? message | |
| 20 | : // NOTE: typescript standard library lies here (https://github.com/mattpocock/ts-reset/pull/190) | |
| 21 | String(JSON.stringify(message) as string | undefined); | |
| 22 | } catch {} | |
| 23 | try { | |
| 24 | return String(message); | |
| 25 | } catch {} | |
| 26 | return `Could not stringify error message ${typeof message}`; | |
| 27 | } | |
| 28 | ||
| 29 | /** retrieve an error-like object from any value. Useful to check fields. */ | |
| 30 | export function obj(error: unknown): { message: string } & Partial<Error> { | |
| 31 | if (error instanceof Error) return error as Error; | |
| 32 | return { | |
| 33 | message: message(error), | |
| 34 | ...(typeof error === "object" && error), | |
| 35 | }; | |
| 36 | } | |
| 37 | ||
| 38 | /** node.js error codes are string values on the `.code` property. */ | |
| 39 | export function code(error: unknown): node.ErrorCode | null { | |
| 40 | const code = (error as { code: unknown })?.code; | |
| 41 | return typeof code === "string" ? code : null; | |
| 42 | } | |
| 43 | ||
| 44 | /** attach extra fields and rethrow a value. */ | |
| 45 | export function rethrowWithMetadata(err: unknown, meta: Record<string, unknown>): never { | |
| 46 | const error = err && typeof err === "object" ? err : new Error(message(err)); // no stack trace :/ | |
| 47 | Object.assign(error, meta); | |
| 48 | throw error; | |
| 49 | } | |
| 50 | ||
| 51 | import type * as node from "./node.ts"; |
packages/clo-lib/license.txt created+13| ... | ... | @@ -0,0 +1,13 @@ |
| 1 | Copyright 2025-2026 clover caruso | |
| 2 | ||
| 3 | Permission to use, copy, modify, and/or distribute this software for any | |
| 4 | purpose with or without fee is hereby granted, provided that the above | |
| 5 | copyright notice and this permission notice appear in all copies. | |
| 6 | ||
| 7 | THE SOFTWARE IS PROVIDED “AS IS” AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH | |
| 8 | REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND | |
| 9 | FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, | |
| 10 | INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM | |
| 11 | LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR | |
| 12 | OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR | |
| 13 | PERFORMANCE OF THIS SOFTWARE. |
packages/clo-lib/log.ts created+2070| ... | ... | @@ -0,0 +1,2070 @@ |
| 1 | // @effect-diagnostics globalDate:off globalConsole:off - Vendored @clo/lib has no Effect dependency. | |
| 2 | /** | |
| 3 | * by using `@clo/lib/log`, an application gets easy scoped logging as well | |
| 4 | * as integration with terminal widgets such as `progress.ts`. even when these | |
| 5 | * widgets are active, using the logging interface is optional; global I/O with | |
| 6 | * `console.*` and `process.std{out/err}` are automatically patched to play nice | |
| 7 | * so nearly any attempts at writing to the terminal should display fine. | |
| 8 | * additionally, the clover library writes to a global symbol to communicate with | |
| 9 | * other copies of this library (even across versions) to coordinate drawing. | |
| 10 | * | |
| 11 | * the pattern for using this module is to shadow the global `console` with a | |
| 12 | * per-file logging scope, which makes it impossible to use the wrong logger. | |
| 13 | * | |
| 14 | * ```ts | |
| 15 | * import * as log from "@clo/lib/log"; | |
| 16 | * const console = log.scoped("http"); | |
| 17 | * console.info("Hello world!"); // info message | |
| 18 | * console.log("hi!"); // debug message only | |
| 19 | * ``` | |
| 20 | * | |
| 21 | * or to use the global scope, import the module's namespace as `console`. | |
| 22 | * | |
| 23 | * ```ts | |
| 24 | * import * as console from "@clo/lib/log"; | |
| 25 | * ``` | |
| 26 | * | |
| 27 | * now code reads familiarly (`console.log` is universally understood), | |
| 28 | * but the output is organized into relevant scopes. | |
| 29 | * | |
| 30 | * in addition to static log messages, a system for interactive I/O via the | |
| 31 | * {@linkcode Widget} interface can be started with {@linkcode startWidget}. | |
| 32 | * these allow showing temporary or interactive information, such as program | |
| 33 | * status or input prompts. a powerful example of this system in action is | |
| 34 | * `lib/progress.ts`, which uses a widget as it's default rendering backend. | |
| 35 | * alongside this, a very complicated locking system is provided to ensure that | |
| 36 | * widgets to not interfere with globals like `process.stdout.write`. a widget | |
| 37 | * that defines `input` receives the keyboard, which `lib/repl.ts` builds a | |
| 38 | * line editor on. | |
| 39 | * | |
| 40 | * `lib/log.ts` offers two environment integrations: | |
| 41 | * - in node.js, log messages are colored depending on the level and show | |
| 42 | * widgets directly under the long using ANSI cursor controls. when the | |
| 43 | * terminal is not a TTY, widgets are silent. | |
| 44 | * - in browsers and other, logs are surfaced using the global `console` API | |
| 45 | * and widgets are disabled. | |
| 46 | * | |
| 47 | * custom log integrations can be built on top of this module by calling | |
| 48 | * `log.tee()` to duplicate all messages elsewhere. for example, a project may | |
| 49 | * configure logs to upload to a telemetry service. these logs can carry custom | |
| 50 | * metadata as well as source code traces -- amazing tools for debugging. | |
| 51 | * | |
| 52 | * if bundling for the browser, see the details in `./node.ts` on how to | |
| 53 | * trigger tree-shaking to eliminate the node.js bindings in your web bundle. | |
| 54 | * | |
| 55 | * @module | |
| 56 | */ | |
| 57 | ||
| 58 | /** | |
| 59 | * logging scopes implement part of the `Console` API. the `log` module itself | |
| 60 | * satisfies this interface. | |
| 61 | */ | |
| 62 | export interface Scope { | |
| 63 | /** emit an informational message */ | |
| 64 | info(...args: unknown[]): void; | |
| 65 | /** emit an advisory message */ | |
| 66 | warn(...args: unknown[]): void; | |
| 67 | /** emit a failure message */ | |
| 68 | error(...args: unknown[]): void; | |
| 69 | ||
| 70 | /** | |
| 71 | * emit a debugging message. | |
| 72 | */ | |
| 73 | log(...args: unknown[]): void; | |
| 74 | /** | |
| 75 | * emit a debugging message. this method is in place for compatibility with | |
| 76 | * the `console` API. prefer calling `.log` directly. | |
| 77 | * @internal | |
| 78 | */ | |
| 79 | debug(...args: unknown[]): void; | |
| 80 | ||
| 81 | /** write a Message object directly. */ | |
| 82 | writeMessage(message: Message): void; | |
| 83 | ||
| 84 | /** create a nested sub-scope */ | |
| 85 | scoped(name: string, custom?: Message["custom"]): Scope; | |
| 86 | /** redirect the logging output of this scope somewhere else */ | |
| 87 | tee(writer: (message: Message) => void): ts.Dispose; | |
| 88 | } | |
| 89 | ||
| 90 | export interface RootScope extends Scope { | |
| 91 | /** write a partial line to the output. */ | |
| 92 | write(text: string): void; | |
| 93 | } | |
| 94 | ||
| 95 | export const originalLogArgs = Symbol("originalLogArgs"); | |
| 96 | ||
| 97 | /** | |
| 98 | * a message written in a log scope. | |
| 99 | * this API will never be altered in a breaking way. | |
| 100 | */ | |
| 101 | export interface Message { | |
| 102 | /** ANSI-styled text */ | |
| 103 | text: string; | |
| 104 | /** datetime in milliseconds since UNIX epoch */ | |
| 105 | time: number; | |
| 106 | /** | |
| 107 | * type of message, if known. | |
| 108 | * @default info | |
| 109 | */ | |
| 110 | level?: MessageLevel | undefined; | |
| 111 | /** scope name */ | |
| 112 | scope?: string | null | undefined; | |
| 113 | /** captured stack, if available */ | |
| 114 | stack?: stack.Frame[] | undefined; | |
| 115 | /** arbitrary data from the logging source. */ | |
| 116 | custom?: Partial<Record<string, ts.Json>> | undefined; | |
| 117 | /** | |
| 118 | * if there is a newline at the end of this log line. | |
| 119 | * @default true | |
| 120 | */ | |
| 121 | newline?: boolean | undefined; | |
| 122 | /** | |
| 123 | * original logging arguments, if present. this field is indexed by a symbol | |
| 124 | * so that it is lost during JSON serialization, as callers are allowed to log | |
| 125 | * non-serializable data. | |
| 126 | */ | |
| 127 | [originalLogArgs]?: unknown[]; | |
| 128 | } | |
| 129 | /** this API will never be altered in a breaking way. */ | |
| 130 | type MessageLevel = "error" | "warn" | "info" | "debug"; | |
| 131 | ||
| 132 | // these functions implement `Scope` for the module's namespace. that means if | |
| 133 | // a function takes in `Scope`, this file's namespace satisfies that. | |
| 134 | ||
| 135 | /** emit an informational message on the global log scope */ | |
| 136 | export function info(...args: unknown[]) { | |
| 137 | globalLog.info(...args); | |
| 138 | } | |
| 139 | /** emit an advisory message on the global log scope */ | |
| 140 | export function warn(...args: unknown[]) { | |
| 141 | globalLog.warn(...args); | |
| 142 | } | |
| 143 | /** emit an failure message on the global log scope */ | |
| 144 | export function error(...args: unknown[]) { | |
| 145 | globalLog.error(...args); | |
| 146 | } | |
| 147 | /** emit an debug message on the global log scope */ | |
| 148 | export function log(...args: unknown[]) { | |
| 149 | globalLog.log(...args); | |
| 150 | } | |
| 151 | /** | |
| 152 | * emit an debug message on the global log scope. this method is in place for | |
| 153 | * compatibility with the `console` API. prefer calling `.log` directly. | |
| 154 | * @internal | |
| 155 | */ | |
| 156 | export function debug(...args: unknown[]) { | |
| 157 | globalLog.debug(...args); | |
| 158 | } | |
| 159 | /** create a named logging scope, optionally carrying custom message fields */ | |
| 160 | export function scoped(name: string, custom?: Message["custom"]): Scope { | |
| 161 | return globalLog.scoped(name, custom); | |
| 162 | } | |
| 163 | /** redirect all log messages to another writer */ | |
| 164 | export function tee(destination: DispatchFunction): ts.Dispose { | |
| 165 | global.tees.add(destination); | |
| 166 | return ts.defer(() => global.tees.delete(destination)); | |
| 167 | } | |
| 168 | ||
| 169 | /** replace the default message writer */ | |
| 170 | export function replaceGlobalMessageDestination(destination: DispatchFunction) { | |
| 171 | global.writeMessage = destination; | |
| 172 | } | |
| 173 | ||
| 174 | /** | |
| 175 | * replace the default interactive widget host. this applies for all separately | |
| 176 | * installed copies of the library, across versions. once the widget host has | |
| 177 | * been activated, it must be preserved forever. | |
| 178 | */ | |
| 179 | export function replaceGlobalWidgetHost(host: WidgetHost) { | |
| 180 | if (global.widget?.frozen) { | |
| 181 | throw new Error( | |
| 182 | `Cannot change widget host implementation, it is locked by another implementation: ${global.widget.source}`, | |
| 183 | ); | |
| 184 | } | |
| 185 | global.widget = { | |
| 186 | frozen: false, | |
| 187 | source: | |
| 188 | stack.capture()[0]?.file ?? | |
| 189 | "untracable call to replaceGlobalWidgetHost in " + import.meta.url, | |
| 190 | host, | |
| 191 | version: 0, | |
| 192 | }; | |
| 193 | } | |
| 194 | ||
| 195 | /** | |
| 196 | * replace the default message formatter. does not affect browsers because that | |
| 197 | * code path does not use `formatAnsiMessage` | |
| 198 | */ | |
| 199 | export function replaceGlobalFormatAnsiMessage(format: (msg: Message, colors: boolean) => string) { | |
| 200 | global.formatAnsiMessage = format; | |
| 201 | } | |
| 202 | ||
| 203 | /** includes the trailing newline for standard log messages */ | |
| 204 | export function formatAnsiMessage(msg: Message, colors: boolean): string { | |
| 205 | return (global.formatAnsiMessage ?? defaultFormatAnsiMessage)(msg, colors); | |
| 206 | } | |
| 207 | ||
| 208 | /** | |
| 209 | * a widget is an interactive display that persists at the end | |
| 210 | * of the log. this can be used to implement status bars, progress | |
| 211 | * indicators, and other human I/O. only 'format' is required. | |
| 212 | * | |
| 213 | * returns `null` if the terminal is not interactive or the global renderer is | |
| 214 | * incapable of displaying this log (such as in a browser) | |
| 215 | * | |
| 216 | * ```ts | |
| 217 | * using _ = log.startWidget({ | |
| 218 | * format: (now) => `It is ${new Date().toString()} right now\n` | |
| 219 | * + `A random number: ${Math.random()}`, | |
| 220 | * // no trailing '\n' is needed. | |
| 221 | * }); | |
| 222 | * await async.delay(10000); | |
| 223 | * | |
| 224 | * import * as log from "@clo/lib/log"; | |
| 225 | * import * as async from "@clo/lib/async"; | |
| 226 | * ``` | |
| 227 | */ | |
| 228 | export function startWidget<T extends WidgetOptions>(widget: T): WidgetInstance<T> | null { | |
| 229 | return globalWidgetHost().startWidget(widget); | |
| 230 | } | |
| 231 | ||
| 232 | /** | |
| 233 | * no built-in prefix, formatting, or newlline. ensures the text does not interweave. | |
| 234 | * data will be flushed in the next frame or when drawing is {@link getDrawLock|unlocked}. | |
| 235 | */ | |
| 236 | export function writeOutput(text: string) { | |
| 237 | globalWidgetHost().writeOutput(text); | |
| 238 | } | |
| 239 | ||
| 240 | /** | |
| 241 | * like {@linkcode writeOutput}, but the text is written to the error stream | |
| 242 | * (stderr in node.js). relative ordering between output and error text is | |
| 243 | * preserved through the shared flush buffer. | |
| 244 | */ | |
| 245 | export function writeError(text: string) { | |
| 246 | globalWidgetHost().writeError(text); | |
| 247 | } | |
| 248 | ||
| 249 | /** write a {@linkcode Message} object directly. */ | |
| 250 | export function writeMessage(m: Message) { | |
| 251 | globalLog.writeMessage(m); | |
| 252 | } | |
| 253 | ||
| 254 | /** | |
| 255 | * hold a lock that stops `@clo/lib` from drawing to the terminal. this is an | |
| 256 | * advanced api that is likely unneeded unless messing with external APIs that | |
| 257 | * freely write to the terminal. | |
| 258 | * | |
| 259 | * NOTE: if the lock will be held for an extremely short amount of time, pass | |
| 260 | * `"short"` which will allow more optimized use of ansi synchronization codes. | |
| 261 | */ | |
| 262 | export function getDrawLock(mode: "long" | "short"): DrawLock { | |
| 263 | return globalWidgetHost().getDrawLock(mode); | |
| 264 | } | |
| 265 | ||
| 266 | /** | |
| 267 | * call this if you are doing silly things with `process.stderr` and you want to | |
| 268 | * make sure that starting a log widget (such as `@clo/lib/progress`) doesn't | |
| 269 | * have interweaving bugs. in particular, you need this if your program directly | |
| 270 | * writes incomplete lines to the terminal (missing a trailing `\n`). | |
| 271 | * | |
| 272 | * the globals are patched to track if widget text must move to a fresh line | |
| 273 | * before drawing, as well as knowing to return to the original position | |
| 274 | * afterwards. | |
| 275 | * | |
| 276 | * you most likely do not need to call this API. | |
| 277 | */ | |
| 278 | export function ensureGlobalsArePatched(): ts.Dispose { | |
| 279 | if (global.widget?.frozen && global.widget?.version !== version) { | |
| 280 | throw new Error( | |
| 281 | `Cannot change widget host implementation, it is locked by another implementation: ${global.widget.source}`, | |
| 282 | ); | |
| 283 | } | |
| 284 | const w = globalWidgetHost().startWidget?.({ format: () => "" }); | |
| 285 | return ts.defer(() => w?.stop()); | |
| 286 | } | |
| 287 | ||
| 288 | /** | |
| 289 | * a non-exclusive lock to drawing | |
| 290 | * this API will never be altered in a breaking way. | |
| 291 | */ | |
| 292 | export interface DrawLock { | |
| 293 | /** | |
| 294 | * decides if widget drawing requires an extra newline, which is needed if | |
| 295 | * there is extra text on the line that the lock is being released on. | |
| 296 | */ | |
| 297 | release(endState: "cursor-start-of-line" | "cursor-middle-of-line"): void; | |
| 298 | /** Assumes worst case `cursor-middle-of-line` */ | |
| 299 | [Symbol.dispose](): void; | |
| 300 | } | |
| 301 | ||
| 302 | export function headlessScope(dispatch: DispatchFunction): RootScope { | |
| 303 | return new ScopeImpl(dispatch); | |
| 304 | } | |
| 305 | ||
| 306 | /** | |
| 307 | * see {@linkcode startWidget} | |
| 308 | * this API will never be altered in a breaking way. | |
| 309 | */ | |
| 310 | export interface WidgetOptions { | |
| 311 | /** | |
| 312 | * return the widget's text. return null to detach the widget. | |
| 313 | * may get called more often than the specified `fps`. | |
| 314 | * supports color codes but not ansi cursor movements. | |
| 315 | */ | |
| 316 | format(ctx: WidgetFormatContext): string | WidgetFrame | null; | |
| 317 | /** 'null' to never update automatically */ | |
| 318 | fps?: number | null; | |
| 319 | /** | |
| 320 | * receive keyboard input. the terminal is only read while some widget | |
| 321 | * defines this, and events go to the most recently started one. reading | |
| 322 | * puts the terminal in raw mode, so `ctrl+c` arrives here as an event | |
| 323 | * instead of interrupting the process. | |
| 324 | */ | |
| 325 | input?(event: WidgetInput): void; | |
| 326 | } | |
| 327 | ||
| 328 | /** | |
| 329 | * one key press or paste, as given to {@linkcode WidgetOptions.input}. | |
| 330 | * this API will never be altered in a breaking way. | |
| 331 | */ | |
| 332 | export interface WidgetInput { | |
| 333 | /** | |
| 334 | * the key, prefixed by its modifiers in the order | |
| 335 | * `ctrl+alt+shift+super+hyper+meta+`. printable keys are named by the | |
| 336 | * grapheme they type (`"a"`, `"A"`, `"é"`), except `"space"`. other keys | |
| 337 | * are named in lowercase: `"enter"`, `"tab"`, `"backspace"`, `"delete"`, | |
| 338 | * `"escape"`, `"up"`, `"down"`, `"left"`, `"right"`, `"home"`, `"end"`, | |
| 339 | * `"pageup"`, `"pagedown"`, `"insert"`, `"f1"` and up. a letter combined | |
| 340 | * with `ctrl`, `alt`, or `super` is lowercase with an explicit `shift+`. | |
| 341 | * a bracketed paste is the key `"paste"`. | |
| 342 | */ | |
| 343 | key: string; | |
| 344 | /** what this input types, with `"\n"` for line breaks. `""` if nothing. */ | |
| 345 | text: string; | |
| 346 | } | |
| 347 | ||
| 348 | /** | |
| 349 | * the object form of a widget's text. | |
| 350 | * this API will never be altered in a breaking way. | |
| 351 | */ | |
| 352 | export interface WidgetFrame { | |
| 353 | text: string; | |
| 354 | /** | |
| 355 | * where the terminal cursor rests: `line` indexes the lines of `text`, and | |
| 356 | * `column` counts cells. honored for the widget receiving input. | |
| 357 | */ | |
| 358 | cursor?: { line: number; column: number }; | |
| 359 | } | |
| 360 | ||
| 361 | /** | |
| 362 | * control for a widget (see {@linkcode startWidget}) | |
| 363 | * this API will never be altered in a breaking way. | |
| 364 | */ | |
| 365 | export interface WidgetInstance<T extends WidgetOptions = WidgetOptions> { | |
| 366 | /** the provided widget options */ | |
| 367 | options: T; | |
| 368 | get fps(): number | null; | |
| 369 | set fps(fps: number | null); | |
| 370 | /** schedules a new frame to be drawn as soon as possible */ | |
| 371 | redraw(): void; | |
| 372 | /** remove the widget from the screen */ | |
| 373 | stop(): void; | |
| 374 | /** alias of `stop` */ | |
| 375 | [Symbol.dispose](): void; | |
| 376 | } | |
| 377 | ||
| 378 | /** | |
| 379 | * this API will never be altered in a breaking way. | |
| 380 | */ | |
| 381 | export interface WidgetFormatContext { | |
| 382 | /** the current time according to the widget host */ | |
| 383 | now: ReturnType<typeof performance.now>; | |
| 384 | /** advisory */ | |
| 385 | width: number; | |
| 386 | /** advisory */ | |
| 387 | height: number; | |
| 388 | host?: WidgetHost; | |
| 389 | /** whether this widget is the one receiving keyboard input */ | |
| 390 | focused?: boolean; | |
| 391 | } | |
| 392 | ||
| 393 | /** {@linkcode widgetHost}'s input takes terminal I/O as well as timing APIs */ | |
| 394 | export interface TerminalWidgetHostOptions { | |
| 395 | /** | |
| 396 | * an exclusive lock on the terminal is held whenever widgets are active. a | |
| 397 | * secondary purpose of this is to instrument/deinstrument other code to | |
| 398 | * integrate with `log.ts`'s widget lock. for example, the node.js adapter | |
| 399 | * will patch `process.std{out,err}` to ensure write calls properly get a | |
| 400 | * draw lock. | |
| 401 | * | |
| 402 | * this lock can be cleared by deactiving all widgets, or by calling | |
| 403 | * {@linkcode getDrawLock} to force `@clo/lib` to stop drawing. | |
| 404 | * | |
| 405 | * currently, this lock must be able to be synchronously aquired at any | |
| 406 | * point. if you desire an async locking function, please contact me so we | |
| 407 | * can design how it would work. | |
| 408 | */ | |
| 409 | lockTerminal: () => TerminalLock; | |
| 410 | /** fast path for writing output without widgets */ | |
| 411 | writeOutputTemporaryLock?: (buffer: string) => void; | |
| 412 | /** monotonic milliseconds */ | |
| 413 | now: () => ReturnType<typeof performance.now>; | |
| 414 | /** after resolving, `now()` should have increased by the delay time */ | |
| 415 | delay: typeof async.delay; | |
| 416 | /** is there color support? */ | |
| 417 | color: boolean; | |
| 418 | /** | |
| 419 | * whether text passed to `writeOutput` lands on the same screen as the | |
| 420 | * interactive output. pass `false` when output is redirected to a file or | |
| 421 | * pipe while the interactive stream remains a terminal; the widget cursor | |
| 422 | * math then ignores log output entirely, since written rows do not | |
| 423 | * displace the widget block. | |
| 424 | * @default true | |
| 425 | */ | |
| 426 | outputSharesScreen?: boolean; | |
| 427 | /** | |
| 428 | * read the keyboard until the returned function is called, passing along | |
| 429 | * what the terminal sends as decoded text. only called while the terminal | |
| 430 | * is locked. the terminal must not echo or line-buffer in the meantime | |
| 431 | * (raw mode). leave undefined if there is no keyboard to read. | |
| 432 | */ | |
| 433 | observeInput?: ((callback: (data: string) => void) => () => void) | undefined; | |
| 434 | } | |
| 435 | ||
| 436 | /** | |
| 437 | * when `@clo/lib` requests a lock on the terminal, the adapter provides this | |
| 438 | * interface to communicate everything about the terminal state correctly. | |
| 439 | */ | |
| 440 | export interface TerminalLock { | |
| 441 | /** | |
| 442 | * recieves ANSI escape sequences for interactive data, as well as error log | |
| 443 | * content from `writeError` (should flush immediately). the interactive | |
| 444 | * stream and the error stream are the same: stderr. | |
| 445 | */ | |
| 446 | writeInteractive(text: string): void; | |
| 447 | /** recieves log content from `write` (pre-buffered; should flush immediately) */ | |
| 448 | writeOutput(text: string): void; | |
| 449 | /** | |
| 450 | * subscribe to the terminal size. the callback must fire synchronously | |
| 451 | * with the current size before this function returns, and again whenever | |
| 452 | * the size changes (SIGWINCH). returns an unsubscribe function. on a size | |
| 453 | * change, the widget host erases its stale drawing and repaints, since a | |
| 454 | * resize rewraps previously drawn rows and invalidates all relative | |
| 455 | * cursor math. | |
| 456 | */ | |
| 457 | observeSize(callback: (size: { columns: number; rows: number }) => void): () => void; | |
| 458 | /** temporarily free the lock */ | |
| 459 | temporaryUnlock?(): () => void; | |
| 460 | /** completely free the lock */ | |
| 461 | close(): void; | |
| 462 | } | |
| 463 | ||
| 464 | /** | |
| 465 | * an implementation of an ANSI-based widget host. | |
| 466 | * this structure must not be altered in a breaking way. | |
| 467 | */ | |
| 468 | export interface WidgetHost { | |
| 469 | /** see the top-level {@linkcode writeOutput} function */ | |
| 470 | writeOutput(text: string): void; | |
| 471 | /** see the top-level {@linkcode writeError} function */ | |
| 472 | writeError(text: string): void; | |
| 473 | /** see the top-level {@linkcode getDrawLock} function */ | |
| 474 | getDrawLock(mode: "long" | "short"): DrawLock; | |
| 475 | /** see the top-level {@linkcode startWidget} function */ | |
| 476 | startWidget<T extends WidgetOptions>(widget: T): WidgetInstance<T> | null; | |
| 477 | /** stop all widgets and remove all timers. */ | |
| 478 | cancel(): void; | |
| 479 | /** generic delay function */ | |
| 480 | delay?: typeof async.delay; | |
| 481 | /** generic now function */ | |
| 482 | now?: typeof performance.now; | |
| 483 | /** | |
| 484 | * what does this widget host support? | |
| 485 | * - color: ANSI escape sequences are displayed and not stripped | |
| 486 | * - widget: `startWidget` can meaninglyful display the widget | |
| 487 | */ | |
| 488 | capabilities: ReadonlyArray<"widget" | "color">; | |
| 489 | } | |
| 490 | ||
| 491 | /** @internal state */ | |
| 492 | interface WidgetState { | |
| 493 | frameTime: number; | |
| 494 | } | |
| 495 | ||
| 496 | // thresholds for flushing the log buffer outside the redraw timer. the 0ms | |
| 497 | // timer batches a synchronous burst of writes into one flush, but waiting on | |
| 498 | // it is wrong in two situations: the buffer growing toward the engine string | |
| 499 | // length limit, and a timer starved by cpu-bound work blocking the event | |
| 500 | // loop (it may never fire). | |
| 501 | const flushSyncBytes = 65536; | |
| 502 | const flushSyncMs = 50; | |
| 503 | ||
| 504 | /** how long a lone escape byte may wait to become the start of a sequence */ | |
| 505 | const escapeMs = 50; | |
| 506 | ||
| 507 | /** | |
| 508 | * terminal widget rendering is done by specifying all system APIs up front in | |
| 509 | * an interface, creating an instance of the "widget host". | |
| 510 | */ | |
| 511 | export function createTerminalWidgetHost(env: TerminalWidgetHostOptions): WidgetHost { | |
| 512 | const { lockTerminal, now, delay, writeOutputTemporaryLock, color, observeInput } = env; | |
| 513 | const outputOnScreen = env.outputSharesScreen ?? true; | |
| 514 | ||
| 515 | let timer: async.Cancelable<void> | null = null; | |
| 516 | ||
| 517 | let rendering = false; | |
| 518 | let pendingRedraw = false; | |
| 519 | let locks = 0; | |
| 520 | let redrawTime = 0; | |
| 521 | let lastFlush = 0; | |
| 522 | // log text awaiting a flush. error text shares the queue so that relative | |
| 523 | // write order is preserved, but flushes to the interactive stream (stderr) | |
| 524 | // and always lands on the widget screen, while plain output rows only | |
| 525 | // count when `outputSharesScreen`. consecutive same-stream writes merge | |
| 526 | // into one chunk. | |
| 527 | let buffer: { text: string; err: boolean }[] = []; | |
| 528 | // visible width of the trailing partial log line (text since the last "\n" | |
| 529 | // written to output). the cursor column is derived as `partialWidth % | |
| 530 | // columns` at draw time, so the value survives resizes and wrapped lines. | |
| 531 | let partialWidth = 0; | |
| 532 | let needsToSaveCursor = false; | |
| 533 | let needsToRestoreCursor = false; | |
| 534 | const widgets: WidgetOptions[] = []; | |
| 535 | const internals: WidgetState[] = []; | |
| 536 | let lines: string[] = []; | |
| 537 | let hasSyncStart = false; | |
| 538 | let terminal: TerminalLock | null = null; | |
| 539 | let size: { columns: number; rows: number } | null = null; | |
| 540 | let unwatchSize: (() => void) | null = null; | |
| 541 | let tempUnlock: (() => void) | null = null; | |
| 542 | // how many rows above its resting place (the line under the widgets) the | |
| 543 | // cursor was left by a widget's `cursor`, and in which column. all other | |
| 544 | // cursor math assumes the resting place, so every write begins with | |
| 545 | // `unpark()`. | |
| 546 | let parked = 0; | |
| 547 | let parkedColumn = 0; | |
| 548 | let unwatchInput: (() => void) | null = null; | |
| 549 | let escapeTimer: async.Cancelable<void> | null = null; | |
| 550 | // the terminal is asked once, since an answer that comes after the | |
| 551 | // keyboard was given back is typed into whatever reads it next | |
| 552 | let keyboard: "unknown" | "asked" | "enhanced" = "unknown"; | |
| 553 | let xtermjs = false; | |
| 554 | ||
| 555 | // the size subscription lives exactly as long as the terminal lock, so | |
| 556 | // acquisition and closing are funneled through these two functions. | |
| 557 | function acquireTerminal(): TerminalLock { | |
| 558 | if (!terminal) { | |
| 559 | let initial = true; | |
| 560 | size = null; | |
| 561 | terminal = lockTerminal(); | |
| 562 | unwatchSize = terminal.observeSize((next) => { | |
| 563 | size = next; | |
| 564 | if (!initial) handleResize(); | |
| 565 | }); | |
| 566 | ASSERT(size, "TerminalLock.observeSize must call back synchronously with the current size"); | |
| 567 | initial = false; | |
| 568 | } | |
| 569 | return terminal; | |
| 570 | } | |
| 571 | function closeTerminal() { | |
| 572 | endInput(); | |
| 573 | unwatchSize?.(); | |
| 574 | unwatchSize = null; | |
| 575 | terminal?.close(); | |
| 576 | terminal = null; | |
| 577 | } | |
| 578 | ||
| 579 | function unpark(): string { | |
| 580 | const text = parked ? ansi.cursorDown(parked) + "\r" : ""; | |
| 581 | parked = 0; | |
| 582 | return text; | |
| 583 | } | |
| 584 | ||
| 585 | function dispatchInput(events: WidgetInput[]) { | |
| 586 | for (const event of events) { | |
| 587 | widgets.findLast((widget) => widget.input)?.input?.(event); | |
| 588 | } | |
| 589 | } | |
| 590 | ||
| 591 | function startInput(term: TerminalLock) { | |
| 592 | const decoder = new InputDecoder((params, final) => { | |
| 593 | if (keyboard === "asked" && final === "u" && params.startsWith("?")) { | |
| 594 | keyboard = "enhanced"; | |
| 595 | term.writeInteractive(keyboardEnhance); | |
| 596 | } | |
| 597 | if (final === "c" && params === xtermjsVersion) xtermjs = true; | |
| 598 | }); | |
| 599 | const unwatch = UNWRAP(observeInput)((data) => { | |
| 600 | escapeTimer?.cancel(); | |
| 601 | escapeTimer = null; | |
| 602 | dispatchInput(decoder.push(data)); | |
| 603 | // a widget may have given up the keyboard in response | |
| 604 | if (decoder.pending && unwatchInput === unwatch) { | |
| 605 | (escapeTimer = delay(escapeMs)).then(() => { | |
| 606 | escapeTimer = null; | |
| 607 | dispatchInput(decoder.flush()); | |
| 608 | }); | |
| 609 | } | |
| 610 | }); | |
| 611 | unwatchInput = unwatch; | |
| 612 | ||
| 613 | term.writeInteractive( | |
| 614 | pasteEnable + | |
| 615 | (keyboard === "enhanced" ? keyboardEnhance : "") + | |
| 616 | (keyboard === "unknown" ? keyboardQuery + versionQuery : ""), | |
| 617 | ); | |
| 618 | if (keyboard === "unknown") keyboard = "asked"; | |
| 619 | } | |
| 620 | ||
| 621 | function endInput() { | |
| 622 | if (!unwatchInput) return; | |
| 623 | escapeTimer?.cancel(); | |
| 624 | escapeTimer = null; | |
| 625 | UNWRAP(terminal).writeInteractive( | |
| 626 | (keyboard === "enhanced" ? keyboardUnenhance : "") + pasteDisable, | |
| 627 | ); | |
| 628 | unwatchInput(); | |
| 629 | unwatchInput = null; | |
| 630 | } | |
| 631 | ||
| 632 | function handleResize() { | |
| 633 | if (lines.length > 0 && terminal) { | |
| 634 | // the terminal has wrapped what was drawn to its new width. the widths | |
| 635 | // that were drawn say how many rows that put between the cursor and | |
| 636 | // the first row. should that row have left the screen, this stops at | |
| 637 | // the screen's. | |
| 638 | const { columns } = UNWRAP(size); | |
| 639 | const wrapped = lines.map((line) => | |
| 640 | Math.max(1, Math.ceil(ansi.widthInTerminal(line) / columns)), | |
| 641 | ); | |
| 642 | const at = lines.length - parked; | |
| 643 | let up = wrapped.slice(0, at).reduce((sum, rows) => sum + rows, 0); | |
| 644 | if (parked && !xtermjs) { | |
| 645 | // the cursor is kept with the text that it was on | |
| 646 | up += Math.floor(parkedColumn / columns); | |
| 647 | } else if (parked) { | |
| 648 | // xterm.js cuts the cursor's line off instead, and moves the cursor | |
| 649 | // down for every row that it adds, including those under the cursor | |
| 650 | up += wrapped.slice(at + 1).reduce((sum, rows) => sum + rows - 1, 0); | |
| 651 | } | |
| 652 | terminal.writeInteractive( | |
| 653 | ansi.cursorUp(up) + | |
| 654 | "\r" + | |
| 655 | // short of the first cell, since tmux moves a screen that is | |
| 656 | // cleared from its top left corner into the scrollback | |
| 657 | ansi.clearFullLine + | |
| 658 | ansi.cursorRight(1) + | |
| 659 | ansi.clearToEndOfScreen + | |
| 660 | "\r", | |
| 661 | ); | |
| 662 | lines = []; | |
| 663 | parked = 0; | |
| 664 | } | |
| 665 | // the save register holds pre-resize coordinates; restoring it would | |
| 666 | // jump somewhere unrelated. | |
| 667 | needsToRestoreCursor = false; | |
| 668 | requestRedraw(); | |
| 669 | } | |
| 670 | ||
| 671 | function redrawCallback() { | |
| 672 | timer = null; | |
| 673 | ASSERT(!rendering); | |
| 674 | rendering = true; | |
| 675 | // the finally is load-bearing: if a render callback or terminal write | |
| 676 | // throws, `rendering` must reset, or the exit path's cancel() asserts | |
| 677 | // and masks the original error. | |
| 678 | try { | |
| 679 | redrawCallbackInner(); | |
| 680 | } finally { | |
| 681 | rendering = false; | |
| 682 | if (pendingRedraw) { | |
| 683 | pendingRedraw = false; | |
| 684 | redrawSoon(0); | |
| 685 | } | |
| 686 | // cancel() during this render pass (an exit handler unwinding through | |
| 687 | // a crashed format callback) skips its teardown; finish it here. | |
| 688 | if (widgets.length === 0 && terminal && !timer && !buffer.length) { | |
| 689 | lines = []; | |
| 690 | closeTerminal(); | |
| 691 | } | |
| 692 | } | |
| 693 | } | |
| 694 | ||
| 695 | function redrawCallbackInner() { | |
| 696 | redrawTime = (lastFlush = now()) - 0.00001; // windows time precision workaround | |
| 697 | ||
| 698 | // trivial path when not using widgets | |
| 699 | if (!lines.length && !widgets.length) { | |
| 700 | if (buffer.length) { | |
| 701 | needsToRestoreCursor = false; | |
| 702 | needsToSaveCursor = false; | |
| 703 | if (!terminal && writeOutputTemporaryLock && !buffer.some((c) => c.err)) { | |
| 704 | const text = buffer.map((c) => c.text).join(""); | |
| 705 | buffer = []; | |
| 706 | writeOutputTemporaryLock(text); | |
| 707 | if (outputOnScreen) trackPartialWidth(text); | |
| 708 | } else { | |
| 709 | trackPartialWidth(flushChunks(acquireTerminal())); | |
| 710 | } | |
| 711 | } | |
| 712 | // with nothing to write, this still has to end what a draw lock left | |
| 713 | // open when it cleared the last widgets | |
| 714 | if (hasSyncStart) { | |
| 715 | acquireTerminal().writeInteractive(ansi.syncEnd); | |
| 716 | hasSyncStart = false; | |
| 717 | } | |
| 718 | closeTerminal(); | |
| 719 | return; | |
| 720 | } | |
| 721 | ||
| 722 | const term = acquireTerminal(); | |
| 723 | const { columns, rows } = UNWRAP(size); | |
| 724 | let newWidgetLines: string[] = []; | |
| 725 | let next = Infinity; | |
| 726 | const focused = widgets.findLast((widget) => widget.input); | |
| 727 | // the widget with the keyboard comes first: it gets the rows it needs | |
| 728 | // before the others share what is left, and it stays where it is while | |
| 729 | // they come and go under it | |
| 730 | const order = widgets.filter((widget) => widget !== focused); | |
| 731 | if (focused) order.unshift(focused); | |
| 732 | let cursor: WidgetFrame["cursor"]; | |
| 733 | for (const widget of order) { | |
| 734 | // a format callback may stop another widget, or its own | |
| 735 | if (!widgets.includes(widget)) continue; | |
| 736 | let out: string | WidgetFrame | null; | |
| 737 | try { | |
| 738 | out = widget.format({ | |
| 739 | now: lastFlush, | |
| 740 | width: columns, | |
| 741 | height: rows, | |
| 742 | focused: widget === focused, | |
| 743 | }); | |
| 744 | } catch (e) { | |
| 745 | out = e instanceof Error ? stack.format(e, color) : exceptions.message(e); | |
| 746 | } | |
| 747 | const w = widgets.indexOf(widget); | |
| 748 | if (w === -1) continue; | |
| 749 | if (out == null) { | |
| 750 | widgets.splice(w, 1); | |
| 751 | internals.splice(w, 1); | |
| 752 | continue; | |
| 753 | } | |
| 754 | next = Math.min(next, UNWRAP(internals[w]).frameTime); | |
| 755 | const outText = typeof out === "string" ? out : out.text; | |
| 756 | // an empty string is a live widget that currently displays nothing; | |
| 757 | // only `null` detaches. | |
| 758 | if (outText === "") continue; | |
| 759 | const rowsLeft = Math.max(1, rows - newWidgetLines.length - 1); | |
| 760 | if (rowsLeft === 1) continue; | |
| 761 | const widgetLines = outText | |
| 762 | .split("\n") | |
| 763 | .slice(0, rowsLeft) | |
| 764 | .map((line) => ansi.trimForTerminal(line, columns - 1)); | |
| 765 | newWidgetLines.push(...widgetLines); | |
| 766 | const at = widget === focused && typeof out !== "string" ? out.cursor : undefined; | |
| 767 | if (at && Number.isInteger(at.line) && at.line >= 0 && at.line < widgetLines.length) { | |
| 768 | cursor = { | |
| 769 | line: at.line, | |
| 770 | column: Math.max(0, Math.min(Math.trunc(at.column), columns - 1)), | |
| 771 | }; | |
| 772 | } | |
| 773 | } | |
| 774 | newWidgetLines = newWidgetLines.slice(0, rows - 1); | |
| 775 | if (next < Infinity) redrawSoon(next); | |
| 776 | ||
| 777 | if (observeInput && widgets.some((widget) => widget.input)) { | |
| 778 | if (!unwatchInput) startInput(term); | |
| 779 | } else { | |
| 780 | endInput(); | |
| 781 | } | |
| 782 | ||
| 783 | // a cursor on a line that did not fit on screen stays at rest | |
| 784 | const park = | |
| 785 | cursor && cursor.line < newWidgetLines.length ? newWidgetLines.length - cursor.line : 0; | |
| 786 | const parkText = park ? ansi.cursorUp(park) + ansi.cursorRight(UNWRAP(cursor).column) : ""; | |
| 787 | ||
| 788 | const pCol = partialWidth % columns; | |
| 789 | ||
| 790 | if (newWidgetLines.length === 0) { | |
| 791 | if (lines.length > 0) { | |
| 792 | term.writeInteractive( | |
| 793 | (hasSyncStart ? "" : ansi.syncStart) + | |
| 794 | unpark() + | |
| 795 | // clear the widget space | |
| 796 | (ansi.cursorUp(1) + ansi.clearFullLine).repeat(lines.length) + | |
| 797 | (pCol ? ansi.cursorUp(1) + ansi.cursorRight(pCol) : "") + | |
| 798 | (needsToRestoreCursor ? ansi.cursorRestore : ""), | |
| 799 | ); | |
| 800 | hasSyncStart = true; | |
| 801 | needsToRestoreCursor = false; | |
| 802 | lines = []; | |
| 803 | } | |
| 804 | if (buffer.length) { | |
| 805 | trackPartialWidth(flushChunks(term)); | |
| 806 | } | |
| 807 | if (hasSyncStart) { | |
| 808 | term.writeInteractive(ansi.syncEnd); | |
| 809 | hasSyncStart = false; | |
| 810 | } | |
| 811 | // the last widget may have detached this frame; release the terminal | |
| 812 | // (and its patches) instead of holding the lock until the next flush | |
| 813 | if (widgets.length === 0) closeTerminal(); | |
| 814 | return; | |
| 815 | } | |
| 816 | ||
| 817 | // concatenation of the buffered text that lands on the widget screen, | |
| 818 | // which is what the cursor math must account for | |
| 819 | const screenText = buffer | |
| 820 | .filter((c) => c.err || outputOnScreen) | |
| 821 | .map((c) => c.text) | |
| 822 | .join(""); | |
| 823 | if (buffer.length && screenText === "") { | |
| 824 | // off-screen output (e.g. stdout redirected to a file) does not | |
| 825 | // interact with the widget block; flush it plainly and fall through | |
| 826 | // to a pure widget redraw | |
| 827 | flushChunks(term); | |
| 828 | } | |
| 829 | ||
| 830 | if (buffer.length) { | |
| 831 | // when writing a buffer alongside widgets, the screen may look like this | |
| 832 | // > [existing log] | |
| 833 | // > [optional partial line] | |
| 834 | // > [widget line 1] | |
| 835 | // > [widget line 2] | |
| 836 | // > [widget line 3] | |
| 837 | // > [cursor is start of this line] | |
| 838 | // | |
| 839 | // first, clear out the space where new lines are going to intersect. | |
| 840 | // `span.rows` measures the cursor descent in physical rows, so log | |
| 841 | // lines wider than the terminal are accounted for correctly. | |
| 842 | const span = measureTerminalSpan(screenText, pCol, columns); | |
| 843 | // if more rows are buffered than there are widgets, only some are | |
| 844 | // needed. when a partial line exists, the buffer starts on its row | |
| 845 | // (one above the widget block), hence the -1. | |
| 846 | const clearLinesTop = Math.min( | |
| 847 | lines.length, | |
| 848 | span.rows + (span.endWidth % columns > 0 ? 1 : 0) + (pCol ? -1 : 0), | |
| 849 | ); | |
| 850 | const oldLines = lines.slice(clearLinesTop); | |
| 851 | term.writeInteractive( | |
| 852 | (needsToSaveCursor ? ansi.cursorSave + "\n" : "") + | |
| 853 | (hasSyncStart ? "" : ansi.syncStart) + | |
| 854 | unpark() + | |
| 855 | (clearLinesTop > 0 || (pCol && lines.length > 0) | |
| 856 | ? // clear the lines for buffer | |
| 857 | (clearLinesTop > 0 | |
| 858 | ? ansi.cursorUp(lines.length - clearLinesTop + 1) + | |
| 859 | ansi.clearFullLine + | |
| 860 | (ansi.cursorUp(1) + ansi.clearFullLine).repeat(clearLinesTop - 1) | |
| 861 | : "") + | |
| 862 | (pCol && lines.length > 0 | |
| 863 | ? ansi.cursorUp(clearLinesTop > 0 ? 1 : lines.length + 1) + ansi.cursorRight(pCol) | |
| 864 | : "") | |
| 865 | : ""), | |
| 866 | ); | |
| 867 | // then write the buffered log content to its streams | |
| 868 | flushChunks(term); | |
| 869 | term.writeInteractive( | |
| 870 | // if the buffer leaves a partial line, the widgets have to go on the | |
| 871 | // next line. to avoid breaking stdout, the newline gets emitted on | |
| 872 | // the interactive out. (a line ending exactly on the terminal edge | |
| 873 | // leaves the cursor wrap-deferred; this newline lands on the next | |
| 874 | // row, which is also where `span.rows` placed the continuation.) | |
| 875 | (span.endWidth > 0 ? "\n" : "") + | |
| 876 | widgetText(newWidgetLines, oldLines) + | |
| 877 | // rows of a taller widget block that nothing was written over | |
| 878 | (oldLines.length > newWidgetLines.length ? ansi.clearToEndOfScreen : "") + | |
| 879 | parkText + | |
| 880 | ansi.syncEnd, | |
| 881 | ); | |
| 882 | partialWidth = span.endWidth; | |
| 883 | } else { | |
| 884 | const clearLinesBottom = Math.min( | |
| 885 | lines.length, | |
| 886 | Math.max(0, lines.length - newWidgetLines.length), | |
| 887 | ); | |
| 888 | term.writeInteractive( | |
| 889 | (needsToSaveCursor ? ansi.cursorSave + "\n" : "") + | |
| 890 | (hasSyncStart ? "" : ansi.syncStart) + | |
| 891 | unpark() + | |
| 892 | // the first draw can land just after a partial log line; widgets | |
| 893 | // must move below it. (`partialWidth` is zero whenever | |
| 894 | // `needsToSaveCursor` is set, since releasing a draw lock resets | |
| 895 | // it, so this never combines with the cursorSave newline.) | |
| 896 | (lines.length === 0 && partialWidth > 0 ? "\n" : "") + | |
| 897 | // clear the bottom lines | |
| 898 | (clearLinesBottom | |
| 899 | ? (ansi.cursorUp(1) + ansi.clearToEndOfLine).repeat(clearLinesBottom) | |
| 900 | : "") + | |
| 901 | // skip up the widget space | |
| 902 | ansi.cursorUp(lines.length - clearLinesBottom) + | |
| 903 | widgetText(newWidgetLines, lines) + | |
| 904 | parkText + | |
| 905 | ansi.syncEnd, | |
| 906 | ); | |
| 907 | } | |
| 908 | hasSyncStart = false; | |
| 909 | needsToRestoreCursor ||= needsToSaveCursor; | |
| 910 | needsToSaveCursor = false; | |
| 911 | lines = newWidgetLines; | |
| 912 | parked = park; | |
| 913 | parkedColumn = cursor?.column ?? 0; | |
| 914 | } | |
| 915 | ||
| 916 | /** `next` drawn from the cursor down, over rows that hold `previous` */ | |
| 917 | function widgetText(next: string[], previous: string[]): string { | |
| 918 | const rows = next | |
| 919 | .map( | |
| 920 | (line, i) => | |
| 921 | // a row that is only erased from where the shorter line ends is still | |
| 922 | // as long as before to tmux, which wraps it on a narrower screen | |
| 923 | (previous[i] && ansi.widthInTerminal(previous[i]) > ansi.widthInTerminal(line) | |
| 924 | ? ansi.clearFullLine | |
| 925 | : "") + | |
| 926 | (line.includes("\x1b") && !line.endsWith(ansi.reset) ? line + ansi.reset : line) + | |
| 927 | "\n", | |
| 928 | ) | |
| 929 | .join(""); | |
| 930 | // a terminal can be narrower than it last said, as its pty is told of a | |
| 931 | // resize after its screen is. a row that wrapped there would be two. | |
| 932 | return unwatchInput ? wrapOff + rows + wrapOn : rows; | |
| 933 | } | |
| 934 | ||
| 935 | /** | |
| 936 | * write every buffered chunk to its stream in order, returning the | |
| 937 | * concatenation of the chunks that landed on the widget screen. error | |
| 938 | * chunks flush through `writeInteractive` since the interactive stream is | |
| 939 | * the error stream; plain output rows land on screen only when | |
| 940 | * `outputSharesScreen`. | |
| 941 | */ | |
| 942 | function flushChunks(term: TerminalLock): string { | |
| 943 | let screen = ""; | |
| 944 | for (const chunk of buffer) { | |
| 945 | if (chunk.err) { | |
| 946 | term.writeInteractive(chunk.text); | |
| 947 | screen += chunk.text; | |
| 948 | } else { | |
| 949 | term.writeOutput(chunk.text); | |
| 950 | if (outputOnScreen) screen += chunk.text; | |
| 951 | } | |
| 952 | } | |
| 953 | buffer = []; | |
| 954 | return screen; | |
| 955 | } | |
| 956 | ||
| 957 | /** | |
| 958 | * update `partialWidth` after `screenText` (already filtered to the chunks | |
| 959 | * that landed on the widget screen) was written. | |
| 960 | */ | |
| 961 | function trackPartialWidth(screenText: string) { | |
| 962 | const i = screenText.lastIndexOf("\n"); | |
| 963 | partialWidth = ansi.widthInTerminal(screenText.slice(i + 1)) + (i === -1 ? partialWidth : 0); | |
| 964 | } | |
| 965 | ||
| 966 | function redrawSoon(ms: number) { | |
| 967 | if (locks > 0 || (ms === 0 && rendering)) return; | |
| 968 | const newRedrawTime = now() + ms; | |
| 969 | if (timer) { | |
| 970 | if (redrawTime < newRedrawTime) return; | |
| 971 | timer.cancel(); // cancel previous | |
| 972 | } | |
| 973 | // 1ms wiggle room, generally runtimes have much larger variance on timers | |
| 974 | redrawTime = newRedrawTime - 1; | |
| 975 | timer = delay(ms); | |
| 976 | timer.then(redrawCallback); | |
| 977 | } | |
| 978 | ||
| 979 | function requestRedraw() { | |
| 980 | if (rendering) pendingRedraw = true; | |
| 981 | else redrawSoon(0); | |
| 982 | } | |
| 983 | ||
| 984 | function flushAndClear(shortTermDrawLock: boolean) { | |
| 985 | timer?.cancel(); | |
| 986 | timer = null; | |
| 987 | if (lines.length > 0) { | |
| 988 | const term = UNWRAP(terminal); | |
| 989 | const pCol = partialWidth % UNWRAP(size).columns; | |
| 990 | term.writeInteractive( | |
| 991 | ansi.syncStart + | |
| 992 | unpark() + | |
| 993 | // clear the widget space | |
| 994 | (ansi.cursorUp(1) + ansi.clearFullLine).repeat(lines.length) + | |
| 995 | (pCol ? ansi.cursorUp(1) + ansi.cursorRight(pCol) : "") + | |
| 996 | (shortTermDrawLock ? "" : ansi.syncEnd) + | |
| 997 | (needsToRestoreCursor ? ansi.cursorRestore : ""), | |
| 998 | ); | |
| 999 | needsToRestoreCursor = false; | |
| 1000 | lines = []; | |
| 1001 | hasSyncStart = shortTermDrawLock; | |
| 1002 | } | |
| 1003 | if (buffer.length > 0) { | |
| 1004 | if ( | |
| 1005 | widgets.length === 0 && | |
| 1006 | !terminal && | |
| 1007 | writeOutputTemporaryLock && | |
| 1008 | !buffer.some((c) => c.err) | |
| 1009 | ) { | |
| 1010 | const text = buffer.map((c) => c.text).join(""); | |
| 1011 | buffer = []; | |
| 1012 | writeOutputTemporaryLock(text); | |
| 1013 | if (outputOnScreen) trackPartialWidth(text); | |
| 1014 | } else { | |
| 1015 | trackPartialWidth(flushChunks(acquireTerminal())); | |
| 1016 | if (widgets.length === 0) closeTerminal(); | |
| 1017 | } | |
| 1018 | } | |
| 1019 | } | |
| 1020 | ||
| 1021 | /** append a write to the flush buffer, scheduling the flush */ | |
| 1022 | function bufferChunk(chunk: string, err: boolean) { | |
| 1023 | const last = buffer[buffer.length - 1]; | |
| 1024 | if (last && last.err === err) last.text += chunk; | |
| 1025 | else buffer.push({ text: chunk, err }); | |
| 1026 | redrawSoon(0); | |
| 1027 | if (locks > 0 || rendering) return; | |
| 1028 | if (buffer.reduce((n, c) => n + c.text.length, 0) >= flushSyncBytes) { | |
| 1029 | flushSync(true); | |
| 1030 | } else if (timer && now() >= redrawTime + flushSyncMs) { | |
| 1031 | // the scheduled flush is long overdue, so the event loop is blocked; | |
| 1032 | // flushing inline keeps logs streaming through cpu-bound work | |
| 1033 | flushSync(false); | |
| 1034 | } | |
| 1035 | } | |
| 1036 | ||
| 1037 | /** | |
| 1038 | * run the redraw flush immediately instead of waiting for the timer. with | |
| 1039 | * `keepPartialLine`, text after the last buffered newline stays in the | |
| 1040 | * buffer so a line under construction is not displayed mid-way; if the | |
| 1041 | * buffer holds no newline at all, everything flushes to bound memory. | |
| 1042 | */ | |
| 1043 | function flushSync(keepPartialLine: boolean) { | |
| 1044 | let tail: typeof buffer = []; | |
| 1045 | if (keepPartialLine) { | |
| 1046 | let i = buffer.length - 1; | |
| 1047 | while (i >= 0 && !UNWRAP(buffer[i]).text.includes("\n")) i -= 1; | |
| 1048 | if (i >= 0) { | |
| 1049 | const edge = UNWRAP(buffer[i]); | |
| 1050 | const split = edge.text.lastIndexOf("\n") + 1; | |
| 1051 | tail = buffer.splice(i + 1); | |
| 1052 | if (split < edge.text.length) { | |
| 1053 | tail.unshift({ text: edge.text.slice(split), err: edge.err }); | |
| 1054 | edge.text = edge.text.slice(0, split); | |
| 1055 | } | |
| 1056 | } | |
| 1057 | } | |
| 1058 | timer?.cancel(); | |
| 1059 | timer = null; | |
| 1060 | redrawCallback(); | |
| 1061 | buffer = tail; | |
| 1062 | if (tail.length) redrawSoon(0); | |
| 1063 | } | |
| 1064 | ||
| 1065 | return { | |
| 1066 | writeOutput(chunk) { | |
| 1067 | if (chunk) bufferChunk(chunk, false); | |
| 1068 | }, | |
| 1069 | writeError(chunk) { | |
| 1070 | if (chunk) bufferChunk(chunk, true); | |
| 1071 | }, | |
| 1072 | getDrawLock(mode) { | |
| 1073 | if (rendering) ASSERT(locks === 0); | |
| 1074 | if (locks === 0) flushAndClear(mode === "short"); | |
| 1075 | // whoever holds a long lock may read the terminal themselves | |
| 1076 | if (mode === "long") endInput(); | |
| 1077 | if (locks === 0 && widgets.length > 0 && terminal) { | |
| 1078 | if (terminal.temporaryUnlock) { | |
| 1079 | tempUnlock = terminal.temporaryUnlock(); | |
| 1080 | } else { | |
| 1081 | closeTerminal(); | |
| 1082 | } | |
| 1083 | } | |
| 1084 | locks += 1; | |
| 1085 | let disposed = false; | |
| 1086 | return { | |
| 1087 | release(mode) { | |
| 1088 | if (disposed) return; | |
| 1089 | disposed = true; | |
| 1090 | locks -= 1; | |
| 1091 | needsToSaveCursor ||= mode === "cursor-middle-of-line"; | |
| 1092 | // whatever the lock holder wrote has detached the cursor from any | |
| 1093 | // previously tracked partial log line. "cursor-start-of-line" | |
| 1094 | // states the cursor is on a fresh line; "cursor-middle-of-line" | |
| 1095 | // engages the save/restore dance instead. | |
| 1096 | partialWidth = 0; | |
| 1097 | if (locks === 0) { | |
| 1098 | tempUnlock?.(); | |
| 1099 | tempUnlock = null; | |
| 1100 | // a redraw is also what lets go of a terminal that the lock | |
| 1101 | // took from widgets which are gone by now | |
| 1102 | if (buffer.length > 0 || widgets.length > 0 || terminal || hasSyncStart) { | |
| 1103 | redrawSoon(0); | |
| 1104 | } | |
| 1105 | } | |
| 1106 | }, | |
| 1107 | [Symbol.dispose]() { | |
| 1108 | this.release("cursor-middle-of-line"); | |
| 1109 | }, | |
| 1110 | }; | |
| 1111 | }, | |
| 1112 | startWidget(options) { | |
| 1113 | ASSERT(!widgets.includes(options), "Cannot start the same widget twice."); | |
| 1114 | let fps = options.fps ?? null; | |
| 1115 | const state: WidgetState = { | |
| 1116 | frameTime: 1000 / (fps ?? 0), | |
| 1117 | }; | |
| 1118 | widgets.push(options); | |
| 1119 | internals.push(state); | |
| 1120 | requestRedraw(); | |
| 1121 | return { | |
| 1122 | options, | |
| 1123 | get fps() { | |
| 1124 | return fps; | |
| 1125 | }, | |
| 1126 | set fps(value) { | |
| 1127 | fps = value; | |
| 1128 | state.frameTime = 1000 / (fps ?? 0); | |
| 1129 | // a pending frame may sit beyond the new cadence; draw to reanchor | |
| 1130 | requestRedraw(); | |
| 1131 | }, | |
| 1132 | redraw: requestRedraw, | |
| 1133 | stop() { | |
| 1134 | const i = widgets.indexOf(options); | |
| 1135 | if (i === -1) return; | |
| 1136 | widgets.splice(i, 1); | |
| 1137 | UNWRAP(internals.splice(i, 1)[0]); | |
| 1138 | requestRedraw(); | |
| 1139 | }, | |
| 1140 | [Symbol.dispose]() { | |
| 1141 | this.stop(); | |
| 1142 | }, | |
| 1143 | }; | |
| 1144 | }, | |
| 1145 | cancel() { | |
| 1146 | // cancel may be reached from exit handlers while a redraw is on the | |
| 1147 | // stack (a crash inside a render callback unwinds through here); skip | |
| 1148 | // the flush in that case and just tear down, so the original error | |
| 1149 | // is the one that surfaces. | |
| 1150 | if (!rendering) flushAndClear(false); | |
| 1151 | widgets.splice(0, widgets.length); | |
| 1152 | internals.splice(0, internals.length); | |
| 1153 | timer?.cancel(); | |
| 1154 | timer = null; | |
| 1155 | // the keyboard is given back even then, as this may be the exit | |
| 1156 | endInput(); | |
| 1157 | if (!rendering) closeTerminal(); | |
| 1158 | }, | |
| 1159 | delay, | |
| 1160 | now, | |
| 1161 | capabilities: color ? ["widget", "color"] : ["widget"], | |
| 1162 | }; | |
| 1163 | } | |
| 1164 | ||
| 1165 | /** | |
| 1166 | * compute the cursor descent (`rows`) and trailing line width (`endWidth`) | |
| 1167 | * from writing `text` to a terminal `columns` wide, with the cursor starting | |
| 1168 | * `startWidth` cells into a line. wrapping follows the DECAWM deferred-wrap | |
| 1169 | * convention shared by modern terminals: a line of exactly `columns` cells | |
| 1170 | * leaves the cursor pending on the same row, so a newline after it descends | |
| 1171 | * only one row. a trailing line ending exactly on the boundary reports its | |
| 1172 | * continuation point at column 0 of the next row (`endWidth % columns === 0` | |
| 1173 | * with the descended row included in `rows`). | |
| 1174 | */ | |
| 1175 | function measureTerminalSpan( | |
| 1176 | text: string, | |
| 1177 | startWidth: number, | |
| 1178 | columns: number, | |
| 1179 | ): { rows: number; endWidth: number } { | |
| 1180 | let rows = 0; | |
| 1181 | let width = startWidth; | |
| 1182 | const parts = text.split("\n"); | |
| 1183 | for (let i = 0; i < parts.length - 1; i += 1) { | |
| 1184 | width += ansi.widthInTerminal(UNWRAP(parts[i])); | |
| 1185 | rows += Math.max(1, Math.ceil(width / columns)); | |
| 1186 | width = 0; | |
| 1187 | } | |
| 1188 | width += ansi.widthInTerminal(UNWRAP(parts[parts.length - 1])); | |
| 1189 | rows += Math.floor(width / columns); | |
| 1190 | return { rows, endWidth: width }; | |
| 1191 | } | |
| 1192 | ||
| 1193 | /** starts and stops bracketed paste, around reading the keyboard */ | |
| 1194 | const pasteEnable = "\x1b[?2004h"; | |
| 1195 | const pasteDisable = "\x1b[?2004l"; | |
| 1196 | /** | |
| 1197 | * asks if the terminal supports the kitty keyboard protocol. one that does | |
| 1198 | * answers with a report of `"?"` parameters ending in `"u"`. | |
| 1199 | */ | |
| 1200 | const keyboardQuery = "\x1b[?u"; | |
| 1201 | /** | |
| 1202 | * written once the terminal answers, to have it encode keys unambiguously. | |
| 1203 | * this is what tells `shift+enter` from `enter`. | |
| 1204 | */ | |
| 1205 | const keyboardEnhance = "\x1b[>1u"; | |
| 1206 | const keyboardUnenhance = "\x1b[<u"; | |
| 1207 | /** | |
| 1208 | * asks which terminal this is, as xterm would tell its version. xterm.js | |
| 1209 | * has a fixed answer, and no other way of telling that it is the one. | |
| 1210 | */ | |
| 1211 | const versionQuery = "\x1b[>c"; | |
| 1212 | const xtermjsVersion = ">0;276;0"; | |
| 1213 | /** stops and starts the terminal continuing on the next row at its last column */ | |
| 1214 | const wrapOff = "\x1b[?7l"; | |
| 1215 | const wrapOn = "\x1b[?7h"; | |
| 1216 | const pasteEnd = "\x1b[201~"; | |
| 1217 | ||
| 1218 | /** | |
| 1219 | * turns what a terminal sends for keyboard input into {@linkcode WidgetInput}: | |
| 1220 | * legacy xterm sequences, the [kitty keyboard protocol], `modifyOtherKeys`, | |
| 1221 | * and bracketed paste. stateful, as any of those can be split across reads. | |
| 1222 | * | |
| 1223 | * [kitty keyboard protocol]: https://sw.kovidgoyal.net/kitty/keyboard-protocol/ | |
| 1224 | */ | |
| 1225 | class InputDecoder { | |
| 1226 | #report: (params: string, final: string) => void; | |
| 1227 | /** an escape sequence, or a paste terminator, that is cut off */ | |
| 1228 | #pending = ""; | |
| 1229 | #paste: string | null = null; | |
| 1230 | ||
| 1231 | /** | |
| 1232 | * `report` receives control sequences where the terminal describes itself | |
| 1233 | * instead of a key, split into parameter bytes and the final byte. | |
| 1234 | */ | |
| 1235 | constructor(report: (params: string, final: string) => void = () => {}) { | |
| 1236 | this.#report = report; | |
| 1237 | } | |
| 1238 | ||
| 1239 | /** | |
| 1240 | * true when input ended inside an escape sequence. a lone escape byte is | |
| 1241 | * either the escape key or the start of a sequence, and only time can tell | |
| 1242 | * them apart: if nothing else arrives shortly, call {@linkcode flush}. | |
| 1243 | */ | |
| 1244 | get pending(): boolean { | |
| 1245 | return this.#pending !== "" && this.#paste === null; | |
| 1246 | } | |
| 1247 | ||
| 1248 | /** decode the next chunk of terminal input */ | |
| 1249 | push(chunk: string): WidgetInput[] { | |
| 1250 | const events: WidgetInput[] = []; | |
| 1251 | const data = this.#pending + chunk; | |
| 1252 | this.#pending = ""; | |
| 1253 | ||
| 1254 | let i = 0; | |
| 1255 | while (i < data.length) { | |
| 1256 | if (this.#paste !== null) { | |
| 1257 | const end = data.indexOf(pasteEnd, i); | |
| 1258 | if (end === -1) { | |
| 1259 | const escape = data.lastIndexOf("\x1b"); | |
| 1260 | const cut = escape >= i && pasteEnd.startsWith(data.slice(escape)) ? escape : data.length; | |
| 1261 | this.#paste += data.slice(i, cut); | |
| 1262 | this.#pending = data.slice(cut); | |
| 1263 | break; | |
| 1264 | } | |
| 1265 | const text = this.#paste + data.slice(i, end); | |
| 1266 | events.push({ key: "paste", text: text.replace(/\r\n?/g, "\n") }); | |
| 1267 | this.#paste = null; | |
| 1268 | i = end + pasteEnd.length; | |
| 1269 | continue; | |
| 1270 | } | |
| 1271 | ||
| 1272 | const code = data.charCodeAt(i); | |
| 1273 | if (code === 0x1b) { | |
| 1274 | const length = this.#escape(data, i, events); | |
| 1275 | if (length === 0) { | |
| 1276 | this.#pending = data.slice(i); | |
| 1277 | break; | |
| 1278 | } | |
| 1279 | i += length; | |
| 1280 | } else if (isControlCode(code)) { | |
| 1281 | const event = controlKey(code, 0); | |
| 1282 | if (event) events.push(event); | |
| 1283 | i += 1; | |
| 1284 | } else { | |
| 1285 | let end = i + 1; | |
| 1286 | while (end < data.length && !isControlCode(data.charCodeAt(end))) end += 1; | |
| 1287 | graphemes ??= new Intl.Segmenter(); | |
| 1288 | for (const { segment } of graphemes.segment(data.slice(i, end))) { | |
| 1289 | events.push(printableKey(segment, 0)); | |
| 1290 | } | |
| 1291 | i = end; | |
| 1292 | } | |
| 1293 | } | |
| 1294 | return events; | |
| 1295 | } | |
| 1296 | ||
| 1297 | /** decode input that {@linkcode pending} was waiting on, as it stands */ | |
| 1298 | flush(): WidgetInput[] { | |
| 1299 | if (!this.pending) return []; | |
| 1300 | const data = this.#pending; | |
| 1301 | this.#pending = ""; | |
| 1302 | // a held escape key, or one pressed twice | |
| 1303 | if (/^\x1b+$/.test(data)) return Array.from(data, () => namedKey("escape", 0)); | |
| 1304 | // anything longer than an alt-prefixed key is a sequence that was cut | |
| 1305 | // off, which is dropped instead of being keyText out as text | |
| 1306 | if (data.length > 2) return []; | |
| 1307 | const code = data.charCodeAt(1); | |
| 1308 | const event = isControlCode(code) ? controlKey(code, modAlt) : printableKey(data[1]!, modAlt); | |
| 1309 | return event ? [event] : []; | |
| 1310 | } | |
| 1311 | ||
| 1312 | /** | |
| 1313 | * decode the escape sequence at `data[start]`, returning its length, or | |
| 1314 | * `0` if it is cut off by the end of `data`. | |
| 1315 | */ | |
| 1316 | #escape(data: string, start: number, events: WidgetInput[]): number { | |
| 1317 | // an escape prefix is how legacy terminals encode alt | |
| 1318 | let last = start; | |
| 1319 | while (data[last + 1] === "\x1b") last += 1; | |
| 1320 | const mods = last > start ? modAlt : 0; | |
| 1321 | const introducer = data[last + 1]; | |
| 1322 | if (introducer === undefined) return 0; | |
| 1323 | ||
| 1324 | if (introducer === "[") { | |
| 1325 | let i = last + 2; | |
| 1326 | // the linux console sends its first function keys as `CSI [ A` | |
| 1327 | if (data[i] === "[") { | |
| 1328 | const name = linuxKeys[data[i + 1] ?? ""]; | |
| 1329 | if (name) events.push(namedKey(name, mods)); | |
| 1330 | return i + 1 < data.length ? i + 2 - start : 0; | |
| 1331 | } | |
| 1332 | while (inRange(data.charCodeAt(i), 0x30, 0x3f)) i += 1; | |
| 1333 | const params = data.slice(last + 2, i); | |
| 1334 | while (inRange(data.charCodeAt(i), 0x20, 0x2f)) i += 1; | |
| 1335 | if (i >= data.length) return 0; | |
| 1336 | // a byte that cannot end the sequence aborts it, and is decoded next | |
| 1337 | if (!inRange(data.charCodeAt(i), 0x40, 0x7e)) return i - start; | |
| 1338 | ||
| 1339 | const final = data[i]!; | |
| 1340 | if (params === "200" && final === "~") { | |
| 1341 | this.#paste = ""; | |
| 1342 | } else if (/^[<=>?]/.test(params)) { | |
| 1343 | this.#report(params, final); | |
| 1344 | } else { | |
| 1345 | const event = csiKey(params, final, mods); | |
| 1346 | if (event) events.push(event); | |
| 1347 | } | |
| 1348 | return i + 1 - start; | |
| 1349 | } | |
| 1350 | ||
| 1351 | if (introducer === "O") { | |
| 1352 | // some terminals put a modifier in front of the final byte | |
| 1353 | let i = last + 2; | |
| 1354 | while (inRange(data.charCodeAt(i), 0x30, 0x3b)) i += 1; | |
| 1355 | if (i >= data.length) return 0; | |
| 1356 | const name = ss3Keys[data[i]!]; | |
| 1357 | if (name) { | |
| 1358 | const modifier = | |
| 1359 | Number( | |
| 1360 | data | |
| 1361 | .slice(last + 2, i) | |
| 1362 | .split(";") | |
| 1363 | .at(-1), | |
| 1364 | ) || 1; | |
| 1365 | events.push(namedKey(name, mods | ((modifier - 1) & keyModifierMask))); | |
| 1366 | return i + 1 - start; | |
| 1367 | } | |
| 1368 | } else if ("]P_^X".includes(introducer)) { | |
| 1369 | // a string sequence is only recognized when whole, since waiting for | |
| 1370 | // a terminator would swallow what is keyText after alt+] | |
| 1371 | const bell = data.indexOf("\x07", last + 2); | |
| 1372 | const terminator = data.indexOf("\x1b\\", last + 2); | |
| 1373 | if (bell !== -1 && (terminator === -1 || bell < terminator)) { | |
| 1374 | return bell + 1 - start; | |
| 1375 | } | |
| 1376 | if (terminator !== -1) return terminator + 2 - start; | |
| 1377 | } | |
| 1378 | ||
| 1379 | const code = data.codePointAt(last + 1)!; | |
| 1380 | const event = isControlCode(code) | |
| 1381 | ? controlKey(code, mods | modAlt) | |
| 1382 | : printableKey(String.fromCodePoint(code), mods | modAlt); | |
| 1383 | if (event) events.push(event); | |
| 1384 | return last - start + (code > 0xffff ? 3 : 2); | |
| 1385 | } | |
| 1386 | } | |
| 1387 | ||
| 1388 | // modifier bits, numbered as the kitty protocol encodes them | |
| 1389 | const modShift = 1; | |
| 1390 | const modAlt = 2; | |
| 1391 | const modCtrl = 4; | |
| 1392 | /** the lock keys are reported in higher bits, and are not keyModifiers */ | |
| 1393 | const keyModifierMask = 0b111111; | |
| 1394 | const keyModifiers: [bit: number, prefix: string][] = [ | |
| 1395 | [modCtrl, "ctrl+"], | |
| 1396 | [modAlt, "alt+"], | |
| 1397 | [modShift, "shift+"], | |
| 1398 | [8, "super+"], | |
| 1399 | [16, "hyper+"], | |
| 1400 | [32, "meta+"], | |
| 1401 | ]; | |
| 1402 | ||
| 1403 | const keyText: Partial<Record<string, string>> = { | |
| 1404 | enter: "\n", | |
| 1405 | tab: "\t", | |
| 1406 | space: " ", | |
| 1407 | }; | |
| 1408 | ||
| 1409 | const csiKeys: Partial<Record<string, string>> = { | |
| 1410 | A: "up", | |
| 1411 | B: "down", | |
| 1412 | C: "right", | |
| 1413 | D: "left", | |
| 1414 | H: "home", | |
| 1415 | F: "end", | |
| 1416 | P: "f1", | |
| 1417 | Q: "f2", | |
| 1418 | S: "f4", | |
| 1419 | }; | |
| 1420 | ||
| 1421 | const ss3Keys: Partial<Record<string, string>> = { ...csiKeys, R: "f3" }; | |
| 1422 | ||
| 1423 | const linuxKeys: Partial<Record<string, string>> = { | |
| 1424 | A: "f1", | |
| 1425 | B: "f2", | |
| 1426 | C: "f3", | |
| 1427 | D: "f4", | |
| 1428 | E: "f5", | |
| 1429 | }; | |
| 1430 | ||
| 1431 | const tildeKeys: Partial<Record<number, string>> = { | |
| 1432 | 1: "home", | |
| 1433 | 2: "insert", | |
| 1434 | 3: "delete", | |
| 1435 | 4: "end", | |
| 1436 | 5: "pageup", | |
| 1437 | 6: "pagedown", | |
| 1438 | 7: "home", | |
| 1439 | 8: "end", | |
| 1440 | 11: "f1", | |
| 1441 | 12: "f2", | |
| 1442 | 13: "f3", | |
| 1443 | 14: "f4", | |
| 1444 | 15: "f5", | |
| 1445 | 17: "f6", | |
| 1446 | 18: "f7", | |
| 1447 | 19: "f8", | |
| 1448 | 20: "f9", | |
| 1449 | 21: "f10", | |
| 1450 | 23: "f11", | |
| 1451 | 24: "f12", | |
| 1452 | }; | |
| 1453 | ||
| 1454 | /** code points that the kitty protocol assigns to keys without text */ | |
| 1455 | const functionalKeys: Partial<Record<number, string>> = { | |
| 1456 | 9: "tab", | |
| 1457 | 13: "enter", | |
| 1458 | 27: "escape", | |
| 1459 | 32: "space", | |
| 1460 | 127: "backspace", | |
| 1461 | 57414: "enter", | |
| 1462 | 57417: "left", | |
| 1463 | 57418: "right", | |
| 1464 | 57419: "up", | |
| 1465 | 57420: "down", | |
| 1466 | 57421: "pageup", | |
| 1467 | 57422: "pagedown", | |
| 1468 | 57423: "home", | |
| 1469 | 57424: "end", | |
| 1470 | 57425: "insert", | |
| 1471 | 57426: "delete", | |
| 1472 | }; | |
| 1473 | ||
| 1474 | let graphemes: Intl.Segmenter | null = null; | |
| 1475 | ||
| 1476 | function inRange(code: number, min: number, max: number) { | |
| 1477 | return code >= min && code <= max; | |
| 1478 | } | |
| 1479 | ||
| 1480 | function isControlCode(code: number) { | |
| 1481 | return code < 0x20 || inRange(code, 0x7f, 0x9f); | |
| 1482 | } | |
| 1483 | ||
| 1484 | function makeKey(base: string, mods: number, text: string): WidgetInput { | |
| 1485 | const prefix = keyModifiers.map(([bit, name]) => (mods & bit ? name : "")).join(""); | |
| 1486 | return { key: prefix + base, text: mods & ~modShift ? "" : text }; | |
| 1487 | } | |
| 1488 | ||
| 1489 | function namedKey(name: string, mods: number): WidgetInput { | |
| 1490 | // shift+tab goes backwards, which is not a tab to type | |
| 1491 | return makeKey(name, mods, name === "tab" && mods ? "" : (keyText[name] ?? "")); | |
| 1492 | } | |
| 1493 | ||
| 1494 | function printableKey(grapheme: string, mods: number): WidgetInput { | |
| 1495 | if (grapheme === " ") return namedKey("space", mods); | |
| 1496 | if (/^[a-zA-Z]$/.test(grapheme)) { | |
| 1497 | if (mods & ~modShift) { | |
| 1498 | if (grapheme !== grapheme.toLowerCase()) mods |= modShift; | |
| 1499 | grapheme = grapheme.toLowerCase(); | |
| 1500 | } else if (mods) { | |
| 1501 | mods = 0; | |
| 1502 | grapheme = grapheme.toUpperCase(); | |
| 1503 | } | |
| 1504 | } | |
| 1505 | return makeKey(grapheme, mods, grapheme); | |
| 1506 | } | |
| 1507 | ||
| 1508 | /** a C0 control byte, which is how legacy terminals encode ctrl */ | |
| 1509 | function controlKey(code: number, mods: number): WidgetInput | null { | |
| 1510 | if (code === 0x0d) return namedKey("enter", mods); | |
| 1511 | if (code === 0x09) return namedKey("tab", mods); | |
| 1512 | if (code === 0x7f) return namedKey("backspace", mods); | |
| 1513 | if (code === 0x1b) return namedKey("escape", mods); | |
| 1514 | if (code === 0x00) return namedKey("space", mods | modCtrl); | |
| 1515 | if (code > 0x7f) return null; | |
| 1516 | const letter = String.fromCharCode(code + (code <= 0x1a ? 0x60 : 0x40)); | |
| 1517 | return printableKey(letter, mods | modCtrl); | |
| 1518 | } | |
| 1519 | ||
| 1520 | function csiKey(params: string, final: string, mods: number): WidgetInput | null { | |
| 1521 | const fields = params.split(";").map((field) => field.split(":").map(Number)); | |
| 1522 | const first = fields[0]?.[0] || 0; | |
| 1523 | mods |= Math.max(0, (fields[1]?.[0] || 1) - 1) & keyModifierMask; | |
| 1524 | // key releases are only sent if asked for, but are cheap to rule out | |
| 1525 | if (fields[1]?.[1] === 3) return null; | |
| 1526 | ||
| 1527 | if (final === "u" || (final === "~" && first === 27)) { | |
| 1528 | const [code = 0, shifted = 0] = final === "u" ? fields[0]! : (fields[2] ?? []); | |
| 1529 | const name = | |
| 1530 | functionalKeys[code] ?? (inRange(code, 57376, 57398) ? `f${code - 57376 + 13}` : null); | |
| 1531 | if (name) return namedKey(name, mods); | |
| 1532 | // the private use area holds keys that have no name here | |
| 1533 | if (!isCodePoint(code) || isControlCode(code) || inRange(code, 0xe000, 0xf8ff)) return null; | |
| 1534 | const event = | |
| 1535 | isCodePoint(shifted) && shifted && mods === modShift | |
| 1536 | ? printableKey(String.fromCodePoint(shifted), 0) | |
| 1537 | : printableKey(String.fromCodePoint(code), mods); | |
| 1538 | const text = final === "u" ? fields[2] : undefined; | |
| 1539 | if (text?.[0] && text.every(isCodePoint)) event.text = String.fromCodePoint(...text); | |
| 1540 | return event; | |
| 1541 | } | |
| 1542 | ||
| 1543 | if (final === "~") { | |
| 1544 | const name = tildeKeys[first]; | |
| 1545 | return name ? namedKey(name, mods) : null; | |
| 1546 | } | |
| 1547 | if (final === "Z") return namedKey("tab", mods | modShift); | |
| 1548 | // `CSI row ; column R` is a cursor position report | |
| 1549 | if (final === "R") return first <= 1 ? namedKey("f3", mods) : null; | |
| 1550 | const name = csiKeys[final]; | |
| 1551 | return name ? namedKey(name, mods) : null; | |
| 1552 | } | |
| 1553 | ||
| 1554 | /** parameters are whatever digits the terminal sent */ | |
| 1555 | function isCodePoint(code: number) { | |
| 1556 | return Number.isInteger(code) && code <= 0x10ffff; | |
| 1557 | } | |
| 1558 | ||
| 1559 | const logColors = node.process?.stdout.isTTY ?? false; | |
| 1560 | ||
| 1561 | let formatLine = /* @__PURE__ */ (() => { | |
| 1562 | const fwo = node.builtin("util")?.formatWithOptions; | |
| 1563 | if (fwo) { | |
| 1564 | return (...args: unknown[]) => { | |
| 1565 | if (args[0] instanceof Error) return stack.format(args[0], logColors); | |
| 1566 | return fwo({ colors: logColors }, ...args); | |
| 1567 | }; | |
| 1568 | } | |
| 1569 | return (...args: unknown[]) => | |
| 1570 | args | |
| 1571 | .map((arg) => { | |
| 1572 | if (typeof arg === "string") return arg; | |
| 1573 | try { | |
| 1574 | return JSON.stringify(arg); | |
| 1575 | } catch { | |
| 1576 | return "[incompatible json " + typeof arg + "]"; | |
| 1577 | } | |
| 1578 | }) | |
| 1579 | .join(" "); | |
| 1580 | })(); | |
| 1581 | ||
| 1582 | let withinDispatch = false; | |
| 1583 | let withinStackCapture = false; | |
| 1584 | ||
| 1585 | /** this class is an implementation detail */ | |
| 1586 | const ScopeImpl = class Scope implements RootScope { | |
| 1587 | name: string | undefined; | |
| 1588 | /** attached to every emitted message; see `Scope.scoped` */ | |
| 1589 | #custom: Message["custom"]; | |
| 1590 | // TODO: this abstraction implementation has low performance. making every | |
| 1591 | // scope define it's own dispatch is needed to correctly implement `tee`. | |
| 1592 | // since it is possible to implement this in simple and non-recursive way, i | |
| 1593 | // feel okay adding the `tee` API. | |
| 1594 | #dispatches: DispatchFunction[] = []; | |
| 1595 | #dispatch: DispatchFunction = (m) => this.#dispatches.forEach((cb) => cb(m)); | |
| 1596 | ||
| 1597 | constructor( | |
| 1598 | dispatch: (m: Message) => void, | |
| 1599 | name: string | undefined = undefined, | |
| 1600 | custom: Message["custom"] = undefined, | |
| 1601 | ) { | |
| 1602 | this.name = name; | |
| 1603 | this.#custom = custom; | |
| 1604 | this.#dispatches = [dispatch]; | |
| 1605 | } | |
| 1606 | ||
| 1607 | #log(level: Message["level"], args: unknown[]) { | |
| 1608 | let frames; | |
| 1609 | if (!withinStackCapture) { | |
| 1610 | withinStackCapture = true; | |
| 1611 | frames = stack.capture().slice(2); | |
| 1612 | withinStackCapture = false; | |
| 1613 | } | |
| 1614 | this.writeMessage({ | |
| 1615 | level, | |
| 1616 | scope: this.name, | |
| 1617 | custom: this.#custom, | |
| 1618 | get text() { | |
| 1619 | const value = formatLine(...args); | |
| 1620 | Object.defineProperty(this, "text", { value }); | |
| 1621 | return value; | |
| 1622 | }, | |
| 1623 | time: Date.now(), | |
| 1624 | stack: frames, | |
| 1625 | [originalLogArgs]: args, | |
| 1626 | }); | |
| 1627 | } | |
| 1628 | ||
| 1629 | info: (...args: unknown[]) => void = (...args: unknown[]) => { | |
| 1630 | this.#log("info", args); | |
| 1631 | }; | |
| 1632 | warn: (...args: unknown[]) => void = (...args: unknown[]) => { | |
| 1633 | this.#log("warn", args); | |
| 1634 | }; | |
| 1635 | error: (...args: unknown[]) => void = (...args: unknown[]) => { | |
| 1636 | this.#log("error", args); | |
| 1637 | }; | |
| 1638 | log: (...args: unknown[]) => void = (...args: unknown[]) => { | |
| 1639 | this.#log("debug", args); | |
| 1640 | }; | |
| 1641 | debug: (...args: unknown[]) => void = (...args: unknown[]) => { | |
| 1642 | this.#log("debug", args); | |
| 1643 | }; | |
| 1644 | ||
| 1645 | writeMessage: (m: Message) => void = (m) => { | |
| 1646 | if (withinDispatch) return void globalLog.#dispatch(m); | |
| 1647 | withinDispatch = true; | |
| 1648 | this.#dispatch(m); | |
| 1649 | withinDispatch = false; | |
| 1650 | }; | |
| 1651 | ||
| 1652 | write: (text: string) => void = (text) => { | |
| 1653 | let frames; | |
| 1654 | if (!withinStackCapture) { | |
| 1655 | withinStackCapture = true; | |
| 1656 | frames = stack.capture().slice(2); | |
| 1657 | withinStackCapture = false; | |
| 1658 | } | |
| 1659 | this.writeMessage({ | |
| 1660 | level: "info", | |
| 1661 | scope: "", | |
| 1662 | custom: this.#custom, | |
| 1663 | newline: false, | |
| 1664 | text, | |
| 1665 | time: Date.now(), | |
| 1666 | stack: frames, | |
| 1667 | }); | |
| 1668 | }; | |
| 1669 | ||
| 1670 | scoped(name: string, custom?: Message["custom"]): Scope { | |
| 1671 | const current = this.name; | |
| 1672 | return new Scope( | |
| 1673 | this.#dispatch, | |
| 1674 | name ? (current ? `${current}/${name}` : name) : current, | |
| 1675 | custom ? { ...this.#custom, ...custom } : this.#custom, | |
| 1676 | ); | |
| 1677 | } | |
| 1678 | ||
| 1679 | tee(destination: DispatchFunction) { | |
| 1680 | this.#dispatches.push(destination); | |
| 1681 | return ts.defer(() => this.#dispatches.splice(this.#dispatches.indexOf(destination), 1)); | |
| 1682 | } | |
| 1683 | }; | |
| 1684 | ||
| 1685 | /** | |
| 1686 | * global integration is done in a special manner with IIFE expressions to | |
| 1687 | * ensure that the logic is removed when a bundler tree-shakes this file. | |
| 1688 | * additionally, this ensures that replacing a global function properly affects | |
| 1689 | * other installations. this is safe to do because these shared interfaces have | |
| 1690 | * a commitment to never change in a breaking way. | |
| 1691 | */ | |
| 1692 | interface GlobalCommunication { | |
| 1693 | readme: string; | |
| 1694 | /** urls / file paths */ | |
| 1695 | instances: string[]; | |
| 1696 | widget?: { | |
| 1697 | version: number; | |
| 1698 | frozen: boolean; | |
| 1699 | host: WidgetHost; | |
| 1700 | /** url / file path / line number / identifying information */ | |
| 1701 | source: string; | |
| 1702 | }; | |
| 1703 | /** overwrite the message writer */ | |
| 1704 | writeMessage?: DispatchFunction; | |
| 1705 | /** overwrite the message formatter */ | |
| 1706 | formatAnsiMessage?: (m: Message, colors: boolean) => string; | |
| 1707 | /** calls to the global `tee()` */ | |
| 1708 | tees: Set<DispatchFunction>; | |
| 1709 | } | |
| 1710 | ||
| 1711 | const globalSymbol = /* @__PURE__ */ Symbol.for("@clo/lib/log"); | |
| 1712 | const version = 6; | |
| 1713 | let global: GlobalCommunication = /* @__PURE__ */ (() => { | |
| 1714 | const global = ((globalThis as { [globalSymbol]?: GlobalCommunication })[globalSymbol] ??= { | |
| 1715 | readme: | |
| 1716 | 'this global holds shared state for "@clo/lib/log", to allow different instances of itself to coordinate with each other', | |
| 1717 | instances: [], | |
| 1718 | tees: new Set(), | |
| 1719 | }); | |
| 1720 | global.instances.push(import.meta.url); | |
| 1721 | return global; | |
| 1722 | })(); | |
| 1723 | ||
| 1724 | function defaultFormatAnsiMessage({ level, scope, text, newline }: Message, colors: boolean) { | |
| 1725 | if (!text) return ""; | |
| 1726 | if (newline === false) return text; | |
| 1727 | const prefix = colors | |
| 1728 | ? // colorful | |
| 1729 | `${levelToAnsi[level ?? "info"]}${scope ? `(${scope})` : ""}${ansi.fgReset}${ansi.dim}:${ansi.reset} ` | |
| 1730 | : // colorless | |
| 1731 | scope | |
| 1732 | ? `${level}(${scope}): ` | |
| 1733 | : `${level}: `; | |
| 1734 | return prefix + text + "\n"; | |
| 1735 | } | |
| 1736 | ||
| 1737 | let initWidgetHost = false; | |
| 1738 | function globalWidgetHost(): WidgetHost { | |
| 1739 | if ( | |
| 1740 | global.widget && | |
| 1741 | (initWidgetHost || | |
| 1742 | global.widget.frozen || | |
| 1743 | // By default, pick the latest version of the widget host implementation. | |
| 1744 | // This is most likely to resolve the most issues as possible. To opt out of | |
| 1745 | // this, import the desired implementation and call its | |
| 1746 | // `ensureGlobalsArePatched` or `replaceGlobalWidgetHost` method. | |
| 1747 | global.widget.version >= version) | |
| 1748 | ) { | |
| 1749 | initWidgetHost = true; | |
| 1750 | return global.widget.host; | |
| 1751 | } | |
| 1752 | initWidgetHost = true; | |
| 1753 | global.widget = { | |
| 1754 | frozen: false, | |
| 1755 | host: node.process ? defaultNodeProcessWidgetHost(node.process) : defaultFallbackWidgetHost(), | |
| 1756 | source: import.meta.url, | |
| 1757 | version, | |
| 1758 | }; | |
| 1759 | return global.widget.host; | |
| 1760 | } | |
| 1761 | ||
| 1762 | export function defaultNodeProcessWidgetHost( | |
| 1763 | process: NonNullable<typeof node.process>, | |
| 1764 | forceWidgetSupport = false, | |
| 1765 | ): WidgetHost { | |
| 1766 | // fallback | |
| 1767 | if (!forceWidgetSupport && !process.stderr.isTTY) { | |
| 1768 | return { | |
| 1769 | writeOutput: (string) => process.stdout.write(string), | |
| 1770 | writeError: (string) => process.stderr.write(string), | |
| 1771 | getDrawLock: () => ({ | |
| 1772 | release() {}, | |
| 1773 | [Symbol.dispose]() {}, | |
| 1774 | }), | |
| 1775 | startWidget: () => null, | |
| 1776 | cancel: () => {}, | |
| 1777 | capabilities: [], | |
| 1778 | }; | |
| 1779 | } | |
| 1780 | ||
| 1781 | const host = createTerminalWidgetHost({ | |
| 1782 | lockTerminal() { | |
| 1783 | const { stdout, stderr } = process; | |
| 1784 | let disposed = false; | |
| 1785 | ||
| 1786 | // patch calls to `process.std{out,err}` | |
| 1787 | // note: `pipe` uses managed calls to `write`, so this is plenty | |
| 1788 | const stdoutWrite = stdout.write; | |
| 1789 | const stderrWrite = stderr.write; | |
| 1790 | const stdoutEnd = stdout.end; | |
| 1791 | const stderrEnd = stderr.end; | |
| 1792 | function patchWriteMethod<T, R, A extends [string | Uint8Array]>( | |
| 1793 | fn: (this: T, ...args: A) => R, | |
| 1794 | touchesScreen: boolean, | |
| 1795 | ) { | |
| 1796 | return function (this: T, ...args: A) { | |
| 1797 | using lock = disposed ? null : host.getDrawLock("short"); | |
| 1798 | const ret = fn.apply(this, args); | |
| 1799 | if (lock) { | |
| 1800 | // writes to a redirected stream never move the screen cursor | |
| 1801 | lock.release( | |
| 1802 | !touchesScreen || | |
| 1803 | (typeof args[0] === "string" | |
| 1804 | ? args[0].endsWith("\n") | |
| 1805 | : bufferEndsInNewline(args[0])) | |
| 1806 | ? "cursor-start-of-line" | |
| 1807 | : "cursor-middle-of-line", | |
| 1808 | ); | |
| 1809 | } | |
| 1810 | return ret; | |
| 1811 | }; | |
| 1812 | } | |
| 1813 | const newStdoutWrite = (stdout.write = patchWriteMethod(stdoutWrite, stdout.isTTY)); | |
| 1814 | const newStderrWrite = (stderr.write = patchWriteMethod(stderrWrite, true)); | |
| 1815 | function patchEndMethod<T, R, A extends unknown[]>(fn: (this: T, ...args: A) => R) { | |
| 1816 | return function (this: T, ...args: A) { | |
| 1817 | using lock = disposed ? null : host.getDrawLock("short"); | |
| 1818 | const ret = fn.apply(this, args); | |
| 1819 | // TODO: this is not handled correctly, but people rarely close their | |
| 1820 | // standard I/O! this likely should just disable the library if you | |
| 1821 | // call end. i'm not particularly worried. | |
| 1822 | if (lock) lock.release("cursor-start-of-line"); | |
| 1823 | return ret; | |
| 1824 | }; | |
| 1825 | } | |
| 1826 | const newStdoutEnd = (stdout.end = patchEndMethod(stdoutEnd)); | |
| 1827 | const newStderrEnd = (stderr.end = patchEndMethod(stderrEnd)); | |
| 1828 | ||
| 1829 | // non-node runtimes will typically implement console in a way that | |
| 1830 | // doesn't use `node:process`, so it must also get patched. this is | |
| 1831 | // okay because the lock is re-enterant. | |
| 1832 | function patchSyncMethod<T, A extends unknown[]>(fn: (this: T, ...args: A) => void) { | |
| 1833 | return function (this: T, ...args: A) { | |
| 1834 | using lock = disposed ? null : host.getDrawLock("short"); | |
| 1835 | fn.apply(this, args); | |
| 1836 | if (lock) lock.release("cursor-start-of-line"); | |
| 1837 | }; | |
| 1838 | } | |
| 1839 | const console = globalThis.console as Console & Record<string, () => void>; | |
| 1840 | const restoreConsole: [string, old: () => void, patch: () => void][] = []; | |
| 1841 | for (const [key, old] of Object.entries(console)) { | |
| 1842 | if (typeof old !== "function") continue; | |
| 1843 | try { | |
| 1844 | const patched = (console[key] = patchSyncMethod(old)); | |
| 1845 | restoreConsole.push([key, old, patched]); | |
| 1846 | } catch { | |
| 1847 | /* skip */ | |
| 1848 | } | |
| 1849 | } | |
| 1850 | ||
| 1851 | return { | |
| 1852 | // log output goes to real stdout so it stays redirectable; the | |
| 1853 | // `outputSharesScreen` option tells the host whether those rows | |
| 1854 | // land on the widget screen and must be counted by the cursor math | |
| 1855 | writeOutput: (string) => stdoutWrite.call(stdout, string), | |
| 1856 | writeInteractive: (string) => stderrWrite.call(stderr, string), | |
| 1857 | observeSize(callback) { | |
| 1858 | const emit = () => | |
| 1859 | callback({ | |
| 1860 | columns: stderr.columns ?? 80, | |
| 1861 | rows: stderr.rows ?? 24, | |
| 1862 | }); | |
| 1863 | emit(); | |
| 1864 | stderr.addListener("resize", emit); | |
| 1865 | return () => void stderr.removeListener("resize", emit); | |
| 1866 | }, | |
| 1867 | temporaryUnlock() { | |
| 1868 | // no action needed: the patched write methods re-enter the draw | |
| 1869 | // lock, which is re-entrant, so external writes flow correctly | |
| 1870 | // while the lock is held. | |
| 1871 | return () => {}; | |
| 1872 | }, | |
| 1873 | close() { | |
| 1874 | disposed = true; | |
| 1875 | // leave patches in place if something else tampered with it. | |
| 1876 | if (stdout.write === newStdoutWrite) stdout.write = stdoutWrite; | |
| 1877 | if (stderr.write === newStderrWrite) stderr.write = stderrWrite; | |
| 1878 | if (stdout.end === newStdoutEnd) stdout.end = stdoutEnd; | |
| 1879 | if (stderr.end === newStderrEnd) stderr.end = stderrEnd; | |
| 1880 | for (const [key, old, patched] of restoreConsole) { | |
| 1881 | if (console[key] === patched) console[key] = old; | |
| 1882 | } | |
| 1883 | }, | |
| 1884 | }; | |
| 1885 | }, | |
| 1886 | now: () => performance.now(), | |
| 1887 | delay: async.delay, | |
| 1888 | color: process.stderr.isTTY, | |
| 1889 | outputSharesScreen: process.stdout.isTTY, | |
| 1890 | observeInput: nodeProcessInput(process, (mode) => host.getDrawLock(mode)), | |
| 1891 | }); | |
| 1892 | process.addListener("beforeExit", () => host.cancel()); | |
| 1893 | process.addListener("exit", () => host.cancel()); | |
| 1894 | return host; | |
| 1895 | } | |
| 1896 | ||
| 1897 | /** reads `process.stdin` in raw mode, if it is a terminal */ | |
| 1898 | function nodeProcessInput( | |
| 1899 | process: NonNullable<typeof node.process>, | |
| 1900 | getDrawLock: WidgetHost["getDrawLock"], | |
| 1901 | ): TerminalWidgetHostOptions["observeInput"] { | |
| 1902 | // asking `process.stdin` would open the stream for every program | |
| 1903 | if (!node.builtin("tty")?.isatty(0)) return undefined; | |
| 1904 | ||
| 1905 | return (callback) => { | |
| 1906 | const { stdin } = process; | |
| 1907 | const decoder = new TextDecoder(); | |
| 1908 | const onData = (chunk: Uint8Array | string) => | |
| 1909 | callback(typeof chunk === "string" ? chunk : decoder.decode(chunk, { stream: true })); | |
| 1910 | ||
| 1911 | // dying to a signal skips the exit handlers, which would leave the | |
| 1912 | // terminal reading input. a long draw lock puts the terminal back, and | |
| 1913 | // also removes these listeners, so that listeners running after them | |
| 1914 | // (and the check below) see what the program itself installed. | |
| 1915 | const signals = ["SIGINT", "SIGTERM"].map((signal) => ({ | |
| 1916 | signal, | |
| 1917 | listener() { | |
| 1918 | const lock = getDrawLock("long"); | |
| 1919 | if (process.listenerCount(signal) === 0) process.kill(process.pid, signal); | |
| 1920 | async.delay(0).then(() => lock.release("cursor-start-of-line")); | |
| 1921 | }, | |
| 1922 | })); | |
| 1923 | ||
| 1924 | const wasRaw = stdin.isRaw; | |
| 1925 | stdin.setRawMode(true); | |
| 1926 | stdin.addListener("data", onData); | |
| 1927 | // a listener alone does not restart a stream that was paused, which is | |
| 1928 | // how the previous reader left it | |
| 1929 | stdin.resume(); | |
| 1930 | for (const { signal, listener } of signals) process.prependListener(signal, listener); | |
| 1931 | ||
| 1932 | return () => { | |
| 1933 | for (const { signal, listener } of signals) process.removeListener(signal, listener); | |
| 1934 | stdin.removeListener("data", onData); | |
| 1935 | stdin.setRawMode(wasRaw); | |
| 1936 | // a flowing stdin keeps the process alive | |
| 1937 | if (stdin.listenerCount("data") === 0) stdin.pause(); | |
| 1938 | }; | |
| 1939 | }; | |
| 1940 | } | |
| 1941 | ||
| 1942 | function defaultFallbackWidgetHost(): WidgetHost { | |
| 1943 | let warned = false; | |
| 1944 | return { | |
| 1945 | writeOutput: (line: string) => console.log(ansi.strip(line)), | |
| 1946 | writeError: (line: string) => console.error(ansi.strip(line)), | |
| 1947 | getDrawLock: () => ({ [Symbol.dispose]() {}, release() {} }), | |
| 1948 | startWidget: (options) => { | |
| 1949 | if (!warned) { | |
| 1950 | console.warn( | |
| 1951 | '"@clo/lib/log"\'s startWidget was called in an environment ' + | |
| 1952 | "that does not support the Node.js 'process' API. Widgets " + | |
| 1953 | "will not be visible.", | |
| 1954 | ); | |
| 1955 | warned = true; | |
| 1956 | } | |
| 1957 | return { | |
| 1958 | options, | |
| 1959 | fps: options.fps ?? null, | |
| 1960 | redraw() {}, | |
| 1961 | stop() {}, | |
| 1962 | [Symbol.dispose]() {}, | |
| 1963 | }; | |
| 1964 | }, | |
| 1965 | cancel: () => {}, | |
| 1966 | capabilities: [], | |
| 1967 | }; | |
| 1968 | } | |
| 1969 | ||
| 1970 | /** | |
| 1971 | * returns a WidgetHost from `node:process`, but without patching its methods. | |
| 1972 | * as it leaves the process alone, it does not read the keyboard either. | |
| 1973 | */ | |
| 1974 | export function simpleNodeProcessWidgetHost( | |
| 1975 | process: NonNullable<typeof node.process>, | |
| 1976 | forceWidgetSupport = false, | |
| 1977 | ): WidgetHost { | |
| 1978 | return forceWidgetSupport || process.stderr.isTTY | |
| 1979 | ? createTerminalWidgetHost({ | |
| 1980 | lockTerminal: () => ({ | |
| 1981 | writeOutput: (string) => process.stdout.write(string), | |
| 1982 | writeInteractive: (string) => process.stderr.write(string), | |
| 1983 | observeSize(callback) { | |
| 1984 | const emit = () => | |
| 1985 | callback({ | |
| 1986 | columns: process.stderr.columns ?? 80, | |
| 1987 | rows: process.stderr.rows ?? 24, | |
| 1988 | }); | |
| 1989 | emit(); | |
| 1990 | process.stderr.addListener("resize", emit); | |
| 1991 | return () => void process.stderr.removeListener("resize", emit); | |
| 1992 | }, | |
| 1993 | close() { | |
| 1994 | // no action needed | |
| 1995 | }, | |
| 1996 | }), | |
| 1997 | now: () => performance.now(), | |
| 1998 | delay: async.delay, | |
| 1999 | color: process.stderr.isTTY, | |
| 2000 | outputSharesScreen: process.stdout.isTTY, | |
| 2001 | }) | |
| 2002 | : { | |
| 2003 | writeOutput: (string) => process.stdout.write(string), | |
| 2004 | writeError: (string) => process.stderr.write(string), | |
| 2005 | getDrawLock: () => ({ | |
| 2006 | release() {}, | |
| 2007 | [Symbol.dispose]() {}, | |
| 2008 | }), | |
| 2009 | startWidget: () => null, | |
| 2010 | cancel: () => {}, | |
| 2011 | capabilities: [], | |
| 2012 | }; | |
| 2013 | } | |
| 2014 | ||
| 2015 | function bufferEndsInNewline(buffer: ArrayBufferView | undefined) { | |
| 2016 | return buffer | |
| 2017 | ? buffer instanceof Uint8Array | |
| 2018 | ? buffer.at(-1) === 0xa | |
| 2019 | : new Uint8Array(buffer.buffer, buffer.byteOffset, buffer.byteLength).at(-1) === 0xa | |
| 2020 | : false; | |
| 2021 | } | |
| 2022 | ||
| 2023 | const levelToAnsi: Record<MessageLevel, string> = { | |
| 2024 | info: `${ansi.fgBlue}info`, | |
| 2025 | warn: `${ansi.fgYellow}warn`, | |
| 2026 | error: `${ansi.fgRed}error`, | |
| 2027 | debug: `${ansi.dim}dbg`, | |
| 2028 | }; | |
| 2029 | ||
| 2030 | const globalLog = /* @__PURE__ */ (() => | |
| 2031 | new ScopeImpl((m) => { | |
| 2032 | if (global.writeMessage) { | |
| 2033 | global.writeMessage(m); | |
| 2034 | } else if (node.process) { | |
| 2035 | // info/debug levels land on stdout, warnings and errors on stderr; | |
| 2036 | // colors are keyed off the destination stream | |
| 2037 | const err = (m.level ?? "info") !== "info" && m.level !== "debug"; | |
| 2038 | globalWidgetHost()[err ? "writeError" : "writeOutput"]( | |
| 2039 | formatAnsiMessage(m, (err ? node.process.stderr : node.process.stdout).isTTY), | |
| 2040 | ); | |
| 2041 | } else { | |
| 2042 | let { level = "info", [originalLogArgs]: args = [m.text], scope } = m; | |
| 2043 | if (scope) { | |
| 2044 | const arg0 = args[0]; | |
| 2045 | const prefix = `[${scope}]`; | |
| 2046 | if (typeof arg0 === "string") args[0] = `${prefix} ${arg0}`; | |
| 2047 | else args.unshift(prefix); | |
| 2048 | } | |
| 2049 | console[level](...args); | |
| 2050 | } | |
| 2051 | global.tees.forEach((cb) => cb(m)); | |
| 2052 | }))(); | |
| 2053 | ||
| 2054 | export type DispatchFunction = (message: Message) => void; | |
| 2055 | /** | |
| 2056 | * agnostic to the backend. should be able to format `newline: false` | |
| 2057 | * messages without a trailing newline, but not all backends may | |
| 2058 | * support this. | |
| 2059 | * | |
| 2060 | * TODO: change this to a `write`+`flush` pattern? | |
| 2061 | */ | |
| 2062 | export type MessageFormatFunction = (message: Message, colors: boolean) => string; | |
| 2063 | ||
| 2064 | import { ASSERT, UNWRAP } from "./assert.ts"; | |
| 2065 | import * as async from "./async.ts"; | |
| 2066 | import * as exceptions from "./exception.ts"; | |
| 2067 | import * as stack from "./log/stack.ts"; | |
| 2068 | import * as node from "./node.ts"; | |
| 2069 | import * as ansi from "./string/ansi.ts"; | |
| 2070 | import * as ts from "./ts.ts"; |
packages/clo-lib/log/stack.ts created+432| ... | ... | @@ -0,0 +1,432 @@ |
| 1 | /** | |
| 2 | * the default stack trace formatters in node.js, deno, and bun are all | |
| 3 | * extremely low quality. they don't highlight the most important information | |
| 4 | * and spend too much space on showing source code. this module provides tools | |
| 5 | * to {@linkcode parse} and {@linkcode format} errors. see {@linkcode format}'s | |
| 6 | * comment for design decisions. | |
| 7 | * | |
| 8 | * to get a taste of the formatter: | |
| 9 | * ```ts | |
| 10 | * try { | |
| 11 | * processFile(undefined); | |
| 12 | * } catch (err) { | |
| 13 | * console.error(stack.parse(err)); | |
| 14 | * console.error(stack.format(err, true)); | |
| 15 | * } | |
| 16 | * | |
| 17 | * function processFile(file) { | |
| 18 | * return fs.createReadStream(file); | |
| 19 | * } | |
| 20 | * | |
| 21 | * import * as fs from "node:fs"; | |
| 22 | * import * as stack from "@clo/lib/log/stack"; | |
| 23 | * ``` | |
| 24 | * | |
| 25 | * @module | |
| 26 | */ | |
| 27 | ||
| 28 | /** retrieved from `parse` */ | |
| 29 | export interface Frame { | |
| 30 | /** `null` if top-level code or unnamed function. */ | |
| 31 | fn: string | null; | |
| 32 | /** `null` if from a built in without a module name. */ | |
| 33 | file: string | null; | |
| 34 | /** `null` if the source provider doesn't have a source location. */ | |
| 35 | line: number | null; | |
| 36 | /** almost certainly defined if `line` is, but the runtime may omit it */ | |
| 37 | col: number | null; | |
| 38 | } | |
| 39 | ||
| 40 | /** | |
| 41 | * parse a error stack trace from either v8 (node, deno, chrome), | |
| 42 | * jsc (safari, bun), or spidermonkey (firefox). | |
| 43 | * | |
| 44 | * this function is derived from clover's work on bun's DevServer, which she | |
| 45 | * derived from stacktracejs. this file is more refined than those two: | |
| 46 | * https://github.com/oven-sh/bun/blob/b5f31a6ee2f52ea67eabeb61f6e6e71215d55b26/src/bake/client/stack-trace.ts | |
| 47 | * https://github.com/stacktracejs/error-stack-parser/blob/9f33c224b5d7b607755eb277f9d51fcdb7287e24/error-stack-parser.js | |
| 48 | * | |
| 49 | * supports parsing v8, JavaScriptCore, SpiderMonkey, and IE error stack | |
| 50 | * frames, effectively working in every JavaScript environment. leaves | |
| 51 | * filesystem urls and paths intact (won't convert 'file:///' to or from a file | |
| 52 | * path) | |
| 53 | */ | |
| 54 | export function parse(error: Error | string): Frame[] | null { | |
| 55 | const stack = (error as Error)?.stack ?? error; | |
| 56 | if (typeof stack !== "string") return null; | |
| 57 | // a v8 trace begins with the message, which is all there is without frames | |
| 58 | if (typeof error !== "string" && stack === String(error)) return []; | |
| 59 | return stack.match(regexV8Stack) ? parseV8OrIe(stack) : parseJscOrSpidermonkey(stack); | |
| 60 | } | |
| 61 | ||
| 62 | /** | |
| 63 | * capture and store for later, or use to trace the caller. with the | |
| 64 | * default `trimStart` of `2`, the caller file is `capture()[0]?.file`. | |
| 65 | */ | |
| 66 | export function capture(trimStart = 2): Frame[] { | |
| 67 | return parse(new Error())?.slice(trimStart) ?? []; | |
| 68 | } | |
| 69 | ||
| 70 | /** an error with a method by this key prints itself, given whether to use colors */ | |
| 71 | export const formatSymbol: unique symbol = Symbol.for("@clo/lib/log/stack.format"); | |
| 72 | ||
| 73 | /** | |
| 74 | * convert an error into a human readable and debuggable string. | |
| 75 | * | |
| 76 | * this printer puts the error metadata at the top. each stack frame gets two | |
| 77 | * lines: the absolute path or url, and then one line of source code. having | |
| 78 | * five lines of context on just the top frame is useless, since five lines is | |
| 79 | * often not enough context to understand the entire block of code. one line is | |
| 80 | * all you need to remind yourself what the code on that line was doing. and if | |
| 81 | * you can see every frame's source code, you can much more easily figure out | |
| 82 | * what the bug is before opening the editor. | |
| 83 | * | |
| 84 | * to make understanding runtime code easier, `node:*` and `ext:deno` paths are | |
| 85 | * resolved into GitHub source code URLs, and node builtins will also display | |
| 86 | * their source code inline (via `node:fs` and `process.binding("natives")`). | |
| 87 | * | |
| 88 | * with zero external imports, it is safely included as a default error | |
| 89 | * formatter. it is used not only in `lib/log.ts`, but also in the headless | |
| 90 | * logger. | |
| 91 | */ | |
| 92 | export function format(error: Error, colors = false): string { | |
| 93 | if (formatSymbol in error) { | |
| 94 | return (error as Error & { [formatSymbol](colors: boolean): string })[formatSymbol](colors); | |
| 95 | } | |
| 96 | const { name, message, stack: _, ...payload } = error; | |
| 97 | ||
| 98 | let out = | |
| 99 | (colors ? ansi.fgRed : "") + | |
| 100 | (name && name !== "Error" && name !== "TypeError" ? `[${name}] ` : "") + | |
| 101 | message + | |
| 102 | (colors ? ansi.fgReset : "") + | |
| 103 | "\n"; | |
| 104 | ||
| 105 | out += Object.entries(payload) | |
| 106 | .map( | |
| 107 | ([k, v]) => | |
| 108 | " " + k + (colors ? ansi.style(ansi.dim, ":") : ":") + " " + JSON.stringify(v) + "\n", | |
| 109 | ) | |
| 110 | .join(""); | |
| 111 | ||
| 112 | let frames = parse(error) ?? []; | |
| 113 | if (node.isServer && "Bun" in globalThis) { | |
| 114 | frames = frames.filter( | |
| 115 | (frame) => | |
| 116 | frame.file !== "native" || | |
| 117 | !["moduleEvaluation", "loadAndEvaluateModule", "processTicksAndRejections"].includes( | |
| 118 | frame.fn ?? "", | |
| 119 | ), | |
| 120 | ); | |
| 121 | } | |
| 122 | if (error instanceof AggregateError) { | |
| 123 | for (const err of error.errors) { | |
| 124 | out += format(err, colors); | |
| 125 | } | |
| 126 | } | |
| 127 | if (frames.length) { | |
| 128 | out += frames.map((frame) => formatFrame(frame, colors)).join("\n") + "\n"; | |
| 129 | } | |
| 130 | ||
| 131 | return out; | |
| 132 | } | |
| 133 | ||
| 134 | /** format a single stack trace frame. see `format` for more details. */ | |
| 135 | export function formatFrame(frame: Frame, colors: boolean): string { | |
| 136 | let { fn, file, line, col } = frame; | |
| 137 | let out = ""; | |
| 138 | if (file === "native") file = null; | |
| 139 | const internal = | |
| 140 | !file || | |
| 141 | (node.isServer && | |
| 142 | (file.startsWith("node:") || | |
| 143 | file.startsWith("bun:") || | |
| 144 | file.startsWith("internal:") || | |
| 145 | file.startsWith("ext:"))); | |
| 146 | ||
| 147 | if (file) { | |
| 148 | if (file.startsWith("file://")) file = new URL(file).pathname; | |
| 149 | const root = node.isServer ? getPackageRoot(file) : null; | |
| 150 | ||
| 151 | // filename | |
| 152 | if ( | |
| 153 | node.isServer && | |
| 154 | internal && | |
| 155 | // all bun internals do not line map | |
| 156 | ("Bun" in globalThis || | |
| 157 | // deno transpiled internals do not line map | |
| 158 | ("Deno" in globalThis && root && root[2].endsWith(".ts"))) | |
| 159 | ) { | |
| 160 | line = 0; | |
| 161 | } | |
| 162 | ||
| 163 | if (colors) { | |
| 164 | if (root) { | |
| 165 | const [pathTo, project] = root; | |
| 166 | out += | |
| 167 | ansi.dim + | |
| 168 | pathTo + | |
| 169 | ansi.mergeStyles(ansi.resetWeight + ansi.fgCyan) + | |
| 170 | project + | |
| 171 | ansi.fgReset; | |
| 172 | } else if (internal) { | |
| 173 | out += ansi.dim; | |
| 174 | } | |
| 175 | out += root?.[2] ?? file; | |
| 176 | } else out += root ? root.join("") : file; | |
| 177 | ||
| 178 | // location | |
| 179 | if (line) { | |
| 180 | if (colors) out += ansi.mergeStyles(ansi.dim + ansi.fgYellow); | |
| 181 | if (root?.[0]?.match(/^https:\/\/github.com/)) { | |
| 182 | out += "#L" + line; | |
| 183 | } else { | |
| 184 | out += ":"; | |
| 185 | out += line; | |
| 186 | if (line) { | |
| 187 | out += ":"; | |
| 188 | out += col; | |
| 189 | } | |
| 190 | } | |
| 191 | if (colors) { | |
| 192 | out += fn ? ansi.fgReset : ansi.mergeStyles(ansi.fgReset + ansi.resetWeight); | |
| 193 | } | |
| 194 | } else if (fn) { | |
| 195 | out += ansi.dim; | |
| 196 | } | |
| 197 | } else { | |
| 198 | if (colors) out += ansi.dim; | |
| 199 | out += "[unknown source]"; | |
| 200 | if (colors && !fn) out += ansi.resetWeight; | |
| 201 | } | |
| 202 | ||
| 203 | if (fn) { | |
| 204 | if (!colors) { | |
| 205 | out += " at " + fn; | |
| 206 | } else if (internal) { | |
| 207 | out += " at " + fn + ansi.resetWeight; | |
| 208 | } else { | |
| 209 | out += " at " + ansi.mergeStyles(ansi.resetWeight + ansi.fgCyan) + fn + ansi.fgReset; | |
| 210 | } | |
| 211 | } | |
| 212 | ||
| 213 | preview: if (node.isServer) { | |
| 214 | if (!file || !line) break preview; | |
| 215 | const code = node.isServer ? getSourceCode(file) : undefined; | |
| 216 | if (!code) break preview; | |
| 217 | const text = code[line - 1]; | |
| 218 | if (!text) break preview; | |
| 219 | out += "\n"; | |
| 220 | if (colors) { | |
| 221 | if (col) { | |
| 222 | // get a selection where the error happened | |
| 223 | let l = col - 1; | |
| 224 | let r = col; | |
| 225 | while (l > 0 && !/[a-zA-Z0-9_$\s]/.test(text[l]!)) l -= 1; | |
| 226 | while (l > 1 && /[a-zA-Z0-9_$]/.test(text[l - 1]!)) l -= 1; | |
| 227 | while (r < text.length && /[a-zA-Z0-9_$]/.test(text[r]!)) r += 1; | |
| 228 | // expand to special cases | |
| 229 | let center = text.slice(l, r); | |
| 230 | if (center === "new") { | |
| 231 | r += text.slice(r).match(/\s*[a-zA-Z0-9_$]+/)?.[0]?.length ?? 0; | |
| 232 | } | |
| 233 | // print it all red, but use dim and bold to highlight the problem | |
| 234 | const left = text.slice(0, l); | |
| 235 | center = text.slice(l, r); | |
| 236 | const right = text.slice(r); | |
| 237 | const irrelevant = internal || file?.includes("node_modules"); | |
| 238 | const fg = !irrelevant ? ansi.fgRed : ""; | |
| 239 | const bold = !irrelevant ? ansi.bold : ansi.dim + ansi.bold; | |
| 240 | out += | |
| 241 | (left | |
| 242 | ? ansi.mergeStyles(fg + ansi.dim) + left + ansi.mergeStyles(ansi.resetWeight + bold) | |
| 243 | : ansi.mergeStyles(fg + bold)) + | |
| 244 | center + | |
| 245 | (right ? ansi.mergeStyles(ansi.resetWeight + ansi.dim) + right : "") + | |
| 246 | ansi.mergeStyles(ansi.resetWeight + ansi.fgReset); | |
| 247 | } else { | |
| 248 | out += text; | |
| 249 | } | |
| 250 | } else { | |
| 251 | out += text; | |
| 252 | } | |
| 253 | } | |
| 254 | ||
| 255 | return out; | |
| 256 | } | |
| 257 | ||
| 258 | const existCache = new Map<string, boolean>(); | |
| 259 | function getPackageRoot(absPath: string) { | |
| 260 | const fs = node.builtin("fs"); | |
| 261 | const path = node.builtin("path"); | |
| 262 | const process = node.process; | |
| 263 | if (!fs || !path || !process) return null; | |
| 264 | ||
| 265 | const isNodeBuiltin = absPath.startsWith("node:"); | |
| 266 | if ("Bun" in globalThis && (isNodeBuiltin || absPath.startsWith("internal:"))) { | |
| 267 | return [ | |
| 268 | `https://github.com/oven-sh/bun/blob/bun-v${process.versions.bun}/src/js/`, | |
| 269 | "", | |
| 270 | absPath.replace(":", "/") + ".ts", | |
| 271 | ]; | |
| 272 | } | |
| 273 | if (isNodeBuiltin) { | |
| 274 | if ("Deno" in globalThis) { | |
| 275 | return [ | |
| 276 | `https://github.com/denoland/deno/blob/v${process.versions.deno}/ext/node/polyfills/`, | |
| 277 | "", | |
| 278 | absPath.slice(5) + ".js", | |
| 279 | ]; | |
| 280 | } | |
| 281 | return [ | |
| 282 | `https://github.com/nodejs/node/blob/${process.versions.node}/lib/`, | |
| 283 | "", | |
| 284 | absPath.slice(5) + ".js", | |
| 285 | ]; | |
| 286 | } | |
| 287 | if ( | |
| 288 | absPath.startsWith("ext:deno_") && | |
| 289 | process.versions.deno && | |
| 290 | // these files are transpiled without source maps. | |
| 291 | !absPath.endsWith(".ts.js") | |
| 292 | ) { | |
| 293 | const node = absPath.startsWith("ext:deno_node"); | |
| 294 | const filename = node | |
| 295 | ? absPath.slice("ext:deno_node/".length) | |
| 296 | : absPath.slice("ext:deno_".length); | |
| 297 | return [ | |
| 298 | `https://github.com/denoland/deno/blob/v${process.versions.deno}/ext/` + | |
| 299 | (node ? "node/polyfills/" : ""), | |
| 300 | "", | |
| 301 | node && filename.endsWith(".ts.js") ? filename.slice(0, -3) : filename, | |
| 302 | ]; | |
| 303 | } | |
| 304 | ||
| 305 | if (!path.isAbsolute(absPath)) return null; | |
| 306 | let dir = path.dirname(absPath); | |
| 307 | do { | |
| 308 | let exists = existCache.get(dir); | |
| 309 | if (exists == null) { | |
| 310 | existCache.set( | |
| 311 | dir, | |
| 312 | (exists = | |
| 313 | fs.existsSync(path.join(dir, "package.json")) || | |
| 314 | fs.existsSync(path.join(dir, "deno.json"))), | |
| 315 | ); | |
| 316 | } | |
| 317 | if (exists) { | |
| 318 | return [path.dirname(dir) + path.sep, path.basename(dir), absPath.slice(dir.length)] as const; | |
| 319 | } | |
| 320 | } while (dir !== (dir = path.dirname(dir))); | |
| 321 | return ""; | |
| 322 | } | |
| 323 | ||
| 324 | const codeCache = new Map<string, string[]>(); | |
| 325 | function getSourceCode(file: string) { | |
| 326 | const fs = node.builtin("fs"); | |
| 327 | const path = node.builtin("path"); | |
| 328 | if (!fs || !path) return null; | |
| 329 | if (file.startsWith("node:")) { | |
| 330 | // node.js has a secret binding for all the internal source codes | |
| 331 | return node.binding("natives")?.[file.slice(5)]?.split("\n") ?? null; | |
| 332 | } else { | |
| 333 | // bun and deno do not offer a way to get source code of internal modules, | |
| 334 | // probably for good reason. | |
| 335 | // | |
| 336 | // i originally had an idea to download and transpile the version to | |
| 337 | // provide a reference and source code, but it's undocumented how deno | |
| 338 | // compiles their code and i don't think bun's line numbers actually remap | |
| 339 | // correctly. | |
| 340 | } | |
| 341 | if (!path.isAbsolute(file)) return null; | |
| 342 | let cache = codeCache.get(file); | |
| 343 | if (cache == null) { | |
| 344 | try { | |
| 345 | codeCache.set(file, (cache = fs.readFileSync(file, "utf-8").split("\n"))); | |
| 346 | } catch {} | |
| 347 | } | |
| 348 | return cache; | |
| 349 | } | |
| 350 | ||
| 351 | function parseV8OrIe(stack: string): Frame[] { | |
| 352 | return stack | |
| 353 | .split("\n") | |
| 354 | .filter((line) => !!line.match(regexV8Stack)) | |
| 355 | .map(function (line) { | |
| 356 | let sanitizedLine = line | |
| 357 | .replace(/^\s+/, "") | |
| 358 | .replace(/\(eval code/g, "(") | |
| 359 | .replace(/^.*?\s+/, ""); | |
| 360 | ||
| 361 | // capture and preserve the parenthesized location "(/foo/my bar.js:12:87)" in | |
| 362 | // case it has spaces in it, as the string is split on \s+ later on | |
| 363 | const loc = sanitizedLine.match(/ (\(.+\)$)/); | |
| 364 | ||
| 365 | // remove the parenthesized location from the line, if it was matched | |
| 366 | sanitizedLine = loc ? sanitizedLine.replace(loc[0], "") : sanitizedLine; | |
| 367 | ||
| 368 | // if a location was matched, pass it to extractLocation() otherwise pass all sanitizedLine | |
| 369 | // because this line doesn't have function name | |
| 370 | // an anonymous async frame is "async <location>" | |
| 371 | const locationParts = extractLocation(loc?.[1] ?? sanitizedLine.replace(/^async /, "")); | |
| 372 | const functionName = (loc && sanitizedLine) || undefined; | |
| 373 | const fileName = | |
| 374 | ["eval", "<anonymous>"].indexOf(locationParts[0]) > -1 ? undefined : locationParts[0]; | |
| 375 | ||
| 376 | return { | |
| 377 | fn: functionName ?? null, | |
| 378 | file: fileName, | |
| 379 | line: 0 | locationParts[1], | |
| 380 | col: 0 | locationParts[2], | |
| 381 | } satisfies Frame; | |
| 382 | }); | |
| 383 | } | |
| 384 | ||
| 385 | function parseJscOrSpidermonkey(stack: string): Frame[] { | |
| 386 | // Using string literal "\n" does not work in Safari. | |
| 387 | return stack.split(/\n/g).map((source) => { | |
| 388 | let fn: string | null = ""; | |
| 389 | let file: string | null = null; | |
| 390 | let line: number | null = null; | |
| 391 | let col: number | null = null; | |
| 392 | if (source.endsWith("@")) { | |
| 393 | // Safari eval frames only have function names and nothing else | |
| 394 | fn = source.slice(0, -1); | |
| 395 | } else if (source.indexOf("@") === -1 && source.indexOf(":") === -1) { | |
| 396 | // Safari eval frames only have function names and nothing else | |
| 397 | fn = source.endsWith("@") ? source.slice(0, -1) : source; | |
| 398 | } else { | |
| 399 | const matches = source.match(regexFunctionName); | |
| 400 | const functionName = matches && matches[1] ? matches[1] : undefined; | |
| 401 | const locationParts = extractLocation(source.replace(regexFunctionName, "")); | |
| 402 | fn = functionName!; | |
| 403 | file = locationParts[0]; | |
| 404 | line = 0 | locationParts[1]; | |
| 405 | col = 0 | locationParts[2]; | |
| 406 | } | |
| 407 | if (fn === "module code") fn = null; | |
| 408 | return { | |
| 409 | fn, | |
| 410 | file, | |
| 411 | line, | |
| 412 | col, | |
| 413 | }; | |
| 414 | }); | |
| 415 | } | |
| 416 | ||
| 417 | function extractLocation(urlLike: string) { | |
| 418 | // Fail-fast but return locations like "(native)" | |
| 419 | if (urlLike.indexOf(":") === -1) { | |
| 420 | return [urlLike]; | |
| 421 | } | |
| 422 | ||
| 423 | const parts: any = regexLocation.exec(urlLike.replace(/[()]/g, "")); | |
| 424 | return [parts[1], parts[2] || undefined, parts[3] || undefined]; | |
| 425 | } | |
| 426 | ||
| 427 | const regexFunctionName = /((.*".+"[^@]*)?[^@]*)(?:@)/; | |
| 428 | const regexV8Stack = /^\s*at .*(\S+:\d+|\(native\))/m; | |
| 429 | const regexLocation = /(.+?)(?::(\d+))?(?::(\d+))?$/; | |
| 430 | ||
| 431 | import * as node from "../node.ts"; | |
| 432 | import * as ansi from "../string/ansi.ts"; |
packages/clo-lib/math.ts created+8| ... | ... | @@ -0,0 +1,8 @@ |
| 1 | export function gcd(a: number, b: number): number { | |
| 2 | while (b !== 0) [a, b] = [b, a % b]; | |
| 3 | return a; | |
| 4 | } | |
| 5 | ||
| 6 | export function clamp(value: number, min: number, max: number): number { | |
| 7 | return Math.min(max, Math.max(min, value)); | |
| 8 | } |
packages/clo-lib/node.ts created+289| ... | ... | @@ -0,0 +1,289 @@ |
| 1 | /** | |
| 2 | * functions to load Node.js apis via `globalThis.process`. trivially bundlable | |
| 3 | * for the browser. does not depend on `@types/node` and does not intend to | |
| 4 | * define types for the entire api. instead, this file's types are used for | |
| 5 | * other library modules like `lib/log.ts` to bind to the system. | |
| 6 | * | |
| 7 | * if you are using a competent bundler, you can define `globalThis.process` as | |
| 8 | * a bundling constant (esbuild: `--define`) to enable tree shaking across the | |
| 9 | * library to only include browser code paths. | |
| 10 | * | |
| 11 | * @module | |
| 12 | */ | |
| 13 | ||
| 14 | export const process: Process | undefined = | |
| 15 | (globalThis as { process?: Process }).process && | |
| 16 | (globalThis as { process?: Process }).process?.versions?.node | |
| 17 | ? (globalThis as { process?: Process }).process | |
| 18 | : undefined; | |
| 19 | ||
| 20 | export const isServer: boolean = !!process; | |
| 21 | ||
| 22 | /** partial types for Node.js `globalThis.process` */ | |
| 23 | interface Process { | |
| 24 | getBuiltinModule<K extends keyof Builtins>(name: K): Builtins[K] | null; | |
| 25 | binding?<K extends keyof Bindings>(name: K): Bindings[K] | null; | |
| 26 | addListener(event: string, callback: () => void): this; | |
| 27 | prependListener(event: string, callback: () => void): this; | |
| 28 | removeListener(event: string, callback: () => void): this; | |
| 29 | listenerCount(event: string): number; | |
| 30 | kill(pid: number, signal: string): boolean; | |
| 31 | env: Partial<Record<string, string>>; | |
| 32 | pid: number; | |
| 33 | ppid: number; | |
| 34 | platform: string; | |
| 35 | versions: { node?: string; bun?: string; deno?: string }; | |
| 36 | stdin: TtyInput; | |
| 37 | stdout: Tty; | |
| 38 | stderr: Tty; | |
| 39 | } | |
| 40 | ||
| 41 | /** the raw mode members are only present when the stream is a tty */ | |
| 42 | interface TtyInput { | |
| 43 | isRaw: boolean; | |
| 44 | setRawMode(mode: boolean): this; | |
| 45 | addListener(event: "data", callback: (chunk: Uint8Array | string) => void): this; | |
| 46 | removeListener(event: "data", callback: (chunk: Uint8Array | string) => void): this; | |
| 47 | listenerCount(event: string): number; | |
| 48 | pause(): this; | |
| 49 | resume(): this; | |
| 50 | } | |
| 51 | ||
| 52 | interface Tty { | |
| 53 | isTTY: boolean; | |
| 54 | /** only present when the stream is a tty */ | |
| 55 | columns: number | undefined; | |
| 56 | /** only present when the stream is a tty */ | |
| 57 | rows: number | undefined; | |
| 58 | addListener(event: string, callback: () => void): this; | |
| 59 | removeListener(event: string, callback: () => void): this; | |
| 60 | end(text?: string | Uint8Array): this; | |
| 61 | write(text: string | Uint8Array): boolean; | |
| 62 | } | |
| 63 | ||
| 64 | /** | |
| 65 | * Subset of Node.js builtin types needed to not depend on the problematic | |
| 66 | * "@types/node" package. | |
| 67 | */ | |
| 68 | interface Builtins { | |
| 69 | child_process: { | |
| 70 | spawnSync(command: string, args: string[], options: { stdio: "ignore" }): unknown; | |
| 71 | }; | |
| 72 | fs: { | |
| 73 | existsSync(file: string): boolean; | |
| 74 | readFileSync(file: string, encoding: "utf-8"): string; | |
| 75 | readFileSync(file: string): Uint8Array; | |
| 76 | writeFileSync(file: string, data: string, options?: { mode?: number }): void; | |
| 77 | appendFileSync(file: string, data: string, options?: { mode?: number }): void; | |
| 78 | writeSync(fd: number, data: string): number; | |
| 79 | mkdirSync(dir: string, options: { recursive: true }): void; | |
| 80 | }; | |
| 81 | inspector: { | |
| 82 | Session: new () => { | |
| 83 | connect(): void; | |
| 84 | post( | |
| 85 | method: string, | |
| 86 | params: object, | |
| 87 | // the result is whatever the Chrome DevTools Protocol method returns | |
| 88 | callback: (error: Error | null, result: any) => void, | |
| 89 | ): void; | |
| 90 | }; | |
| 91 | }; | |
| 92 | path: { | |
| 93 | isAbsolute(file: string): boolean; | |
| 94 | dirname(file: string): string; | |
| 95 | basename(file: string): string; | |
| 96 | join(...parts: string[]): string; | |
| 97 | resolve(...parts: string[]): string; | |
| 98 | sep: string; | |
| 99 | }; | |
| 100 | tty: { | |
| 101 | isatty(fd: number): boolean; | |
| 102 | }; | |
| 103 | url: { | |
| 104 | pathToFileURL(file: string): URL; | |
| 105 | }; | |
| 106 | util: { | |
| 107 | inspect(object: unknown, options?: { colors?: boolean }): string; | |
| 108 | formatWithOptions(options: { colors?: boolean }, ...args: unknown[]): string; | |
| 109 | }; | |
| 110 | stream: { | |
| 111 | Readable: { | |
| 112 | toWeb(nodeStream: unknown): ReadableStream; | |
| 113 | fromWeb(webStream: ReadableStream): { pipe: (other: unknown) => void }; | |
| 114 | }; | |
| 115 | Writable: { | |
| 116 | prototype: { | |
| 117 | write: (value: string) => boolean; | |
| 118 | }; | |
| 119 | }; | |
| 120 | }; | |
| 121 | } | |
| 122 | ||
| 123 | /** subset of Node.js binding types */ | |
| 124 | interface Bindings { | |
| 125 | /** | |
| 126 | * key value mapping of internal module id to its source code. | |
| 127 | * ``` | |
| 128 | * { "fs": "// Copyright Joyent, Inc. and other Node contributors.\n... | |
| 129 | * ``` | |
| 130 | */ | |
| 131 | natives: Partial<Record<string, string>>; | |
| 132 | } | |
| 133 | ||
| 134 | /** return a built-in module */ | |
| 135 | export function builtin<K extends keyof Builtins>(name: K): Builtins[K] | undefined { | |
| 136 | return process?.getBuiltinModule(name) ?? undefined; | |
| 137 | } | |
| 138 | ||
| 139 | /** return a built-in semi-private binding */ | |
| 140 | export function binding<K extends keyof Bindings>(name: K): Bindings[K] | undefined { | |
| 141 | if (!process) return undefined; | |
| 142 | try { | |
| 143 | return process.binding?.(name) ?? undefined; | |
| 144 | } catch { | |
| 145 | return undefined; | |
| 146 | } | |
| 147 | } | |
| 148 | ||
| 149 | /** copy of `keyof typeof import('node:os').constants.errno` */ | |
| 150 | // deno-fmt-ignore-next | |
| 151 | export type ErrorCode = | |
| 152 | | (string & {}) | |
| 153 | | "E2BIG" | |
| 154 | | "EACCES" | |
| 155 | | "EADDRINUSE" | |
| 156 | | "EADDRNOTAVAIL" | |
| 157 | | "EAFNOSUPPORT" | |
| 158 | | "EAGAIN" | |
| 159 | | "EALREADY" | |
| 160 | | "EBADF" | |
| 161 | | "EBADMSG" | |
| 162 | | "EBUSY" | |
| 163 | | "ECANCELED" | |
| 164 | | "ECHILD" | |
| 165 | | "ECONNABORTED" | |
| 166 | | "ECONNREFUSED" | |
| 167 | | "ECONNRESET" | |
| 168 | | "EDEADLK" | |
| 169 | | "EDESTADDRREQ" | |
| 170 | | "EDOM" | |
| 171 | | "EDQUOT" | |
| 172 | | "EEXIST" | |
| 173 | | "EFAULT" | |
| 174 | | "EFBIG" | |
| 175 | | "EHOSTUNREACH" | |
| 176 | | "EIDRM" | |
| 177 | | "EILSEQ" | |
| 178 | | "EINPROGRESS" | |
| 179 | | "EINTR" | |
| 180 | | "EINVAL" | |
| 181 | | "EIO" | |
| 182 | | "EISCONN" | |
| 183 | | "EISDIR" | |
| 184 | | "ELOOP" | |
| 185 | | "EMFILE" | |
| 186 | | "EMLINK" | |
| 187 | | "EMSGSIZE" | |
| 188 | | "EMULTIHOP" | |
| 189 | | "ENAMETOOLONG" | |
| 190 | | "ENETDOWN" | |
| 191 | | "ENETRESET" | |
| 192 | | "ENETUNREACH" | |
| 193 | | "ENFILE" | |
| 194 | | "ENOBUFS" | |
| 195 | | "ENODATA" | |
| 196 | | "ENODEV" | |
| 197 | | "ENOENT" | |
| 198 | | "ENOEXEC" | |
| 199 | | "ENOLCK" | |
| 200 | | "ENOLINK" | |
| 201 | | "ENOMEM" | |
| 202 | | "ENOMSG" | |
| 203 | | "ENOPROTOOPT" | |
| 204 | | "ENOSPC" | |
| 205 | | "ENOSR" | |
| 206 | | "ENOSTR" | |
| 207 | | "ENOSYS" | |
| 208 | | "ENOTCONN" | |
| 209 | | "ENOTDIR" | |
| 210 | | "ENOTEMPTY" | |
| 211 | | "ENOTSOCK" | |
| 212 | | "ENOTSUP" | |
| 213 | | "ENOTTY" | |
| 214 | | "ENXIO" | |
| 215 | | "EOPNOTSUPP" | |
| 216 | | "EOVERFLOW" | |
| 217 | | "EPERM" | |
| 218 | | "EPIPE" | |
| 219 | | "EPROTO" | |
| 220 | | "EPROTONOSUPPORT" | |
| 221 | | "EPROTOTYPE" | |
| 222 | | "ERANGE" | |
| 223 | | "EROFS" | |
| 224 | | "ESPIPE" | |
| 225 | | "ESRCH" | |
| 226 | | "ESTALE" | |
| 227 | | "ETIME" | |
| 228 | | "ETIMEDOUT" | |
| 229 | | "ETXTBSY" | |
| 230 | | "EWOULDBLOCK" | |
| 231 | | "EXDEV" | |
| 232 | | "WSAEINTR" | |
| 233 | | "WSAEBADF" | |
| 234 | | "WSAEACCES" | |
| 235 | | "WSAEFAULT" | |
| 236 | | "WSAEINVAL" | |
| 237 | | "WSAEMFILE" | |
| 238 | | "WSAEWOULDBLOCK" | |
| 239 | | "WSAEINPROGRESS" | |
| 240 | | "WSAEALREADY" | |
| 241 | | "WSAENOTSOCK" | |
| 242 | | "WSAEDESTADDRREQ" | |
| 243 | | "WSAEMSGSIZE" | |
| 244 | | "WSAEPROTOTYPE" | |
| 245 | | "WSAENOPROTOOPT" | |
| 246 | | "WSAEPROTONOSUPPORT" | |
| 247 | | "WSAESOCKTNOSUPPORT" | |
| 248 | | "WSAEOPNOTSUPP" | |
| 249 | | "WSAEPFNOSUPPORT" | |
| 250 | | "WSAEAFNOSUPPORT" | |
| 251 | | "WSAEADDRINUSE" | |
| 252 | | "WSAEADDRNOTAVAIL" | |
| 253 | | "WSAENETDOWN" | |
| 254 | | "WSAENETUNREACH" | |
| 255 | | "WSAENETRESET" | |
| 256 | | "WSAECONNABORTED" | |
| 257 | | "WSAECONNRESET" | |
| 258 | | "WSAENOBUFS" | |
| 259 | | "WSAEISCONN" | |
| 260 | | "WSAENOTCONN" | |
| 261 | | "WSAESHUTDOWN" | |
| 262 | | "WSAETOOMANYREFS" | |
| 263 | | "WSAETIMEDOUT" | |
| 264 | | "WSAECONNREFUSED" | |
| 265 | | "WSAELOOP" | |
| 266 | | "WSAENAMETOOLONG" | |
| 267 | | "WSAEHOSTDOWN" | |
| 268 | | "WSAEHOSTUNREACH" | |
| 269 | | "WSAENOTEMPTY" | |
| 270 | | "WSAEPROCLIM" | |
| 271 | | "WSAEUSERS" | |
| 272 | | "WSAEDQUOT" | |
| 273 | | "WSAESTALE" | |
| 274 | | "WSAEREMOTE" | |
| 275 | | "WSASYSNOTREADY" | |
| 276 | | "WSAVERNOTSUPPORTED" | |
| 277 | | "WSANOTINITIALISED" | |
| 278 | | "WSAEDISCON" | |
| 279 | | "WSAENOMORE" | |
| 280 | | "WSAECANCELLED" | |
| 281 | | "WSAEINVALIDPROCTABLE" | |
| 282 | | "WSAEINVALIDPROVIDER" | |
| 283 | | "WSAEPROVIDERFAILEDINIT" | |
| 284 | | "WSASYSCALLFAILURE" | |
| 285 | | "WSASERVICE_NOT_FOUND" | |
| 286 | | "WSATYPE_NOT_FOUND" | |
| 287 | | "WSA_E_NO_MORE" | |
| 288 | | "WSA_E_CANCELLED" | |
| 289 | | "WSAEREFUSED"; |
packages/clo-lib/package.json created+18| ... | ... | @@ -0,0 +1,18 @@ |
| 1 | { | |
| 2 | "name": "@clo/lib", | |
| 3 | "private": true, | |
| 4 | "license": "ISC", | |
| 5 | "type": "module", | |
| 6 | "exports": { | |
| 7 | "./progress": "./progress.ts", | |
| 8 | "./progress/browser": "./progress-browser.ts" | |
| 9 | }, | |
| 10 | "scripts": { | |
| 11 | "typecheck": "tsc-rs --noEmit", | |
| 12 | "test": "vp test run --config ../../vite.config.ts --dir ." | |
| 13 | }, | |
| 14 | "devDependencies": { | |
| 15 | "@types/node": "catalog:", | |
| 16 | "vite-plus": "catalog:" | |
| 17 | } | |
| 18 | } |
packages/clo-lib/progress-browser.ts created+373| ... | ... | @@ -0,0 +1,373 @@ |
| 1 | // @effect-diagnostics globalDate:off globalTimers:off - Vendored @clo/lib has no Effect dependency. | |
| 2 | import { UNWRAP } from "./assert.ts"; | |
| 3 | import type { Events } from "./Events.ts"; | |
| 4 | import type * as log from "./log.ts"; | |
| 5 | import * as math from "./math.ts"; | |
| 6 | import type { ReadOnlyNode, Root } from "./progress.ts"; | |
| 7 | import { isVisible, maxLogLines, remainingSeconds } from "./progress-format.ts"; | |
| 8 | import * as string from "./string.ts"; | |
| 9 | import * as ansi from "./string/ansi.ts"; | |
| 10 | import * as ts from "./ts.ts"; | |
| 11 | ||
| 12 | /** | |
| 13 | * draw `root` into the shadow root of `host`, after the Windows 7 progress bar | |
| 14 | * and busy cursor, on the animation frame after each change, until disposed. | |
| 15 | * the shadow root keeps the page and the tree from styling each other, and the | |
| 16 | * tree follows the page's `color-scheme`. `host` must accept a shadow root, | |
| 17 | * such as a `div`; one rendered by {@linkcode formatHtml} is taken over. | |
| 18 | * ```ts | |
| 19 | * const root = new progress.Root(); | |
| 20 | * const detach = progress.attachToDocument(root, document.getElementById("progress")!); | |
| 21 | * await build(root); | |
| 22 | * detach(); | |
| 23 | * ``` | |
| 24 | */ | |
| 25 | export function attachToDocument<R, M extends Events.Map>( | |
| 26 | root: Root<R, M>, | |
| 27 | host: Element, | |
| 28 | ): ts.Dispose { | |
| 29 | const shadow = host.shadowRoot ?? host.attachShadow({ mode: "open" }); | |
| 30 | shadow.innerHTML = formatShadow(""); | |
| 31 | const tree = UNWRAP(shadow.lastElementChild); | |
| 32 | const template = document.createElement("template"); | |
| 33 | const elements = new WeakMap<ReadOnlyNode, Element>(); | |
| 34 | const changedAt = new WeakMap<ReadOnlyNode, number>(); | |
| 35 | const staleLogs = new Set<ReadOnlyNode>(); | |
| 36 | let frame: number | null = null; | |
| 37 | let tick: ts.Timer | undefined; | |
| 38 | let tickIn = Infinity; | |
| 39 | ||
| 40 | const redraw = () => void (frame ??= requestAnimationFrame(draw)); | |
| 41 | function draw() { | |
| 42 | frame = null; | |
| 43 | for (const node of staleLogs) { | |
| 44 | const logs = elements.get(node)?.children[2]; | |
| 45 | if (logs) logs.innerHTML = formatLogs(node.logs); | |
| 46 | } | |
| 47 | staleLogs.clear(); | |
| 48 | clearTimeout(tick); | |
| 49 | tickIn = Infinity; | |
| 50 | sync(tree, root.active); | |
| 51 | if (tickIn !== Infinity) tick = setTimeout(redraw, tickIn); | |
| 52 | } | |
| 53 | function sync(list: Element, nodes: readonly ReadOnlyNode[]) { | |
| 54 | let next = list.firstElementChild; | |
| 55 | for (const node of nodes) { | |
| 56 | if (!isVisible(node)) continue; | |
| 57 | // estimates show whole seconds, rounded, so each changes on a half second | |
| 58 | const ms = (node.estimatedTime ?? 0) - Date.now(); | |
| 59 | if (ms > 0) tickIn = Math.min(tickIn, ((ms + 500) % 1000) + 5); | |
| 60 | let li = elements.get(node); | |
| 61 | if (li) { | |
| 62 | update(li, node, changedAt); | |
| 63 | } else { | |
| 64 | template.innerHTML = formatNode(node, ""); | |
| 65 | elements.set(node, (li = UNWRAP(template.content.firstElementChild))); | |
| 66 | changedAt.set(node, performance.now()); | |
| 67 | } | |
| 68 | if (li === next) next = li.nextElementSibling; | |
| 69 | else list.insertBefore(li, next); | |
| 70 | sync(UNWRAP(li.children[1]), node.children); | |
| 71 | } | |
| 72 | while (next) { | |
| 73 | const stale = next; | |
| 74 | next = next.nextElementSibling; | |
| 75 | stale.remove(); | |
| 76 | } | |
| 77 | } | |
| 78 | ||
| 79 | const listeners = [ | |
| 80 | root.on("node-start", redraw), | |
| 81 | root.on("node-change", (node, key) => { | |
| 82 | if (key === "logs") staleLogs.add(node); | |
| 83 | redraw(); | |
| 84 | }), | |
| 85 | root.on("node-end", redraw), | |
| 86 | ]; | |
| 87 | draw(); | |
| 88 | return ts.defer(() => { | |
| 89 | for (const off of listeners) off(); | |
| 90 | if (frame !== null) cancelAnimationFrame(frame); | |
| 91 | clearTimeout(tick); | |
| 92 | shadow.replaceChildren(); | |
| 93 | }); | |
| 94 | } | |
| 95 | ||
| 96 | /** | |
| 97 | * the markup {@linkcode attachToDocument} draws for `list`, as a declarative | |
| 98 | * shadow root to place inside the element it later takes over. browsers attach | |
| 99 | * it only while parsing a document, such as a server rendered page. | |
| 100 | */ | |
| 101 | export function formatHtml(list: readonly ReadOnlyNode[]): string { | |
| 102 | const format = (nodes: readonly ReadOnlyNode[]): string => | |
| 103 | nodes | |
| 104 | .filter(isVisible) | |
| 105 | .map((node) => formatNode(node, format(node.children))) | |
| 106 | .join(""); | |
| 107 | return `<template shadowrootmode="open">${formatShadow(format(list))}</template>`; | |
| 108 | } | |
| 109 | ||
| 110 | const formatShadow = (items: string) => | |
| 111 | `<style>${css}</style><ol class=tree role=list>${items}</ol>`; | |
| 112 | ||
| 113 | /** the parts of a node's markup that change after it is drawn */ | |
| 114 | function fields(node: ReadOnlyNode) { | |
| 115 | const fraction = | |
| 116 | node.showTotal && node.total > 0 ? math.clamp(node.value / node.total, 0, 1) : null; | |
| 117 | const remaining = remainingSeconds(node); | |
| 118 | return { | |
| 119 | className: fraction === null ? "spin" : fraction === 1 ? "bar full" : "bar", | |
| 120 | fraction, | |
| 121 | count: formatCount(node), | |
| 122 | eta: remaining | |
| 123 | ? string.formatDurationLetters(remaining).replace(/(?<=\D)(?=\d)/g, " ") + " left" | |
| 124 | : "", | |
| 125 | }; | |
| 126 | } | |
| 127 | ||
| 128 | /** `attachToDocument` relies on this layout: row, children, logs */ | |
| 129 | function formatNode(node: ReadOnlyNode, children: string): string { | |
| 130 | const { className, fraction, count, eta } = fields(node); | |
| 131 | const id = `n${node.key}`; | |
| 132 | const value = | |
| 133 | fraction === null | |
| 134 | ? "" | |
| 135 | : ` aria-valuenow="${node.value}" aria-valuemax="${node.total}" style="--p:${fraction}"`; | |
| 136 | return ( | |
| 137 | `<li class="${className}"><div class=row>` + | |
| 138 | `<div class=indicator role=progressbar aria-labelledby=${id}${value}><i></i></div>` + | |
| 139 | `<span class=count><b>${esc(count[0])}</b><span>${esc(count[1])}</span></span>` + | |
| 140 | `<span class=eta>${eta}</span>` + | |
| 141 | `<span class=label id=${id}>${esc(node.text)}</span>` + | |
| 142 | `</div><ol role=list>${children}</ol><div class=logs>${formatLogs(node.logs)}</div></li>` | |
| 143 | ); | |
| 144 | } | |
| 145 | ||
| 146 | /** | |
| 147 | * bring `li` from {@linkcode formatNode} up to date, except its children and | |
| 148 | * logs. `changedAt` holds when each bar last moved. | |
| 149 | */ | |
| 150 | function update(li: Element, node: ReadOnlyNode, changedAt: WeakMap<ReadOnlyNode, number>) { | |
| 151 | const { className, fraction, count, eta } = fields(node); | |
| 152 | const [indicator, counter, estimate, label] = UNWRAP(li.firstElementChild).children; | |
| 153 | const [value, total] = UNWRAP(counter).children; | |
| 154 | const progressbar = UNWRAP(indicator); | |
| 155 | setAttribute(li, "class", className); | |
| 156 | setText(UNWRAP(value), count[0]); | |
| 157 | setText(UNWRAP(total), count[1]); | |
| 158 | setText(UNWRAP(estimate), eta); | |
| 159 | setText(UNWRAP(label), node.text); | |
| 160 | if (fraction === null) { | |
| 161 | for (const name of ["aria-valuenow", "aria-valuemax", "style"]) | |
| 162 | setAttribute(progressbar, name, null); | |
| 163 | return; | |
| 164 | } | |
| 165 | const shown = math.clamp( | |
| 166 | Number(progressbar.getAttribute("aria-valuenow")) / | |
| 167 | Number(progressbar.getAttribute("aria-valuemax")), | |
| 168 | 0, | |
| 169 | 1, | |
| 170 | ); | |
| 171 | setAttribute(progressbar, "aria-valuenow", String(node.value)); | |
| 172 | setAttribute(progressbar, "aria-valuemax", String(node.total)); | |
| 173 | if (fraction === shown) return; | |
| 174 | // a forward glide lasts as long as the last update took to arrive, so steady | |
| 175 | // progress moves steadily; going back snaps, as the original does | |
| 176 | const now = performance.now(); | |
| 177 | const glide = fraction > shown ? Math.min(now - (changedAt.get(node) ?? now), 1000) : 0; | |
| 178 | changedAt.set(node, now); | |
| 179 | setAttribute(progressbar, "style", `--p:${fraction};--glide:${Math.round(glide)}ms`); | |
| 180 | } | |
| 181 | ||
| 182 | function setAttribute(element: Element, name: string, value: string | null) { | |
| 183 | if (element.getAttribute(name) === value) return; | |
| 184 | if (value === null) element.removeAttribute(name); | |
| 185 | else element.setAttribute(name, value); | |
| 186 | } | |
| 187 | ||
| 188 | function setText(element: Element, text: string) { | |
| 189 | if (element.textContent !== text) element.textContent = text; | |
| 190 | } | |
| 191 | ||
| 192 | /** `[value, total]`, split at the formatter's slash so the total can be styled apart */ | |
| 193 | function formatCount({ value, total, showTotal, valueFormatter }: ReadOnlyNode): [string, string] { | |
| 194 | const text = valueFormatter(value, total); | |
| 195 | const slash = text.lastIndexOf("/"); | |
| 196 | if (slash === -1) return [text, ""]; | |
| 197 | return [text.slice(0, slash), showTotal ? text.slice(slash + 1) : ""]; | |
| 198 | } | |
| 199 | ||
| 200 | /** the last lines of `logs`. partial writes join into lines of their own. */ | |
| 201 | function formatLogs(logs: readonly log.Message[]): string { | |
| 202 | let start = logs.length; | |
| 203 | for (let breaks = 0; start > 0 && breaks <= maxLogLines;) { | |
| 204 | const { text, newline } = UNWRAP(logs[--start]); | |
| 205 | breaks += string.countNewlines(text) + (newline === false ? 0 : 1); | |
| 206 | } | |
| 207 | const lines: { kind: string; html: string }[] = []; | |
| 208 | let partial: { kind: string; html: string } | null = null; | |
| 209 | for (const { text, newline, level = "info", scope } of logs.slice(start)) { | |
| 210 | ansi | |
| 211 | .toHtml(text) | |
| 212 | .split("\n") | |
| 213 | .forEach((html, i) => { | |
| 214 | if (newline !== false) { | |
| 215 | // a message starts its own line, so a write's trailing newline leaves no blank one | |
| 216 | if (i === 0 && partial?.html === "") lines.pop(); | |
| 217 | const prefix = i === 0 && scope ? `<span class=scope>${esc(scope)}</span> ` : ""; | |
| 218 | lines.push({ kind: i === 0 ? level : "more", html: prefix + html }); | |
| 219 | } else if (i === 0 && partial) { | |
| 220 | partial.html += html; | |
| 221 | } else { | |
| 222 | lines.push((partial = { kind: "raw", html })); | |
| 223 | } | |
| 224 | }); | |
| 225 | if (newline !== false) partial = null; | |
| 226 | } | |
| 227 | while (lines.at(-1)?.html === "") lines.pop(); | |
| 228 | return lines | |
| 229 | .slice(-maxLogLines) | |
| 230 | .map(({ kind, html }) => `<div class=${kind}>${html}</div>`) | |
| 231 | .join(""); | |
| 232 | } | |
| 233 | ||
| 234 | const esc = string.escapeHtmlContent; | |
| 235 | ||
| 236 | /** inline svg for a background image */ | |
| 237 | const svg = (body: string) => | |
| 238 | `url("data:image/svg+xml,${encodeURIComponent( | |
| 239 | `<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12'>${body}</svg>`, | |
| 240 | )}")`; | |
| 241 | ||
| 242 | // light-dark() resolves against `color-scheme`, which is inherited from the | |
| 243 | // host; `all: initial` keeps every other inherited property out. | |
| 244 | const css = /* @__PURE__ */ (() => ` | |
| 245 | :host{display:block} | |
| 246 | .tree{all:initial;display:block;color-scheme:inherit;container-type:inline-size; | |
| 247 | font:12px/23px "Segoe UI",system-ui,sans-serif;color:var(--progress-foreground,light-dark(#1e1e1e,#e3e5e8)); | |
| 248 | --line:var(--progress-border,light-dark(#a9b3bd,#5a626c));--muted:var(--progress-muted,light-dark(#56616c,#a4aeb9));${ansi.htmlColors}} | |
| 249 | ol{margin:0;padding:0} | |
| 250 | li{display:flow-root;position:relative} | |
| 251 | .tree>li+li{margin-top:4px} | |
| 252 | .row{display:flex;flex-wrap:wrap;align-items:center;column-gap:6px;min-height:23px;white-space:nowrap} | |
| 253 | .label{overflow:hidden;text-overflow:ellipsis;min-width:0} | |
| 254 | .tree>li>.row>.label{font-size:13px;color:var(--progress-accent,light-dark(#039,#8fb4ff))} | |
| 255 | .count,.eta{flex:none;margin:4px 0;padding:0 6px;border-radius:8px;font-size:11px;line-height:15px; | |
| 256 | font-variant-numeric:tabular-nums;color:var(--muted); | |
| 257 | background:linear-gradient(light-dark(#fdfeff,#363b43),light-dark(#edf1f5,#2b3037)); | |
| 258 | box-shadow:inset 0 0 0 1px light-dark(#d5dce4,#40474f)} | |
| 259 | .count:has(>b:empty),.eta:empty{display:none} | |
| 260 | .count>b{font-weight:600;color:var(--progress-foreground,light-dark(#2c3640,#d5dbe1))} | |
| 261 | .count>span:not(:empty)::before{content:"/";margin:0 .2em;opacity:.55} | |
| 262 | ||
| 263 | /* bars lead the row, wider at the top level, as in the terminal. the track is | |
| 264 | a 1px frame, 6px of gloss, a stretched body, and 5px of bottom light */ | |
| 265 | .tree>li{--bar:176} | |
| 266 | li li{--bar:96} | |
| 267 | @container (width < 400px){.tree>li{--bar:112}li li{--bar:64}} | |
| 268 | .bar>.row>.indicator{position:relative;flex:none;box-sizing:border-box;width:calc(var(--bar) * 1px); | |
| 269 | height:15px;margin:4px 0;border:1px solid transparent;border-radius:2px; | |
| 270 | background: | |
| 271 | linear-gradient(light-dark(#f3f3f3,#34383e),light-dark(#fcfcfc,#3b3f45) 1.5px, | |
| 272 | light-dark(#dbdbdb,#2f3237) 6px,light-dark(#cacaca,#26292d) 6px, | |
| 273 | light-dark(#cacaca,#26292d) calc(100% - 5px),light-dark(#d5d5d5,#2b2e33) calc(100% - 1px), | |
| 274 | light-dark(#eaeaea,#33373c)) padding-box, | |
| 275 | linear-gradient(light-dark(#b2b2b2,#4b5058) 0 1px,light-dark(#bdbdbd,#464b53) 1px, | |
| 276 | light-dark(#919191,#393d44) calc(100% - 1px),light-dark(#8c8c8c,#393d44) calc(100% - 1px)) | |
| 277 | border-box} | |
| 278 | .indicator>i{position:absolute;top:0;bottom:0;left:0;width:calc(var(--p) * 100%);overflow-x:clip; | |
| 279 | --gloss:#d2f1fc 1.5px,#addcf3 3.5px,#9ad3ef;--body:var(--progress-accent,#0794d1);--rest:var(--progress-accent,#1ea0d8);--edge:var(--progress-accent,#035f8f); | |
| 280 | --glow:160 225 255;--shade:color-mix(in srgb,var(--edge) 40%,#0000); | |
| 281 | background: | |
| 282 | linear-gradient(90deg,#0000 1px,var(--shade) 1px,var(--shade) 5px,#0000 27px), | |
| 283 | linear-gradient(270deg,#0000 1px,var(--shade) 1px,#0000 14px), | |
| 284 | linear-gradient(var(--gloss) 6px,var(--body) 6px,var(--body) calc(100% - 5px),var(--rest)); | |
| 285 | transition:width var(--glide,0s) linear} | |
| 286 | /* a glow sweeps the fill at one speed for every bar, over the frame too, while | |
| 287 | the bottom light swells under it */ | |
| 288 | .indicator>i::before{content:"";position:absolute;left:0;right:0;bottom:0;height:5px; | |
| 289 | background:linear-gradient(#0000,rgb(var(--glow)/.6));animation:swell 2.4s ease-in-out infinite} | |
| 290 | .indicator>i::after{content:"";position:absolute;top:-1px;bottom:-1px;left:0;width:64px; | |
| 291 | background:linear-gradient(90deg,#0000,rgb(var(--glow)/.03) 8.3%,rgb(var(--glow)/.08) 16.7%, | |
| 292 | rgb(var(--glow)/.2) 25%,rgb(var(--glow)/.36) 33.3%,rgb(var(--glow)/.53) 41.7%, | |
| 293 | rgb(var(--glow)/.6),rgb(var(--glow)/.53) 58.3%,rgb(var(--glow)/.36) 66.7%, | |
| 294 | rgb(var(--glow)/.2) 75%,rgb(var(--glow)/.08) 83.3%,rgb(var(--glow)/.03) 91.7%,#0000); | |
| 295 | animation:glow 2.4s linear infinite} | |
| 296 | @keyframes glow{from{translate:-100%}50%,to{translate:176px}} | |
| 297 | @keyframes swell{from{opacity:.4}25%{opacity:1}65%,88%{opacity:0}to{opacity:.4}} | |
| 298 | :is(.full,li:has(>.logs>:is(.warn,.error)))>.row>.indicator>i::before, | |
| 299 | :is(.full,li:has(>.logs>:is(.warn,.error)))>.row>.indicator>i::after{content:none} | |
| 300 | li:has(>.logs>.warn)>.row>.indicator>i{ | |
| 301 | --gloss:#fffdcd 1.5px,#fffba1 3.5px,#fffa8b;--body:#d2ca00;--rest:#fef400;--edge:#8f8600} | |
| 302 | li:has(>.logs>.error)>.row>.indicator>i{ | |
| 303 | --gloss:#ffcdcd 1.5px,#ffa1a1 3.5px,#ff8b8b;--body:#d20000;--rest:#fe0000;--edge:#8f0000} | |
| 304 | ||
| 305 | /* the busy ring leads a top-level row, lit from above, with a highlight | |
| 306 | running clockwise; below the top level the terminal shows no spinner */ | |
| 307 | .spin>.row>.indicator{position:relative;flex:none;width:15px;height:15px;margin:4px 0;border-radius:50%; | |
| 308 | background:conic-gradient(#9bc6da,#8dc2d0 30deg,#6daebd 60deg,#2b8982 90deg,#319f9d 120deg, | |
| 309 | #4dc7bb 150deg,#5ecfc5 180deg,#4fc6bb 210deg,#2da09e 240deg,#238382 270deg,#6db2bb 300deg, | |
| 310 | #91c5d1 330deg,#9bc6da); | |
| 311 | mask:radial-gradient(closest-side,#0000 61%,#000 67%)} | |
| 312 | li li.spin>.row>.indicator{display:none} | |
| 313 | .spin>.row>.indicator::before,.spin>.row>.indicator::after{content:"";position:absolute;inset:0;border-radius:50%} | |
| 314 | .spin>.row>.indicator::before{animation:spin .9s linear infinite; | |
| 315 | background:conic-gradient(from -150deg,#0000,#2fd4dc99 40deg,#4fe6eae6 90deg,#8ff3f4 125deg, | |
| 316 | #e8fcfd 150deg,#b4eef0b3 162deg,#0000 180deg)} | |
| 317 | .spin>.row>.indicator::after{background:radial-gradient(closest-side,#00323c73 64%,#00323c00 76%, | |
| 318 | #fff3 86%,#00323c00 92%,#00323c80)} | |
| 319 | .spin>.row>.indicator>i{display:none} | |
| 320 | @keyframes spin{to{rotate:1turn}} | |
| 321 | ||
| 322 | /* tree lines: a node's children hang from the start of its row, and each child | |
| 323 | is drawn with the terminal's ├─, │ and └─ */ | |
| 324 | li li{padding-left:22px} | |
| 325 | li>ol{position:relative} | |
| 326 | li>ol:not(:empty)::before{content:"";position:absolute;left:7px;top:-4px;height:5px; | |
| 327 | border-left:1px solid var(--line)} | |
| 328 | li li::before,li li::after{content:"";position:absolute;left:7px;border:0 solid var(--line)} | |
| 329 | li li::before{top:0;bottom:-1px;border-left-width:1px} | |
| 330 | li li::after{top:11px;width:11px;border-top-width:1px} | |
| 331 | li li:last-child::before{bottom:auto;height:12px} | |
| 332 | ||
| 333 | .logs{margin:1px 0 4px;padding:3px 8px;border-radius:4px; | |
| 334 | background:var(--progress-background,light-dark(#f3f5f8,#24282e));box-shadow:inset 0 0 0 1px light-dark(#e1e6ec,#31363d); | |
| 335 | font:11px/16px ui-monospace,"Cascadia Mono",Consolas,"SF Mono",Menlo,monospace;color:var(--muted)} | |
| 336 | .logs:empty{display:none} | |
| 337 | .logs>div{overflow:hidden;text-overflow:ellipsis;white-space:pre} | |
| 338 | .logs>.more{padding-left:16px} | |
| 339 | .logs>.debug{opacity:.7} | |
| 340 | .logs>:is(.info,.warn,.error)::before{content:"";display:inline-block;width:11px;height:11px; | |
| 341 | margin-right:5px;vertical-align:-2px;background:center/contain no-repeat} | |
| 342 | .logs>.info::before{background-image:${svg( | |
| 343 | "<defs><radialGradient id='g' cx='.35' cy='.3' r='.8'><stop stop-color='#7cbaf6'/><stop offset='1' stop-color='#1557b8'/></radialGradient></defs><circle cx='6' cy='6' r='5.5' fill='url(#g)' stroke='#0c3f8c' stroke-width='.6'/><rect x='5.25' y='5' width='1.5' height='4.2' rx='.5' fill='#fff'/><circle cx='6' cy='3.3' r='.9' fill='#fff'/>", | |
| 344 | )}} | |
| 345 | .logs>.warn::before{background-image:${svg( | |
| 346 | "<defs><linearGradient id='g' x2='0' y2='1'><stop stop-color='#ffe680'/><stop offset='1' stop-color='#f0b400'/></linearGradient></defs><path d='M6 .8l5.4 10H.6z' stroke-linejoin='round' fill='url(#g)' stroke='#b58300' stroke-width='.7'/><rect x='5.3' y='4' width='1.4' height='3.8' rx='.5' fill='#333'/><circle cx='6' cy='9.2' r='.8' fill='#333'/>", | |
| 347 | )}} | |
| 348 | .logs>.error::before{background-image:${svg( | |
| 349 | "<defs><radialGradient id='g' cx='.35' cy='.3' r='.8'><stop stop-color='#ff8a80'/><stop offset='1' stop-color='#c41a1a'/></radialGradient></defs><circle cx='6' cy='6' r='5.5' fill='url(#g)' stroke='#8a0f0f' stroke-width='.6'/><path d='M4 4l4 4m0-4l-4 4' stroke='#fff' stroke-width='1.5' stroke-linecap='round'/>", | |
| 350 | )}} | |
| 351 | .scope{color:var(--progress-secondary,light-dark(#7a6a9e,#b5a6d8))} | |
| 352 | ||
| 353 | ||
| 354 | @media (prefers-reduced-motion:reduce){ | |
| 355 | .indicator>i{transition:none} | |
| 356 | .indicator>i::before,.indicator>i::after{content:none} | |
| 357 | .spin>.row>.indicator::before{animation:pulse 1.8s ease-in-out infinite alternate} | |
| 358 | } | |
| 359 | @keyframes pulse{from{opacity:.25}} | |
| 360 | @media (forced-colors:active){ | |
| 361 | .indicator{forced-color-adjust:none} | |
| 362 | .bar>.row>.indicator{background:Canvas;border-color:CanvasText} | |
| 363 | .indicator>i{background:Highlight} | |
| 364 | .indicator>i::before,.indicator>i::after{content:none} | |
| 365 | .spin>.row>.indicator{background:Highlight} | |
| 366 | } | |
| 367 | :host([data-progress-motion="static"]) .indicator, | |
| 368 | :host([data-progress-motion="static"]) .indicator>i, | |
| 369 | :host([data-progress-motion="static"]) .indicator::before, | |
| 370 | :host([data-progress-motion="static"]) .indicator::after, | |
| 371 | :host([data-progress-motion="static"]) .indicator>i::before, | |
| 372 | :host([data-progress-motion="static"]) .indicator>i::after{animation:none;transition:none} | |
| 373 | `)(); |
packages/clo-lib/progress-format.ts created+23| ... | ... | @@ -0,0 +1,23 @@ |
| 1 | // @effect-diagnostics globalDate:off - Vendored @clo/lib has no Effect dependency. | |
| 2 | import type { ReadOnlyNode } from "./progress.ts"; | |
| 3 | ||
| 4 | export function isVisible(node: ReadOnlyNode): boolean { | |
| 5 | return !node.hidden && (!node.passive || node.children.some(isVisible)); | |
| 6 | } | |
| 7 | ||
| 8 | export function remainingSeconds(node: ReadOnlyNode): number | null { | |
| 9 | const { estimatedTime } = node; | |
| 10 | if (!estimatedTime) return null; | |
| 11 | const ms = estimatedTime - Date.now(); | |
| 12 | return ms > 1000 && !findLongerEstimate(node, estimatedTime) ? Math.round(ms / 1000) : null; | |
| 13 | } | |
| 14 | ||
| 15 | function findLongerEstimate(state: ReadOnlyNode, estimatedTime: number): boolean { | |
| 16 | return state.children.some( | |
| 17 | (child) => | |
| 18 | isVisible(child) && | |
| 19 | ((child.estimatedTime ?? 0) > estimatedTime || findLongerEstimate(child, estimatedTime)), | |
| 20 | ); | |
| 21 | } | |
| 22 | ||
| 23 | export const maxLogLines = 3; |
packages/clo-lib/progress.test.ts created+85| ... | ... | @@ -0,0 +1,85 @@ |
| 1 | import { describe, expect, it } from "vite-plus/test"; | |
| 2 | ||
| 3 | import { | |
| 4 | decodeEventStream, | |
| 5 | encodeEventStream, | |
| 6 | Root, | |
| 7 | type StreamNode, | |
| 8 | type StreamEvent, | |
| 9 | } from "./progress.ts"; | |
| 10 | ||
| 11 | describe("progress transport", () => { | |
| 12 | it("reconstructs a late snapshot with nested nodes and empty labels", async () => { | |
| 13 | const source = new Root(); | |
| 14 | const parent = source.start("parent", { total: 8, value: 3 }); | |
| 15 | parent.start("", { total: 4, value: 2 }); | |
| 16 | const target = new Root(); | |
| 17 | const input = encodeEventStream(source, { throttleMs: 0 }); | |
| 18 | const decoding = decodeEventStream(input, target); | |
| 19 | await new Promise<void>((resolve) => | |
| 20 | target.on("node-start", () => { | |
| 21 | if (target.active[0]?.children.length) resolve(); | |
| 22 | }), | |
| 23 | ); | |
| 24 | expect(target.active[0]?.text).toBe("parent"); | |
| 25 | expect(target.active[0]?.value).toBe(3); | |
| 26 | expect(target.active[0]?.children[0]?.text).toBe(""); | |
| 27 | expect(target.active[0]?.children[0]?.value).toBe(2); | |
| 28 | decoding.cancel(); | |
| 29 | expect(target.active).toHaveLength(0); | |
| 30 | }); | |
| 31 | ||
| 32 | it("applies zero values, cleared text, and a cleared estimate", async () => { | |
| 33 | const target = new Root(); | |
| 34 | let controller: ReadableStreamDefaultController<StreamEvent>; | |
| 35 | const input = new ReadableStream<StreamEvent>({ | |
| 36 | start(value) { | |
| 37 | controller = value; | |
| 38 | }, | |
| 39 | }); | |
| 40 | const decoding = decodeEventStream<void, { checkpoint: [] }>(input, target); | |
| 41 | const checkpoint = () => new Promise<void>((resolve) => decoding.on("checkpoint", resolve)); | |
| 42 | let ready = checkpoint(); | |
| 43 | controller!.enqueue([ | |
| 44 | -1, | |
| 45 | { k: 1 as StreamNode["k"], t: "task", v: 5, e: 10, E: 2000 }, | |
| 46 | [1], | |
| 47 | ["checkpoint"], | |
| 48 | ]); | |
| 49 | await ready; | |
| 50 | ready = checkpoint(); | |
| 51 | controller!.enqueue([ | |
| 52 | 0, | |
| 53 | { k: 1 as StreamNode["k"], t: "", v: 0, e: 0, E: null }, | |
| 54 | ["checkpoint"], | |
| 55 | ]); | |
| 56 | await ready; | |
| 57 | expect(target.active[0]).toMatchObject({ text: "", value: 0, total: 0, estimatedTime: null }); | |
| 58 | controller!.enqueue([0, ["end", null]]); | |
| 59 | controller!.close(); | |
| 60 | await decoding; | |
| 61 | expect(target.active).toHaveLength(0); | |
| 62 | }); | |
| 63 | ||
| 64 | it("cancels a pending read and removes managed nodes without ending the transport", async () => { | |
| 65 | const target = new Root(); | |
| 66 | const cancelled = Promise.withResolvers<void>(); | |
| 67 | let controller: ReadableStreamDefaultController<StreamEvent>; | |
| 68 | const input = new ReadableStream<StreamEvent>({ | |
| 69 | start(value) { | |
| 70 | controller = value; | |
| 71 | }, | |
| 72 | cancel() { | |
| 73 | cancelled.resolve(); | |
| 74 | }, | |
| 75 | }); | |
| 76 | const decoding = decodeEventStream<void, { checkpoint: [] }>(input, target); | |
| 77 | const ready = new Promise<void>((resolve) => decoding.on("checkpoint", resolve)); | |
| 78 | controller!.enqueue([-1, { k: 1 as StreamNode["k"], t: "interrupted" }, [1], ["checkpoint"]]); | |
| 79 | await ready; | |
| 80 | decoding.cancel(); | |
| 81 | await cancelled.promise; | |
| 82 | expect(target.active).toHaveLength(0); | |
| 83 | expect(input.locked).toBe(false); | |
| 84 | }); | |
| 85 | }); |
packages/clo-lib/progress.ts created+1917| ... | ... | @@ -0,0 +1,1917 @@ |
| 1 | // @effect-diagnostics globalDate:off globalTimers:off - Vendored @clo/lib has no Effect dependency. | |
| 2 | /** | |
| 3 | * a progress reporting API that allows creating CLI spinners and progress bars | |
| 4 | * that can be easily nested to visualize complex, parallel work. in addition | |
| 5 | * to providing rich feedback in the terminal, a headless {@linkcode Root} node | |
| 6 | * can be constructed, which can be bound to the DOM through `progress/browser` | |
| 7 | * or transmit progress information | |
| 8 | * {@link encodeEventStream|across a network}. | |
| 9 | * | |
| 10 | * the progress API takes advantage of the `using` keyword to make nodes | |
| 11 | * scoped. the convention is to make functions take a {@linkcode Ref|progress.Ref}, | |
| 12 | * to act as the destination for the sub-node. | |
| 13 | * | |
| 14 | * ```ts | |
| 15 | * async function doSomething(p: progress.Ref) { | |
| 16 | * using node = p.start("my task"); | |
| 17 | * // ... | |
| 18 | * await doSomethingElse(node); // node satisfies progress.Ref | |
| 19 | * // ... | |
| 20 | * } | |
| 21 | * async function doSomethingElse(p: progress.Ref) { | |
| 22 | * using node = p.start("my sub task"); | |
| 23 | * // ... | |
| 24 | * } | |
| 25 | * | |
| 26 | * doSomething(progress); // the progress module satisfies progress.Ref | |
| 27 | * | |
| 28 | * import * as progress from "@clo/lib/progress"; | |
| 29 | * ``` | |
| 30 | * | |
| 31 | * in some cases, such as in `subprocess/ffmpeg.ts`, it may make more sense | |
| 32 | * to pass in a pre-started node. the `ffmpeg` module, for example, uses the | |
| 33 | * starting text as a base title and decorates it with encoding stats. | |
| 34 | * | |
| 35 | * when using the top-level {@linkcode start|progress.start}, proper | |
| 36 | * integration is made with `lib/log.ts` so that the progress tree does not | |
| 37 | * intersect with regular log messages, as well as forwarding each | |
| 38 | * {@linkcode Node} custom logging scope to the global log, including any | |
| 39 | * custom redirections. | |
| 40 | * | |
| 41 | * this module is under construction. while i am happy with the overall API, it | |
| 42 | * needs more work and feature development. the API of `Node` is stable, though. | |
| 43 | * | |
| 44 | * inspired by the [Zig Progress API](https://andrewkelley.me/post/zig-new-cli-progress-bar-explained.html). | |
| 45 | * | |
| 46 | * @module | |
| 47 | */ | |
| 48 | ||
| 49 | /** | |
| 50 | * creates a new trackable unit of work, visible in the terminal as a spinner | |
| 51 | * that rests at the end of the log. if `estimate` is given, a progress bar is | |
| 52 | * shown. | |
| 53 | */ | |
| 54 | /* node:coverage ignore next 3 */ | |
| 55 | export function start(text: string, opts?: StartOptions): Node { | |
| 56 | return global.start(text, opts); | |
| 57 | } | |
| 58 | ||
| 59 | /** | |
| 60 | * a reference point to create sub-items. this interface is satisfied by | |
| 61 | * - the `progress` module itself | |
| 62 | * - a node recieved by `p.start("label")` | |
| 63 | * - a headless progress runner from `progress.headless()` | |
| 64 | * | |
| 65 | * prefer taking `Ref` as a function parameter, so that the function can be | |
| 66 | * started at the top level, or as a sub-task of an existing node. | |
| 67 | * | |
| 68 | * ```ts | |
| 69 | * function build(p: progress.Ref = progress) { | |
| 70 | * using _ = p.start("build process"); | |
| 71 | * // ... | |
| 72 | * } | |
| 73 | * ``` | |
| 74 | * | |
| 75 | * sometimes it makes sense to have an API recieve `Node` instead of `Ref`, | |
| 76 | * specifically if the caller should have some control over the node name. | |
| 77 | * | |
| 78 | * ```ts | |
| 79 | * await ffmpeg.spawn({ | |
| 80 | * cmd: ["-i", "hello.mov", "-c:v", "svtav1", "hello.mp4"], | |
| 81 | * progress: progress.start("encode hello.mov"), | |
| 82 | * }); | |
| 83 | * ``` | |
| 84 | */ | |
| 85 | export interface Ref { | |
| 86 | /** | |
| 87 | * creates a new trackable unit of work as a child of this one. | |
| 88 | * when given an estimate, a progress bar is rendered. | |
| 89 | */ | |
| 90 | start(text: string, opts?: StartOptions): Node; | |
| 91 | } | |
| 92 | ||
| 93 | /** | |
| 94 | * control for a progress nooe. all fields are primed with setters to trigger | |
| 95 | * ui dispatch automatically. to make tools that use `progress.ts` more | |
| 96 | * modular to a custom progress implementation, many fields are optional. | |
| 97 | */ | |
| 98 | export interface Node extends Ref, Disposable { | |
| 99 | /** end this Node or RootNode. ending collapses children. */ | |
| 100 | end(this: Node): void; | |
| 101 | /** one line of text display. */ | |
| 102 | text: string; | |
| 103 | /** | |
| 104 | * number of items completed. by default this is a count of items, but the | |
| 105 | * `units` property can be passed to `start` to change how the `value` is | |
| 106 | * interpretted when displaying this progress node. | |
| 107 | */ | |
| 108 | value: number; | |
| 109 | /** | |
| 110 | * an estimate of how many items this task contains. | |
| 111 | * when set above `0`, it is shown with a progress bar. | |
| 112 | */ | |
| 113 | total: number; | |
| 114 | /** | |
| 115 | * defaults to increasing by 1. if incrementing causes `value >= estimate`, | |
| 116 | * it also calls `end`. use `node.value += 1` if that behavior is not wanted. | |
| 117 | */ | |
| 118 | inc(this: Node, value?: number): void; | |
| 119 | /** a scoped logger set to output inline on this item. */ | |
| 120 | readonly log: log.RootScope; | |
| 121 | ||
| 122 | /** | |
| 123 | * a time for when this node is estimated to be completed, in milliseconds | |
| 124 | * relative to the unix epoch. by default, this is automatically managed and | |
| 125 | * made read-only. if `StartOptions` specifies `estimateCompletions: false`, | |
| 126 | * this field may be mutated. | |
| 127 | */ | |
| 128 | estimatedTime?: number | null | undefined; | |
| 129 | /** | |
| 130 | * default `true`. `false` will hides a bar + printing total. this is useful | |
| 131 | * for preseving auto-end behavior of `inc` without showing the total. | |
| 132 | */ | |
| 133 | showTotal?: boolean; | |
| 134 | /** | |
| 135 | * default `false`. when true, this progress item is hidden if | |
| 136 | * there are no children. | |
| 137 | */ | |
| 138 | passive?: boolean; | |
| 139 | /** default `false`. when true, this progress item is hidden */ | |
| 140 | hidden?: boolean; | |
| 141 | /** override sorting for children */ | |
| 142 | sortChildren?: ((a: ReadOnlyNode, b: ReadOnlyNode) => number) | null; | |
| 143 | /** | |
| 144 | * specify how `value` and `total` is formed into text. | |
| 145 | * this has some discoverable presets on `StartOptions.units`. | |
| 146 | */ | |
| 147 | valueFormatter?: (value: number, total: number | null) => string; | |
| 148 | } | |
| 149 | ||
| 150 | /** override the default field values of {@linkcode Node} on creation. */ | |
| 151 | export interface StartOptions { | |
| 152 | /** number of items already completed. */ | |
| 153 | value?: number | undefined | null; | |
| 154 | /** when set above `0`, it is shown with a progress bar. */ | |
| 155 | total?: number | undefined | null; | |
| 156 | /** | |
| 157 | * presets for common formattings of `value` and `total`. | |
| 158 | * @default "count" | |
| 159 | */ | |
| 160 | units?: "count" | "bytes" | "percent"; | |
| 161 | /** | |
| 162 | * customize how an estimate is given for progress bars. to make | |
| 163 | * `Node.estimatedTime` mutable, pass `false` here. | |
| 164 | * @default `null`, which is equivilent to `new progress.Ema()` | |
| 165 | */ | |
| 166 | estimateCompletion?: EstimationAlgorithm | boolean | null; | |
| 167 | /** | |
| 168 | * when true, this progress item is hidden if there are no children. | |
| 169 | * this makes sense when scheduling items with a priority queue, where a | |
| 170 | * passive node is only used for grouping, but children are used for | |
| 171 | * indicating status. | |
| 172 | * @default false | |
| 173 | */ | |
| 174 | passive?: boolean | undefined | null; | |
| 175 | /** default `false`. when true, this progress item is hidden */ | |
| 176 | hidden?: boolean | undefined | null; | |
| 177 | /** | |
| 178 | * default `true`. `false` will hides a bar + printing estimate, | |
| 179 | * but preseving auto-end behavior of `inc` | |
| 180 | */ | |
| 181 | showTotal?: boolean | undefined | null; | |
| 182 | /** specify sorting for children */ | |
| 183 | sortChildren?: Node["sortChildren"] | undefined | null; | |
| 184 | /** | |
| 185 | * specify how `value` and `total` is formed into text. | |
| 186 | * this has some discoverable presets on `StartOptions.units`. | |
| 187 | */ | |
| 188 | valueFormatter?: ValueFormatter; | |
| 189 | /** pre-fill the list of log messages */ | |
| 190 | messages?: log.Message[] | undefined; | |
| 191 | } | |
| 192 | ||
| 193 | /** | |
| 194 | * value` will be at least zero, total will be `null` or greater than zero. a | |
| 195 | * custom value formatter can be passed to {@linkcode start} or mutated on an | |
| 196 | * existing node. | |
| 197 | */ | |
| 198 | export type ValueFormatter = (value: number, total: number | null) => string; | |
| 199 | ||
| 200 | /** | |
| 201 | * creates a function to use as a `Node.valueFormatter` by using a single | |
| 202 | * number formatter, and then joining the node's value and total with a "/", | |
| 203 | * but leaving it out if there is no total. | |
| 204 | */ | |
| 205 | export function slashValueFormatter(fmtNumber: (value: number) => string): ValueFormatter { | |
| 206 | return (value: number, total: number | null) => | |
| 207 | value > 0 ? fmtNumber(value) + (total != null && total > 0 ? "/" + fmtNumber(total) : "") : ""; | |
| 208 | } | |
| 209 | ||
| 210 | /** the formatter used when passing `units: "count"` to {@linkcode start} */ | |
| 211 | export const defaultValueFormatter: ValueFormatter = /* @__PURE__ */ slashValueFormatter(String); | |
| 212 | ||
| 213 | /** the formatter used when passing `units: "bytes"` to {@linkcode start} */ | |
| 214 | export const bytesValueFormatter: ValueFormatter = | |
| 215 | /* @__PURE__ */ | |
| 216 | slashValueFormatter(string.formatByteSize); | |
| 217 | ||
| 218 | /** the formatter used when passing `units: "percent"` to {@linkcode start} */ | |
| 219 | export function percentValueFormatter(value: number, total: number | null): string { | |
| 220 | if (total != null && total > 0) { | |
| 221 | value /= total; | |
| 222 | } | |
| 223 | return `${Math.round(value * 1000) / 10}%`; | |
| 224 | } | |
| 225 | ||
| 226 | /** | |
| 227 | * {@linkcode Root} allows overriding it's timing functions, which are used for | |
| 228 | * mostly for rendering and streaming adapters. | |
| 229 | */ | |
| 230 | export interface RootOptions { | |
| 231 | delay?: typeof async.delay; | |
| 232 | /** used for debouncing and UI. time estimation does NOT use this method */ | |
| 233 | now?: typeof performance.now; | |
| 234 | } | |
| 235 | ||
| 236 | /** | |
| 237 | * a headless progress node. when the state of the tree changes, the `change` | |
| 238 | * event is emitted (batched with many changes). the primary use case of using | |
| 239 | * a custom `Root`s is to stream a task's progress over a network or process | |
| 240 | * IPC using {@linkcode encodeByteStream}/{@linkcode encodeEventStream}. | |
| 241 | * another use case is to render a progress task differently. | |
| 242 | * | |
| 243 | * custom events may be dispatched on the `Root`, but this is only useful when | |
| 244 | * streaming, since the streams will carry any custom events. | |
| 245 | */ | |
| 246 | export class Root<Result = void, Map extends Events.Map = ts.EmptyObject> extends Events< | |
| 247 | MergeRootEvents<Result, Map> | |
| 248 | > { | |
| 249 | delay: typeof async.delay; | |
| 250 | now: typeof performance.now; | |
| 251 | #status: "active" | "end" | "error" = "active"; | |
| 252 | #active: Internal[] = []; | |
| 253 | #debounce: async.Cancelable<void> | null = null; | |
| 254 | ||
| 255 | constructor({ delay, now }: RootOptions = {}) { | |
| 256 | super(); | |
| 257 | this.delay = delay ?? async.delay; | |
| 258 | this.now = now ?? performance.now.bind(performance); | |
| 259 | this.on("node-end", (state) => { | |
| 260 | if (state.parent) return; | |
| 261 | const i = this.#active.indexOf(state as Internal); | |
| 262 | ASSERT(i !== -1); | |
| 263 | this.#active.splice(i, 1); | |
| 264 | }); | |
| 265 | } | |
| 266 | ||
| 267 | /** the caller may not mutate `Root`'s state */ | |
| 268 | get active(): readonly ReadOnlyNode[] { | |
| 269 | return this.#active; | |
| 270 | } | |
| 271 | ||
| 272 | /** | |
| 273 | * creates a new trackable unit of work at the top level of this root. | |
| 274 | * when given an estimate, a progress bar is rendered. | |
| 275 | */ | |
| 276 | start(text: string, opts?: StartOptions): Node { | |
| 277 | ASSERT(this.#status === "active"); | |
| 278 | const [state, node] = newNode(this, text, opts); | |
| 279 | this.#active.push(state); | |
| 280 | this.emit("node-start", state, null); | |
| 281 | return node; | |
| 282 | } | |
| 283 | ||
| 284 | /** | |
| 285 | * end this root, as well as all children nodes. this emits the `end` event | |
| 286 | * with the provided value, JSON-serializing it if connected via | |
| 287 | * {@linkcode encodeEventStream} or {@linkcode encodeByteStream}. | |
| 288 | * | |
| 289 | * this function is pre-bound so that it can be passed to `then`: | |
| 290 | * ```ts | |
| 291 | * const root = new Root(); | |
| 292 | * void asyncFunction().then(root.end, root.error); | |
| 293 | * ``` | |
| 294 | */ | |
| 295 | end = (result: Result): void => { | |
| 296 | this.emit("end", result); | |
| 297 | }; | |
| 298 | ||
| 299 | /** | |
| 300 | * end this root, as well as all children nodes. this emits the `error` event. | |
| 301 | */ | |
| 302 | error = (error: unknown): void => { | |
| 303 | this.emit("error", error); | |
| 304 | }; | |
| 305 | ||
| 306 | /** convert this root into a promise. */ | |
| 307 | asPromise(): Promise<Result> { | |
| 308 | return this.once("end").then((x) => x[0]); | |
| 309 | } | |
| 310 | ||
| 311 | /** | |
| 312 | * emit an event on the specified channel. behavior is not defined when manually | |
| 313 | * emitting one of the built in events. | |
| 314 | */ | |
| 315 | override emit<C extends keyof MergeRootEvents<Result, Map>>( | |
| 316 | channel: C, | |
| 317 | ...args: MergeRootEvents<Result, Map>[C] | |
| 318 | ): void { | |
| 319 | ASSERT(this.#status === "active"); | |
| 320 | if (channel !== "change" && rootEvents.has(channel)) this.#emitChangeSoon(); | |
| 321 | if (channel === "error" || channel === "end") { | |
| 322 | this.#end(); | |
| 323 | this.#status = channel; | |
| 324 | } | |
| 325 | super.emit(channel, ...args); | |
| 326 | } | |
| 327 | ||
| 328 | #end() { | |
| 329 | const hasActive = this.#active.length > 0 || !this.#debounce; | |
| 330 | for (const active of this.#active) endNode(this, active); | |
| 331 | this.#active.splice(0, this.#active.length); | |
| 332 | this.#debounce?.cancel(); | |
| 333 | if (hasActive) this.emit("change", this.#active); | |
| 334 | } | |
| 335 | ||
| 336 | #emitChangeSoon = () => { | |
| 337 | if (this.#debounce) return; | |
| 338 | (this.#debounce = this.delay(1)).then(() => { | |
| 339 | this.#debounce = null; | |
| 340 | this.emit("change", this.#active); | |
| 341 | }); | |
| 342 | }; | |
| 343 | } | |
| 344 | ||
| 345 | /** the built in events provided by {@linkcode Root} */ | |
| 346 | export type RootEventMap<Result = void> = { | |
| 347 | // this event is the debounced "ready to re-render" event. do not emit. | |
| 348 | change: [rootNodes: readonly ReadOnlyNode[]]; | |
| 349 | // these events are emitted without a debounce, and are used for general | |
| 350 | // communication within `Root`'s implementation. do not emit. | |
| 351 | "node-start": [newNode: ReadOnlyNode, parentNode: ReadOnlyNode | null]; | |
| 352 | "node-change": [node: ReadOnlyNode, key: keyof ReadOnlyNode]; | |
| 353 | "node-end": [node: ReadOnlyNode]; | |
| 354 | "node-detached-log": [msg: log.Message]; | |
| 355 | // these events are used for result communication. they can be sent via | |
| 356 | // `emit` or the shorthand methods `end` and `error`. sending either will put | |
| 357 | // the `Root` into a "done" state. | |
| 358 | end: [result: Result]; | |
| 359 | error: [error: unknown]; | |
| 360 | }; | |
| 361 | ||
| 362 | const rootEvents: ReadonlySet<unknown> = new Set([ | |
| 363 | "change", | |
| 364 | "node-start", | |
| 365 | "node-change", | |
| 366 | "node-end", | |
| 367 | "node-detached-log", | |
| 368 | "end", | |
| 369 | "error", | |
| 370 | ]); | |
| 371 | ||
| 372 | type MergeRootEvents<Result, Map extends Events.Map> = { | |
| 373 | [K in keyof Map | keyof RootEventMap]: K extends keyof RootEventMap | |
| 374 | ? RootEventMap<Result>[K] | |
| 375 | : Map[K]; | |
| 376 | }; | |
| 377 | ||
| 378 | /** a read-only version of {@linkcode Node}, provided to renderers. */ | |
| 379 | export type ReadOnlyNode = Readonly< | |
| 380 | Pick< | |
| 381 | Required<Node>, | |
| 382 | | "text" | |
| 383 | | "value" | |
| 384 | | "total" | |
| 385 | | "estimatedTime" | |
| 386 | | "passive" | |
| 387 | | "hidden" | |
| 388 | | "showTotal" | |
| 389 | | "sortChildren" | |
| 390 | | "valueFormatter" | |
| 391 | > & { | |
| 392 | /** unique identifier. once {@linkcode Node} is disposed, the key is reused. */ | |
| 393 | key: number; | |
| 394 | logs: readonly log.Message[]; | |
| 395 | /** traverse down the tree. */ | |
| 396 | children: readonly ReadOnlyNode[]; | |
| 397 | /** traverse up the tree. */ | |
| 398 | parent: ReadOnlyNode | null; | |
| 399 | } | |
| 400 | >; | |
| 401 | ||
| 402 | /** internal state is read-write. */ | |
| 403 | interface Internal extends ts.Writeable<ReadOnlyNode> { | |
| 404 | logs: log.Message[]; | |
| 405 | children: Internal[]; | |
| 406 | parent: Internal | null; | |
| 407 | detached: boolean; | |
| 408 | } | |
| 409 | ||
| 410 | // TypeScript Test | |
| 411 | void function (unknown: unknown) { | |
| 412 | unknown as Internal satisfies ReadOnlyNode; | |
| 413 | }; | |
| 414 | ||
| 415 | /** | |
| 416 | * constructs a linked `Node` and `Internal` that dispatches events to `owner`. | |
| 417 | * the `Internal` is the non-reactive source of truth that can be blindly cast | |
| 418 | * to `ReadOnlyNode`, and `Node` is the Node. | |
| 419 | */ | |
| 420 | function newNode<Result, EventMap extends Events.Map>( | |
| 421 | owner: Root<Result, EventMap>, | |
| 422 | text: string, | |
| 423 | opts: StartOptions = {}, | |
| 424 | ): [Internal, Node] { | |
| 425 | let estimator: EstimationAlgorithm | null = null; | |
| 426 | ||
| 427 | const state: Internal = { | |
| 428 | key: globalKeyPool.get(), | |
| 429 | text, | |
| 430 | value: opts.value ?? 0, | |
| 431 | total: opts.total ?? 0, | |
| 432 | estimatedTime: null, | |
| 433 | showTotal: opts.showTotal ?? true, | |
| 434 | passive: opts.passive ?? false, | |
| 435 | hidden: opts.hidden ?? false, | |
| 436 | logs: [...(opts.messages ?? [])], | |
| 437 | children: [], | |
| 438 | parent: null, | |
| 439 | sortChildren: opts.sortChildren ?? null, | |
| 440 | valueFormatter: | |
| 441 | opts.valueFormatter ?? | |
| 442 | { | |
| 443 | count: defaultValueFormatter, | |
| 444 | bytes: bytesValueFormatter, | |
| 445 | percent: percentValueFormatter, | |
| 446 | }[opts.units ?? "count"], | |
| 447 | detached: false, | |
| 448 | }; | |
| 449 | ||
| 450 | if (opts.estimateCompletion !== false) { | |
| 451 | const { estimateCompletion } = opts; | |
| 452 | if (estimateCompletion && typeof estimateCompletion === "object") { | |
| 453 | estimator = estimateCompletion; | |
| 454 | } else estimator = new Ema(); | |
| 455 | } | |
| 456 | estimator?.sample(Date.now(), 0, 0); | |
| 457 | ||
| 458 | function mutate(key: keyof ReadOnlyNode) { | |
| 459 | if (state.detached) return; | |
| 460 | if ( | |
| 461 | key === "value" && | |
| 462 | estimator && | |
| 463 | state.total > 0 && | |
| 464 | state.value > 0 && | |
| 465 | state.value < state.total | |
| 466 | ) { | |
| 467 | // originally, auto-estimation would use the Root's timing primitives, | |
| 468 | // but this makes it so that `state.estimatedTime` is in terms of the | |
| 469 | // Root, which is unintuitive for everyone but the renderer. | |
| 470 | const time = estimator.sample(Date.now(), state.value, state.total); | |
| 471 | if (time && (!state.estimatedTime || state.estimatedTime !== time)) { | |
| 472 | state.estimatedTime = time; | |
| 473 | owner.emit("node-change", state, "estimatedTime"); | |
| 474 | } | |
| 475 | } | |
| 476 | const { parent } = state; | |
| 477 | parent?.sortChildren && parent.children.sort(parent.sortChildren); | |
| 478 | owner.emit("node-change", state, key); | |
| 479 | } | |
| 480 | ||
| 481 | const scope = log.headlessScope((m) => { | |
| 482 | if (state.detached) return owner.emit("node-detached-log", m); | |
| 483 | state.logs.push(m); | |
| 484 | owner.emit("node-change", state, "logs"); | |
| 485 | }); | |
| 486 | ||
| 487 | const binding: Node = { | |
| 488 | start(text, opts) { | |
| 489 | if (state.detached) return nullNode; | |
| 490 | const [child, node] = newNode(owner, text, opts); | |
| 491 | state.children.push(child); | |
| 492 | child.parent = state; | |
| 493 | state.sortChildren && state.children.sort(state.sortChildren); | |
| 494 | owner.emit("node-start", child, state); | |
| 495 | owner.emit("node-change", state, "children"); | |
| 496 | return node; | |
| 497 | }, | |
| 498 | get text() { | |
| 499 | return state.text; | |
| 500 | }, | |
| 501 | set text(value) { | |
| 502 | state.text = value; | |
| 503 | mutate("text"); | |
| 504 | }, | |
| 505 | get value() { | |
| 506 | return state.value; | |
| 507 | }, | |
| 508 | set value(value) { | |
| 509 | state.value = value; | |
| 510 | mutate("value"); | |
| 511 | }, | |
| 512 | get total() { | |
| 513 | return state.total; | |
| 514 | }, | |
| 515 | set total(value) { | |
| 516 | state.total = value; | |
| 517 | mutate("total"); | |
| 518 | }, | |
| 519 | get showTotal() { | |
| 520 | return state.showTotal; | |
| 521 | }, | |
| 522 | set showTotal(value) { | |
| 523 | state.showTotal = value; | |
| 524 | mutate("showTotal"); | |
| 525 | }, | |
| 526 | get passive() { | |
| 527 | return state.passive; | |
| 528 | }, | |
| 529 | set passive(value) { | |
| 530 | state.passive = value; | |
| 531 | mutate("passive"); | |
| 532 | }, | |
| 533 | get hidden() { | |
| 534 | return state.hidden; | |
| 535 | }, | |
| 536 | set hidden(value) { | |
| 537 | state.hidden = value; | |
| 538 | mutate("hidden"); | |
| 539 | }, | |
| 540 | get sortChildren() { | |
| 541 | return state.sortChildren; | |
| 542 | }, | |
| 543 | set sortChildren(value) { | |
| 544 | state.sortChildren = value; | |
| 545 | mutate("sortChildren"); | |
| 546 | }, | |
| 547 | get estimatedTime() { | |
| 548 | return state.estimatedTime; | |
| 549 | }, | |
| 550 | set estimatedTime(date) { | |
| 551 | if (!estimator) { | |
| 552 | state.estimatedTime = date; | |
| 553 | mutate("estimatedTime"); | |
| 554 | } | |
| 555 | }, | |
| 556 | get valueFormatter() { | |
| 557 | return state.valueFormatter; | |
| 558 | }, | |
| 559 | set valueFormatter(value) { | |
| 560 | state.valueFormatter = value; | |
| 561 | mutate("valueFormatter"); | |
| 562 | }, | |
| 563 | inc(delta = 1) { | |
| 564 | state.value += delta; | |
| 565 | if (state.total > 0 && state.value >= state.total) { | |
| 566 | endNode(owner, state); | |
| 567 | } else { | |
| 568 | mutate("value"); | |
| 569 | } | |
| 570 | }, | |
| 571 | log: scope, | |
| 572 | end: () => void endNode(owner, state), | |
| 573 | [Symbol.dispose]: () => void endNode(owner, state), | |
| 574 | }; | |
| 575 | return [state, binding]; | |
| 576 | } | |
| 577 | ||
| 578 | const noop = () => {}; | |
| 579 | noop[Symbol.dispose] = noop; | |
| 580 | ||
| 581 | /** minimal implementation of {@linkcode Node} that has no I/O */ | |
| 582 | export const nullNode: Node = { | |
| 583 | start: () => nullNode, | |
| 584 | get text() { | |
| 585 | return "[detached]"; | |
| 586 | }, | |
| 587 | set text(_) {}, | |
| 588 | get value() { | |
| 589 | return 0; | |
| 590 | }, | |
| 591 | set value(_) {}, | |
| 592 | get total() { | |
| 593 | return 0; | |
| 594 | }, | |
| 595 | set total(_) {}, | |
| 596 | inc: noop, | |
| 597 | log: { | |
| 598 | info: noop, | |
| 599 | warn: noop, | |
| 600 | error: noop, | |
| 601 | log: noop, | |
| 602 | debug: noop, | |
| 603 | writeMessage: noop, | |
| 604 | write: noop, | |
| 605 | scoped() { | |
| 606 | return this; | |
| 607 | }, | |
| 608 | tee: () => noop, | |
| 609 | }, | |
| 610 | end: () => {}, | |
| 611 | [Symbol.dispose]: () => {}, | |
| 612 | }; | |
| 613 | ||
| 614 | function endNode<R, M extends Events.Map>(owner: Root<R, M>, state: Internal) { | |
| 615 | if (state.detached) return; | |
| 616 | state.children.forEach((child) => endNode(owner, child)); | |
| 617 | state.detached = true; | |
| 618 | globalKeyPool.recycle(state.key); | |
| 619 | if (state.parent) { | |
| 620 | const i = state.parent.children.indexOf(state); | |
| 621 | ASSERT(i !== -1); | |
| 622 | state.parent.children.splice(i, 1); | |
| 623 | } | |
| 624 | owner.emit("node-end", state); | |
| 625 | state.logs = []; | |
| 626 | state.parent = null; | |
| 627 | } | |
| 628 | ||
| 629 | /** Convert the top level {@linkcode ReadOnlyNode|ReadOnlyNode[]} into ANSI text. */ | |
| 630 | export function formatAnsi(now: number, list: readonly ReadOnlyNode[]): string { | |
| 631 | let out = ""; | |
| 632 | for (const top of list) { | |
| 633 | if (!isVisible(top)) continue; | |
| 634 | out += renderAnsiMainLine(top, now, 0) + "\n"; | |
| 635 | out += renderChildren(top, now, []); | |
| 636 | } | |
| 637 | return out.trimEnd(); | |
| 638 | } | |
| 639 | ||
| 640 | const spinnerFps = 12.5; | |
| 641 | const box = { tee: "├─ ", line: "│ ", langle: "└─ " }; | |
| 642 | const barChars = [" ", "▏", "▎", "▍", "▌", "▋", "▊", "▉"]; | |
| 643 | const fullBar = "█"; | |
| 644 | const spinner = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"].map((frame) => | |
| 645 | ansi.style(ansi.fgBlue, frame), | |
| 646 | ); | |
| 647 | ||
| 648 | function renderAnsiMainLine(state: ReadOnlyNode, now: number, depth: number) { | |
| 649 | const { text, total, showTotal, value } = state; | |
| 650 | const remaining = remainingSeconds(state); | |
| 651 | const estimate = remaining ? ", " + string.formatDurationLetters(remaining) : ""; | |
| 652 | const valueFormatted = state.valueFormatter(value, total); | |
| 653 | if (total && showTotal) { | |
| 654 | return ( | |
| 655 | ansi.style( | |
| 656 | ansi.bgBrightBlack + ansi.fgBlue, | |
| 657 | formatUnicodeBar(value / total, Math.max(4, depth > 0 ? 12 : 25)), | |
| 658 | ) + | |
| 659 | (valueFormatted || estimate ? ` [${valueFormatted}${estimate}] ` : " ") + | |
| 660 | text | |
| 661 | ); | |
| 662 | } | |
| 663 | const frame = Math.floor(now / (1000 / spinnerFps)) % spinner.length; | |
| 664 | return ( | |
| 665 | (depth === 0 ? spinner[frame] + " " : "") + | |
| 666 | (valueFormatted ? `[${valueFormatted}] ` : "") + | |
| 667 | text | |
| 668 | ); | |
| 669 | } | |
| 670 | ||
| 671 | function renderChildren(state: ReadOnlyNode, now: number, depth: boolean[]) { | |
| 672 | let maxHeight = 50; // TODO: flexible layout | |
| 673 | let truncated = 0; | |
| 674 | let out = ""; | |
| 675 | const children = state.children.filter(isVisible); | |
| 676 | if (children.length === 0) return ""; | |
| 677 | for (let i = 0, { length } = children; i < length; i += 1) { | |
| 678 | const child = UNWRAP(children[i]); | |
| 679 | let item = ""; | |
| 680 | const left = depth.map((x) => (x ? box.line : " ")).join(""); | |
| 681 | item += left + (i === length - 1 && !truncated ? box.langle : box.tee); | |
| 682 | item += renderAnsiMainLine(child, now, 1 + depth.length) + "\n"; | |
| 683 | item += renderChildren(child, now, depth.concat(i < length - 1)); | |
| 684 | const h = string.countNewlines(item); | |
| 685 | if (h > maxHeight) { | |
| 686 | truncated += 1; | |
| 687 | continue; | |
| 688 | } | |
| 689 | const logLines = child.logs | |
| 690 | .map((msg) => log.formatAnsiMessage(msg, true)) | |
| 691 | .join("") | |
| 692 | .trim(); | |
| 693 | if (logLines) { | |
| 694 | for (const line of logLines.split("\n").slice(-Math.min(maxLogLines, maxHeight))) { | |
| 695 | item += | |
| 696 | left + | |
| 697 | (i === length - 1 && !truncated ? " " : box.line) + | |
| 698 | " " + | |
| 699 | ansi.style(ansi.fgBrightBlack, ">") + | |
| 700 | " " + | |
| 701 | line + | |
| 702 | "\n"; | |
| 703 | } | |
| 704 | } | |
| 705 | maxHeight -= h + string.countNewlines(logLines); | |
| 706 | out += item; | |
| 707 | } | |
| 708 | if (truncated) { | |
| 709 | out += depth.map((x) => (x ? box.line : " ")).join("") + box.langle; | |
| 710 | out += ansi.style(ansi.fgBrightBlack, `[${truncated} more]`) + "\n"; | |
| 711 | } | |
| 712 | return out; | |
| 713 | } | |
| 714 | ||
| 715 | /** | |
| 716 | * This function is derived from an old program I wrote back in 2020 called `f` | |
| 717 | * which did ffmpeg handling. It is probably one of the coolest progress bars | |
| 718 | * ever imagined. | |
| 719 | */ | |
| 720 | export function formatUnicodeBar(progress: number, width: number): string { | |
| 721 | if (progress >= 1) return fullBar.repeat(width); | |
| 722 | if (progress <= 0 || Number.isNaN(progress)) return " ".repeat(width); | |
| 723 | ||
| 724 | const wholeWidth = Math.floor(progress * width); | |
| 725 | const remainderWidth = (progress * width) % 1; | |
| 726 | const partWidth = Math.floor(remainderWidth * 8); | |
| 727 | let partChar = barChars[partWidth]; | |
| 728 | if (width - wholeWidth - 1 < 0) partChar = ""; | |
| 729 | ||
| 730 | const fill = fullBar.repeat(wholeWidth); | |
| 731 | const empty = " ".repeat(width - wholeWidth - 1); | |
| 732 | ||
| 733 | return fill + partChar + empty; | |
| 734 | } | |
| 735 | ||
| 736 | /** | |
| 737 | * configure a {@linkcode Root} to display its contents to a | |
| 738 | * {@linkcode log.HeadlessWidgetHost}. this is used by the global progress | |
| 739 | * instance to output to the terminal. | |
| 740 | * ```ts | |
| 741 | * const globalProgress = new progress.Root(); | |
| 742 | * progress.attachToScreen(log); | |
| 743 | * ``` | |
| 744 | * unit tests use a mock screen instead of the terminal. | |
| 745 | * ```ts | |
| 746 | * const screen = new TestWidgetHost(); | |
| 747 | * const root = new progress.Root(screen.delay); | |
| 748 | * root.attachToScreen(screen); | |
| 749 | * // can use the progress API | |
| 750 | * root.start("hello"); | |
| 751 | * // and then examine the contents | |
| 752 | * screen.expectFrame(...); | |
| 753 | * ``` | |
| 754 | */ | |
| 755 | export function attachToScreen( | |
| 756 | root: Root, | |
| 757 | { writeOutput, startWidget }: Pick<log.WidgetHost, "writeOutput" | "startWidget">, | |
| 758 | ): ts.Dispose { | |
| 759 | const stack = new DisposableStack(); | |
| 760 | let widget: log.WidgetInstance | null = null; | |
| 761 | ||
| 762 | stack.use( | |
| 763 | root.on("change", (items) => { | |
| 764 | if (items.length > 0) { | |
| 765 | widget ??= | |
| 766 | startWidget({ | |
| 767 | format: ({ now }) => formatAnsi(now, root.active), | |
| 768 | }) ?? null; | |
| 769 | if (!widget) return; | |
| 770 | // scan for spinners and visible estimations | |
| 771 | widget.fps = items.some((x) => !x.hidden && !(x.showTotal !== false && x.total > 0)) | |
| 772 | ? spinnerFps | |
| 773 | : hasEstimate(items) | |
| 774 | ? 1 | |
| 775 | : null; | |
| 776 | widget.redraw(); | |
| 777 | } else { | |
| 778 | widget?.stop(); | |
| 779 | widget = null; | |
| 780 | } | |
| 781 | }), | |
| 782 | ); | |
| 783 | stack.use( | |
| 784 | root.on("node-detached-log", (msg) => { | |
| 785 | writeOutput(log.formatAnsiMessage(msg, true)); | |
| 786 | }), | |
| 787 | ); | |
| 788 | stack.use( | |
| 789 | root.on("node-end", (node) => { | |
| 790 | let title = node.text; | |
| 791 | let p: ReadOnlyNode | null = node; | |
| 792 | while ((p = p.parent)) title = p.text + " / " + title; | |
| 793 | const { logs } = node; | |
| 794 | if (logs.length > 0) { | |
| 795 | const header = `[logs from ${title}]`; | |
| 796 | writeOutput(ansi.style(ansi.fgBrightBlack, header) + "\n"); | |
| 797 | writeOutput(logs.map((msg) => log.formatAnsiMessage(msg, true)).join("")); | |
| 798 | } | |
| 799 | }), | |
| 800 | ); | |
| 801 | ||
| 802 | stack.defer(() => { | |
| 803 | widget?.stop(); | |
| 804 | widget = null; | |
| 805 | }); | |
| 806 | ||
| 807 | return ts.defer(() => stack.dispose()); | |
| 808 | } | |
| 809 | ||
| 810 | function hasEstimate(nodes: readonly ReadOnlyNode[]): boolean { | |
| 811 | return nodes.some((n) => n.estimatedTime != null || hasEstimate(n.children)); | |
| 812 | } | |
| 813 | ||
| 814 | const header = /* @__PURE__ */ string.encodeUtf8("clover's progress <3\n"); | |
| 815 | ||
| 816 | /** the 'Content-Type' of {@linkcode encodeByteStream}'s return value */ | |
| 817 | export const contentType = "application/x-clover-progress"; | |
| 818 | ||
| 819 | /** | |
| 820 | * opaque but JSON-compatible serialized state, either containing the entire | |
| 821 | * progress tree or just a delta update. this data structure always be future | |
| 822 | * and backwards compatbile with changes to this library. if a breaking change | |
| 823 | * is made, the APIs in this file will be renamed. | |
| 824 | * | |
| 825 | * for details on the format, read the source of {@linkcode encodeEventStream} or | |
| 826 | * {@linkcode encodeByteStream} | |
| 827 | */ | |
| 828 | export type StreamEvent = Array<number | StreamNode | StreamCustomEvent | StreamRootChildren>; | |
| 829 | const kNode = Symbol("underlyingNode"); | |
| 830 | /** @internal */ | |
| 831 | export interface StreamNode { | |
| 832 | k: EncodedKey; | |
| 833 | /** text, overwrite */ | |
| 834 | t?: string | undefined; | |
| 835 | /** value, overwrite */ | |
| 836 | v?: number | undefined; | |
| 837 | /** total, overwrite */ | |
| 838 | e?: number | undefined; | |
| 839 | /** showTotal, overwrite */ | |
| 840 | s?: boolean | undefined; | |
| 841 | /** passive, overwrite */ | |
| 842 | p?: boolean | undefined; | |
| 843 | /** logs, append */ | |
| 844 | l?: log.Message[] | undefined; | |
| 845 | /** hidden, overwrite */ | |
| 846 | h?: boolean | undefined; | |
| 847 | /** children, overwrite */ | |
| 848 | c?: EncodedKey[] | undefined; | |
| 849 | /** estimated time, in unix milliseconds, overwrite */ | |
| 850 | E?: number | null | undefined; | |
| 851 | /** formatted value if not defaultValueFormatter, overwrite */ | |
| 852 | V?: string | undefined; | |
| 853 | ||
| 854 | /** access to the underlying node is used by {@linkcode encodeByteStream} */ | |
| 855 | [kNode]?: ReadOnlyNode; | |
| 856 | } | |
| 857 | type StreamCustomEvent = [ | |
| 858 | /** custom event name or "end" */ | |
| 859 | e: "end" | string, | |
| 860 | /** custom event payload */ | |
| 861 | ...args: ts.Json[], | |
| 862 | ]; | |
| 863 | type StreamRootChildren = number[]; | |
| 864 | ||
| 865 | /** options for both {@linkcode encodeEventStream} and {@linkcode encodeByteStream} */ | |
| 866 | export interface EncodeStreamOptions { | |
| 867 | /** | |
| 868 | * set the minimum time between packets. | |
| 869 | * @default 1000 / 30 (30fps) | |
| 870 | */ | |
| 871 | throttleMs?: number; | |
| 872 | /** | |
| 873 | * `log.ts` is capable of capturing stack traces from all log calls. by | |
| 874 | * seeing this to true, those traces will be serialized. disabled by default | |
| 875 | * for privacy reasons. | |
| 876 | */ | |
| 877 | serializeStackTraces?: boolean; | |
| 878 | } | |
| 879 | ||
| 880 | /** | |
| 881 | * converts a {@linkcode Root|progress.Root} into an object stream | |
| 882 | * for communicating progress over the process or network boundary. | |
| 883 | * | |
| 884 | * if the given root adds custom event handlers, they must all have | |
| 885 | * json-serializable payloads. | |
| 886 | */ | |
| 887 | export function encodeEventStream<Result extends ts.Json, Map extends { [key: string]: ts.Json[] }>( | |
| 888 | // fun fact, this type is impossible to write without generics | |
| 889 | root: Root<Result, Map>, | |
| 890 | { throttleMs = 1000 / 30 /* 30fps */, serializeStackTraces = false }: EncodeStreamOptions = {}, | |
| 891 | ): ReadableStream<StreamEvent> { | |
| 892 | const { delay, now } = root; | |
| 893 | const stack = new DisposableStack(); | |
| 894 | const s = new Encoder(root, serializeStackTraces); | |
| 895 | const ready = new async.Watch(false); | |
| 896 | ||
| 897 | let lastEvent = now(); | |
| 898 | let timer: async.Cancelable<void> | null = null; | |
| 899 | function emitSoon() { | |
| 900 | if (timer || ready.value) return; | |
| 901 | const remaining = lastEvent + throttleMs - now(); | |
| 902 | if (remaining > 0) { | |
| 903 | (timer = delay(remaining)).then(() => { | |
| 904 | timer = null; | |
| 905 | ready.value = true; | |
| 906 | lastEvent = now(); | |
| 907 | }); | |
| 908 | } else { | |
| 909 | ready.value = true; | |
| 910 | lastEvent = now(); | |
| 911 | } | |
| 912 | } | |
| 913 | /** terminal events skip the throttle so the stream ends promptly */ | |
| 914 | function emitNow() { | |
| 915 | timer?.cancel(); | |
| 916 | timer = null; | |
| 917 | ready.value = true; | |
| 918 | lastEvent = now(); | |
| 919 | } | |
| 920 | stack.defer(() => { | |
| 921 | if (timer) timer.cancel(); | |
| 922 | s.changed.clear(); | |
| 923 | s.deleted.clear(); | |
| 924 | }); | |
| 925 | ||
| 926 | let isFirst = true; | |
| 927 | return new ReadableStream( | |
| 928 | { | |
| 929 | start(controller) { | |
| 930 | if (root.active.length > 0) { | |
| 931 | controller.enqueue(s.getDelta(isFirst)); | |
| 932 | isFirst = false; | |
| 933 | } | |
| 934 | ||
| 935 | stack.use(s.observe(emitSoon, emitNow)); | |
| 936 | }, | |
| 937 | async pull(controller) { | |
| 938 | await ready.until((x) => x === true); | |
| 939 | ready.value = false; | |
| 940 | if (timer) { | |
| 941 | timer.cancel(); | |
| 942 | timer = null; | |
| 943 | } | |
| 944 | controller.enqueue(s.getDelta(isFirst)); | |
| 945 | isFirst = false; | |
| 946 | if (s.ended) { | |
| 947 | controller.close(); | |
| 948 | stack.dispose(); | |
| 949 | } | |
| 950 | }, | |
| 951 | cancel() { | |
| 952 | stack.dispose(); | |
| 953 | }, | |
| 954 | }, | |
| 955 | { highWaterMark: 0 }, | |
| 956 | ); | |
| 957 | } | |
| 958 | ||
| 959 | /** | |
| 960 | * decodes a stream created by {@linkcode encodeEventStream} into managed calls | |
| 961 | * to `target.start`. when cancelling, all the managed nodes are destroyed. | |
| 962 | */ | |
| 963 | export function decodeEventStream<Result = void, EventMap extends Events.Map = ts.EmptyObject>( | |
| 964 | encoded: ReadableStream<StreamEvent>, | |
| 965 | target: Ref, | |
| 966 | ): async.Cancelable<Result> & Events<EventMap> { | |
| 967 | const { resolve, reject, promise } = Promise.withResolvers<Result>(); | |
| 968 | const events = new Events<EventMap>(); | |
| 969 | const ctrl = new AbortController(); | |
| 970 | const decoder = new Decoder(target, events, resolve); | |
| 971 | const { signal } = ctrl; | |
| 972 | (async () => { | |
| 973 | if (signal.aborted) return; | |
| 974 | const reader = encoded.getReader(); | |
| 975 | const cancel = () => void reader.cancel(signal.reason).catch(() => {}); | |
| 976 | signal.addEventListener("abort", cancel, { once: true }); | |
| 977 | try { | |
| 978 | let hasEmittedEnd = false; | |
| 979 | while (true) { | |
| 980 | signal.throwIfAborted(); | |
| 981 | const { value, done } = await reader.read(); | |
| 982 | signal.throwIfAborted(); | |
| 983 | if (done) break; | |
| 984 | if (decoder.processEvent(value)) hasEmittedEnd = true; | |
| 985 | } | |
| 986 | ASSERT(hasEmittedEnd, "Stream terminated early."); | |
| 987 | } catch (err) { | |
| 988 | reader.cancel(err); | |
| 989 | if (!signal.aborted) reject(err); | |
| 990 | } finally { | |
| 991 | signal.removeEventListener("abort", cancel); | |
| 992 | decoder.reset(); | |
| 993 | reader.releaseLock(); | |
| 994 | } | |
| 995 | })(); | |
| 996 | return Object.assign(ts.mixin(promise, events), { | |
| 997 | cancel: (e?: unknown) => { | |
| 998 | ctrl.abort(e); | |
| 999 | decoder.reset(); | |
| 1000 | }, | |
| 1001 | [Symbol.dispose]: () => { | |
| 1002 | ctrl.abort(); | |
| 1003 | decoder.reset(); | |
| 1004 | }, | |
| 1005 | }); | |
| 1006 | } | |
| 1007 | ||
| 1008 | /** | |
| 1009 | * by listening to the event interface in {@linkcode Root}, compute delta | |
| 1010 | * events for a progress stream. the caller is expected to fill `changed` and `deleted` | |
| 1011 | */ | |
| 1012 | class Encoder<Result extends ts.Json, EventMap extends { [key: string]: ts.Json[] }> { | |
| 1013 | root: Root<Result, EventMap>; | |
| 1014 | pool: KeyPool<EncodedKey> = new KeyPool(); | |
| 1015 | keyMap: Map<ReadOnlyNode, EncodedKey> = new Map(); | |
| 1016 | logsLength: WeakMap<ReadOnlyNode, number> = new WeakMap(); | |
| 1017 | serializeStackTraces: boolean; | |
| 1018 | ||
| 1019 | rootChildrenUpdated = true; | |
| 1020 | changed = new Map<ReadOnlyNode, Set<keyof ReadOnlyNode>>(); | |
| 1021 | deleted = new Set<ReadOnlyNode>(); | |
| 1022 | /** custom and terminal ("end"/"error") events awaiting the next delta */ | |
| 1023 | pendingEvents: StreamCustomEvent[] = []; | |
| 1024 | /** set when "end" or "error" was recorded; the stream closes after flushing */ | |
| 1025 | ended = false; | |
| 1026 | ||
| 1027 | constructor(root: Root<Result, EventMap>, serializeStackTraces: boolean = false) { | |
| 1028 | this.root = root; | |
| 1029 | this.serializeStackTraces = serializeStackTraces; | |
| 1030 | for (const node of root.active) { | |
| 1031 | this.changed.set(node, new Set()); | |
| 1032 | } | |
| 1033 | } | |
| 1034 | ||
| 1035 | /** | |
| 1036 | * fill `changed`, `deleted`, and `pendingEvents` from the events of the | |
| 1037 | * root. `soon` is called when there is a delta worth sending, and `now` | |
| 1038 | * when it is the last one. | |
| 1039 | */ | |
| 1040 | observe(soon: () => void, now: () => void): DisposableStack { | |
| 1041 | const { root } = this; | |
| 1042 | const stack = new DisposableStack(); | |
| 1043 | // TODO: why does ts require two args | |
| 1044 | stack.use( | |
| 1045 | root.on("node-start", (newNode, _) => { | |
| 1046 | if (!newNode.parent) this.rootChildrenUpdated = true; | |
| 1047 | this.changed.set(newNode, new Set()); | |
| 1048 | soon(); | |
| 1049 | }), | |
| 1050 | ); | |
| 1051 | stack.use( | |
| 1052 | root.on("node-change", (node, key) => { | |
| 1053 | let set = this.changed.get(node); | |
| 1054 | if (!set) this.changed.set(node, (set = new Set())); | |
| 1055 | set.add(key); | |
| 1056 | soon(); | |
| 1057 | }), | |
| 1058 | ); | |
| 1059 | stack.use( | |
| 1060 | root.on("node-end", (node) => { | |
| 1061 | if (!node.parent) this.rootChildrenUpdated = true; | |
| 1062 | this.changed.delete(node); | |
| 1063 | this.deleted.add(node); // handle add and remove in same frame | |
| 1064 | soon(); | |
| 1065 | }), | |
| 1066 | ); | |
| 1067 | // custom events pass through the stream verbatim | |
| 1068 | stack.use( | |
| 1069 | root.onAny((channel, args) => { | |
| 1070 | if (rootEvents.has(channel)) return; | |
| 1071 | this.pendingEvents.push([channel as string, ...(args as ts.Json[])]); | |
| 1072 | soon(); | |
| 1073 | }), | |
| 1074 | ); | |
| 1075 | stack.use( | |
| 1076 | root.on("end", (result) => { | |
| 1077 | this.pendingEvents.push(["end", result]); | |
| 1078 | this.ended = true; | |
| 1079 | now(); | |
| 1080 | }), | |
| 1081 | ); | |
| 1082 | stack.use( | |
| 1083 | root.on("error", (error) => { | |
| 1084 | this.pendingEvents.push(["error", exceptions.message(error)]); | |
| 1085 | this.ended = true; | |
| 1086 | now(); | |
| 1087 | }), | |
| 1088 | ); | |
| 1089 | return stack; | |
| 1090 | } | |
| 1091 | ||
| 1092 | getDelta(isFirst = false) { | |
| 1093 | const deleted = Array.from(this.deleted, (node) => [node, this.keyMap.get(node)] as const) | |
| 1094 | .filter(([node, dst]) => { | |
| 1095 | if (dst == null) return false; // started and stopped in same tick | |
| 1096 | this.pool.recycle(dst); | |
| 1097 | this.keyMap.delete(node); | |
| 1098 | return true; | |
| 1099 | }) | |
| 1100 | .map((x) => UNWRAP(x[1])); | |
| 1101 | ASSERT(!isFirst || deleted.length === 0); | |
| 1102 | ||
| 1103 | const { updated = [], started = [] } = Object.groupBy(this.changed, ([node]) => | |
| 1104 | this.keyMap.has(node) ? "updated" : "started", | |
| 1105 | ); | |
| 1106 | ||
| 1107 | const payload: StreamEvent = [isFirst ? -1 : deleted.length, ...deleted]; | |
| 1108 | ||
| 1109 | if (started.length > 0) { | |
| 1110 | this.beginNodesDepthFirst( | |
| 1111 | started.reverse().map(([node]) => node), | |
| 1112 | payload, | |
| 1113 | ); | |
| 1114 | } | |
| 1115 | ||
| 1116 | for (const [node, props] of updated) { | |
| 1117 | let logs = undefined; | |
| 1118 | if (props.has("logs") && node.logs.length > (this.logsLength.get(node) ?? 0)) { | |
| 1119 | logs = this.redactLogsIfNeeded(node.logs.slice(this.logsLength.get(node) ?? 0)); | |
| 1120 | this.logsLength.set(node, node.logs.length); | |
| 1121 | } | |
| 1122 | payload.push({ | |
| 1123 | k: UNWRAP(this.keyMap.get(node)), | |
| 1124 | t: props.has("text") ? node.text : undefined, | |
| 1125 | v: props.has("value") ? node.value : undefined, | |
| 1126 | e: props.has("total") ? node.total : undefined, | |
| 1127 | s: props.has("showTotal") ? node.showTotal : undefined, | |
| 1128 | p: props.has("passive") ? node.passive : undefined, | |
| 1129 | l: logs, | |
| 1130 | h: props.has("hidden") ? node.hidden : undefined, | |
| 1131 | c: props.has("children") | |
| 1132 | ? node.children.map((node) => UNWRAP(this.keyMap.get(node))) | |
| 1133 | : undefined, | |
| 1134 | E: props.has("estimatedTime") ? node.estimatedTime : undefined, | |
| 1135 | V: | |
| 1136 | node.valueFormatter !== defaultValueFormatter && | |
| 1137 | (props.has("value") || props.has("total") || props.has("valueFormatter")) | |
| 1138 | ? node.valueFormatter(node.value, node.total) | |
| 1139 | : undefined, | |
| 1140 | [kNode]: node, | |
| 1141 | }); | |
| 1142 | } | |
| 1143 | ||
| 1144 | if (this.rootChildrenUpdated) { | |
| 1145 | payload.push(this.root.active.map((node) => UNWRAP(this.keyMap.get(node)))); | |
| 1146 | } | |
| 1147 | ||
| 1148 | payload.push(...this.pendingEvents); | |
| 1149 | ||
| 1150 | this.deleted.clear(); | |
| 1151 | this.changed.clear(); | |
| 1152 | this.pendingEvents = []; | |
| 1153 | this.rootChildrenUpdated = false; | |
| 1154 | ||
| 1155 | return payload; | |
| 1156 | } | |
| 1157 | ||
| 1158 | beginNodesDepthFirst(nodes: ReadOnlyNode[], out: StreamEvent) { | |
| 1159 | for (let i = 0; i < nodes.length; i += 1) { | |
| 1160 | const node = UNWRAP(nodes[i]); | |
| 1161 | nodes.push(...node.children); | |
| 1162 | } | |
| 1163 | let next; | |
| 1164 | while ((next = nodes.pop())) { | |
| 1165 | if (!this.keyMap.has(next)) { | |
| 1166 | out.push(this.beginNode(next)); | |
| 1167 | } | |
| 1168 | } | |
| 1169 | } | |
| 1170 | ||
| 1171 | beginNode(node: ReadOnlyNode): StreamNode { | |
| 1172 | const k = this.pool.get(); | |
| 1173 | this.keyMap.set(node, k); | |
| 1174 | ||
| 1175 | return { | |
| 1176 | k, | |
| 1177 | v: node.value !== 0 ? node.value : undefined, | |
| 1178 | e: node.total !== 0 ? node.total : undefined, | |
| 1179 | t: node.text, | |
| 1180 | l: | |
| 1181 | node.logs.length > 0 | |
| 1182 | ? (this.logsLength.set(node, node.logs.length), | |
| 1183 | this.redactLogsIfNeeded(node.logs.slice())) | |
| 1184 | : undefined, | |
| 1185 | c: | |
| 1186 | node.children.length > 0 | |
| 1187 | ? node.children.map((node) => | |
| 1188 | UNWRAP(this.keyMap.get(node), () => [node.children, this.keyMap]), | |
| 1189 | ) | |
| 1190 | : undefined, | |
| 1191 | p: node.passive === true ? true : undefined, | |
| 1192 | s: node.showTotal === false ? false : undefined, | |
| 1193 | h: node.hidden === true ? true : undefined, | |
| 1194 | E: node.estimatedTime != null ? node.estimatedTime : undefined, | |
| 1195 | V: | |
| 1196 | node.valueFormatter !== defaultValueFormatter | |
| 1197 | ? node.valueFormatter(node.value, node.total) | |
| 1198 | : undefined, | |
| 1199 | [kNode]: node, | |
| 1200 | }; | |
| 1201 | } | |
| 1202 | ||
| 1203 | redactLogsIfNeeded(messages: log.Message[]) { | |
| 1204 | if (this.serializeStackTraces) return messages; | |
| 1205 | return messages.map((x) => ({ ...x, stack: undefined })); | |
| 1206 | } | |
| 1207 | } | |
| 1208 | ||
| 1209 | class Decoder<Result = void, EventMap extends Events.Map = ts.EmptyObject> { | |
| 1210 | target: Ref; | |
| 1211 | events: Events<EventMap>; | |
| 1212 | resolve: (result: Result) => void; | |
| 1213 | active = new Map<EncodedKey, Node>(); | |
| 1214 | pendingStart = new Map<EncodedKey, PendingStart>(); | |
| 1215 | pendingParents = new Map<EncodedKey, EncodedKey>(); | |
| 1216 | ||
| 1217 | constructor(target: Ref, events: Events<EventMap>, resolve: (result: Result) => void) { | |
| 1218 | this.target = target; | |
| 1219 | this.events = events; | |
| 1220 | this.resolve = resolve; | |
| 1221 | } | |
| 1222 | ||
| 1223 | reset() { | |
| 1224 | for (const v of this.active.values()) v.end(); | |
| 1225 | this.active.clear(); | |
| 1226 | } | |
| 1227 | ||
| 1228 | /** returns whether the stream signalled "end"; throws on "error" */ | |
| 1229 | processEvent(event: StreamEvent): boolean { | |
| 1230 | event = event.slice(); | |
| 1231 | ||
| 1232 | // delete unreferenced nodes first, since they may get re-allocated | |
| 1233 | // in the next phase. | |
| 1234 | const deletedCount = event.shift(); | |
| 1235 | ASSERT(typeof deletedCount === "number"); | |
| 1236 | if (deletedCount === -1) { | |
| 1237 | // connection reset, delete all nodes | |
| 1238 | this.reset(); | |
| 1239 | } else { | |
| 1240 | for (const id of event.splice(0, deletedCount)) { | |
| 1241 | ASSERT(typeof id === "number"); | |
| 1242 | UNWRAP( | |
| 1243 | this.active.get(id as EncodedKey), | |
| 1244 | () => `dstkey ${id} not found, ${[...this.active.keys()]}`, | |
| 1245 | ).end(); | |
| 1246 | ASSERT(this.active.delete(id as EncodedKey)); | |
| 1247 | } | |
| 1248 | } | |
| 1249 | ||
| 1250 | // parse all of the events, updating all nodes and extracting creations | |
| 1251 | let resolving = false; | |
| 1252 | let resolvingValue: unknown = null; | |
| 1253 | let updateChildren: Array<{ | |
| 1254 | key: EncodedKey; | |
| 1255 | children: EncodedKey[]; | |
| 1256 | }> = []; | |
| 1257 | for (const chunk of event) { | |
| 1258 | ASSERT(typeof chunk === "object"); | |
| 1259 | if (Array.isArray(chunk)) { | |
| 1260 | if (typeof chunk[0] === "string") { | |
| 1261 | if (chunk[0] === "end") { | |
| 1262 | resolving = true; | |
| 1263 | resolvingValue = chunk[1]; | |
| 1264 | } else if (chunk[0] === "error") { | |
| 1265 | throw new Error(`Progress stream error: ${chunk[1]}`); | |
| 1266 | } else { | |
| 1267 | // custom event | |
| 1268 | // @ts-expect-error TODO: typescript soundness | |
| 1269 | this.events.emit(...chunk); | |
| 1270 | } | |
| 1271 | } else { | |
| 1272 | const ids = chunk as EncodedKey[]; | |
| 1273 | for (const id of ids) this.pendingParents.set(id, 0 as EncodedKey); | |
| 1274 | } | |
| 1275 | } else { | |
| 1276 | // node update or creation | |
| 1277 | const { | |
| 1278 | k: key, | |
| 1279 | t: text, | |
| 1280 | v: value, | |
| 1281 | e: total, | |
| 1282 | s: showTotal, | |
| 1283 | p: passive, | |
| 1284 | h: hidden, | |
| 1285 | l: messages, | |
| 1286 | c: children, | |
| 1287 | E: estimatedTime, | |
| 1288 | V: formattedValue, | |
| 1289 | } = chunk; | |
| 1290 | const node = this.active.get(key); | |
| 1291 | if (children) { | |
| 1292 | for (const id of children) this.pendingParents.set(id, key); | |
| 1293 | updateChildren.push({ key, children }); | |
| 1294 | } | |
| 1295 | if (node) { | |
| 1296 | if (text !== undefined) node.text = text; | |
| 1297 | if (value !== undefined) node.value = value; | |
| 1298 | if (total !== undefined) node.total = total; | |
| 1299 | if (showTotal != null) node.showTotal = showTotal; | |
| 1300 | if (passive != null) node.passive = passive; | |
| 1301 | if (hidden != null) node.hidden = hidden; | |
| 1302 | for (const message of messages ?? []) node.log.writeMessage(message); | |
| 1303 | if (estimatedTime !== undefined) node.estimatedTime = estimatedTime; | |
| 1304 | if (formattedValue !== undefined) node.valueFormatter = () => formattedValue; | |
| 1305 | for (const id of children ?? []) this.pendingParents.set(id, key); | |
| 1306 | } else { | |
| 1307 | ASSERT(text != null, `DstKey ${key} not active but not created`); | |
| 1308 | this.pendingStart.set(key, { | |
| 1309 | text, | |
| 1310 | value, | |
| 1311 | total, | |
| 1312 | showTotal, | |
| 1313 | passive, | |
| 1314 | hidden, | |
| 1315 | messages, | |
| 1316 | estimatedTime, | |
| 1317 | formattedValue, | |
| 1318 | }); | |
| 1319 | } | |
| 1320 | } | |
| 1321 | } | |
| 1322 | ||
| 1323 | let entry; | |
| 1324 | while ((entry = this.pendingStart.entries().next().value)) { | |
| 1325 | const [key, value] = entry; | |
| 1326 | this.startRecursive(key, value); | |
| 1327 | } | |
| 1328 | this.pendingParents.clear(); | |
| 1329 | ||
| 1330 | // validate that every referenced child became active; a failure here | |
| 1331 | // means the stream is desynced | |
| 1332 | for (const { key, children } of updateChildren) { | |
| 1333 | UNWRAP(this.active.get(key), () => `parent ${key} not active`); | |
| 1334 | for (const id of children) { | |
| 1335 | UNWRAP(this.active.get(id), () => `child ${id} not active`); | |
| 1336 | } | |
| 1337 | } | |
| 1338 | ||
| 1339 | if (resolving) { | |
| 1340 | this.reset(); | |
| 1341 | this.resolve(resolvingValue as Result); | |
| 1342 | return true; | |
| 1343 | } | |
| 1344 | return false; | |
| 1345 | } | |
| 1346 | ||
| 1347 | startRecursive(key: EncodedKey, opts: PendingStart): Ref { | |
| 1348 | ASSERT(this.pendingStart.delete(key)); | |
| 1349 | // a node whose parent linkage is missing (malformed stream) attaches at | |
| 1350 | // the root rather than killing the whole decoder | |
| 1351 | const parentId = this.pendingParents.get(key) ?? (0 as EncodedKey); | |
| 1352 | let ref: Ref | null = parentId === 0 ? this.target : (this.active.get(parentId) ?? null); | |
| 1353 | if (!ref) { | |
| 1354 | ref = this.startRecursive(parentId, UNWRAP(this.pendingStart.get(parentId))); | |
| 1355 | } | |
| 1356 | const { text, estimatedTime, formattedValue, ...options } = opts; | |
| 1357 | const node = ref.start(text, { | |
| 1358 | ...options, | |
| 1359 | estimateCompletion: false, | |
| 1360 | }); | |
| 1361 | this.active.set(key, node); | |
| 1362 | node.estimatedTime = estimatedTime; | |
| 1363 | if (formattedValue !== undefined) node.valueFormatter = () => formattedValue; | |
| 1364 | this.pendingParents.delete(key); | |
| 1365 | return node; | |
| 1366 | } | |
| 1367 | } | |
| 1368 | ||
| 1369 | interface PendingStart { | |
| 1370 | text: string; | |
| 1371 | value?: number | undefined; | |
| 1372 | total?: number | undefined; | |
| 1373 | showTotal?: boolean | undefined; | |
| 1374 | passive?: boolean | undefined; | |
| 1375 | hidden?: boolean | undefined; | |
| 1376 | messages?: log.Message[] | undefined; | |
| 1377 | estimatedTime?: number | null | undefined; | |
| 1378 | formattedValue?: string | undefined; | |
| 1379 | } | |
| 1380 | ||
| 1381 | /** this interface must not have more than 16 properties */ | |
| 1382 | interface DeltaFlags { | |
| 1383 | // the new status is always present since it's one bit | |
| 1384 | hidden: boolean; | |
| 1385 | passive: boolean; | |
| 1386 | showTotal: boolean; | |
| 1387 | ||
| 1388 | // for each packet that changed it stores out of line | |
| 1389 | changedValue: boolean; | |
| 1390 | changedTotal: boolean; | |
| 1391 | changedLogs: boolean; | |
| 1392 | changedText: boolean; | |
| 1393 | changedChildren: boolean; | |
| 1394 | changedEstimatedTime: boolean; | |
| 1395 | changedFormattedValue: boolean; | |
| 1396 | ||
| 1397 | estimatedTimeNonNull: boolean; | |
| 1398 | ||
| 1399 | futureProofingPacket: boolean; | |
| 1400 | deleted: boolean; | |
| 1401 | } | |
| 1402 | ||
| 1403 | /** | |
| 1404 | * NOTE: this function contains bugs and its format is not yet stabilized. | |
| 1405 | * | |
| 1406 | * converts a {@linkcode Root|progress.Root} into an byte stream for | |
| 1407 | * communicating progress over the process or network boundary. the output is a | |
| 1408 | * raw binary payload that uses an extremely compact representation for the | |
| 1409 | * nodes. if trying to transmit UTF-8, consider the JSON-encoding | |
| 1410 | * {@linkcode encodeEventStream} | |
| 1411 | * | |
| 1412 | * if the given root adds custom event handlers, they must all have | |
| 1413 | * json-serializable payloads. | |
| 1414 | */ | |
| 1415 | export function encodeByteStream< | |
| 1416 | Result extends ts.Json, | |
| 1417 | EventMap extends { [key: string]: ts.Json[] }, | |
| 1418 | >(root: Root<Result, EventMap>, options?: EncodeStreamOptions): ReadableStream<Uint8Array> { | |
| 1419 | const events = encodeEventStream(root, options); | |
| 1420 | const transform = new TransformStream({ transform: writeStreamEvent }); | |
| 1421 | return events.pipeThrough(transform); | |
| 1422 | } | |
| 1423 | ||
| 1424 | /** convert {@linkcode StreamEvent} into a byte payload. Mutates the input event. */ | |
| 1425 | function writeStreamEvent(event: StreamEvent, writer: stream.WriteTarget) { | |
| 1426 | using w = new stream.BufferedWriter(writer); | |
| 1427 | ||
| 1428 | let deleteCount = event.shift(); | |
| 1429 | ASSERT(typeof deleteCount === "number"); | |
| 1430 | ||
| 1431 | if (deleteCount === -1) { | |
| 1432 | deleteCount = 0; | |
| 1433 | w.write(header); | |
| 1434 | } else { | |
| 1435 | w.u8("\n".charCodeAt(0)); | |
| 1436 | ||
| 1437 | const deletedNodes = event.splice(0, deleteCount); | |
| 1438 | w.varUint(deletedNodes.length); | |
| 1439 | for (const dstKey of deletedNodes) { | |
| 1440 | ASSERT(typeof dstKey === "number"); | |
| 1441 | w.varUint(dstKey); | |
| 1442 | } | |
| 1443 | } | |
| 1444 | ||
| 1445 | const rootChildren = event.find( | |
| 1446 | (x): x is StreamRootChildren => Array.isArray(x) && typeof x[0] === "number", | |
| 1447 | ); | |
| 1448 | const changedNodes = event.filter( | |
| 1449 | (x): x is StreamNode => typeof x === "object" && !Array.isArray(x), | |
| 1450 | ); | |
| 1451 | w.varUint(changedNodes.length + +!!rootChildren); | |
| 1452 | for (const obj of changedNodes) { | |
| 1453 | ASSERT(typeof obj === "object" && "k" in obj); | |
| 1454 | const { | |
| 1455 | k: key, | |
| 1456 | t: text, | |
| 1457 | v: value, | |
| 1458 | e: total, | |
| 1459 | l: messages, | |
| 1460 | c: childrenSet, | |
| 1461 | E: estimatedTime, | |
| 1462 | V: formattedValue, | |
| 1463 | } = obj; | |
| 1464 | const { hidden, showTotal, passive } = UNWRAP(obj[kNode]); | |
| 1465 | ASSERT(key > 0); | |
| 1466 | ASSERT(messages == null || messages.length > 0); | |
| 1467 | ||
| 1468 | w.varUint(key); | |
| 1469 | w.u16( | |
| 1470 | encodeDeltaFlags({ | |
| 1471 | hidden, | |
| 1472 | passive, | |
| 1473 | showTotal, | |
| 1474 | changedValue: value !== undefined, | |
| 1475 | changedTotal: total !== undefined, | |
| 1476 | changedLogs: messages !== undefined, | |
| 1477 | changedText: text !== undefined, | |
| 1478 | changedChildren: childrenSet !== undefined, | |
| 1479 | changedEstimatedTime: estimatedTime !== undefined, | |
| 1480 | changedFormattedValue: formattedValue !== undefined, | |
| 1481 | estimatedTimeNonNull: estimatedTime !== null, | |
| 1482 | }), | |
| 1483 | ); | |
| 1484 | if (value !== undefined) w.f64(value); | |
| 1485 | if (total !== undefined) w.f64(total); | |
| 1486 | if (text !== undefined) w.stringWithLength(text); | |
| 1487 | if (messages !== undefined) { | |
| 1488 | w.varUint(messages.length); | |
| 1489 | for (const msg of messages) { | |
| 1490 | let level = logLevelSerialize.indexOf(msg.level ?? "info"); | |
| 1491 | if (level === -1) level = 0; | |
| 1492 | w.u8( | |
| 1493 | level + | |
| 1494 | (msg.newline === false ? 1 << 4 : 0) + | |
| 1495 | (msg.scope ? 1 << 5 : 0) + | |
| 1496 | (msg.stack && msg.stack.length > 0 ? 1 << 6 : 0) + | |
| 1497 | (msg.custom !== undefined ? 1 << 7 : 0), | |
| 1498 | ); | |
| 1499 | w.stringWithLength(msg.text); | |
| 1500 | w.varUint(Math.floor(msg.time)); | |
| 1501 | if (msg.scope) w.stringWithLength(msg.scope); | |
| 1502 | if (msg.stack && msg.stack.length > 0) { | |
| 1503 | w.varUint(msg.stack.length); | |
| 1504 | for (const { fn, file, line, col } of msg.stack) { | |
| 1505 | w.stringWithLength(fn ?? ""); | |
| 1506 | w.stringWithLength(file ?? ""); | |
| 1507 | w.varUint(line ?? 0); | |
| 1508 | w.varUint(col ?? 0); | |
| 1509 | } | |
| 1510 | } | |
| 1511 | if (msg.custom !== undefined) { | |
| 1512 | w.stringWithLength(JSON.stringify(msg.custom)); | |
| 1513 | } | |
| 1514 | } | |
| 1515 | } | |
| 1516 | if (childrenSet !== undefined) { | |
| 1517 | w.varUint(childrenSet.length); | |
| 1518 | for (const key of childrenSet) w.varUint(key); | |
| 1519 | } | |
| 1520 | if (estimatedTime != null) w.varUint(Math.floor(estimatedTime)); | |
| 1521 | if (formattedValue !== undefined) { | |
| 1522 | w.stringWithLength(formattedValue); | |
| 1523 | } | |
| 1524 | } | |
| 1525 | if (rootChildren) { | |
| 1526 | w.varUint(0); | |
| 1527 | w.varUint(rootChildren.length); | |
| 1528 | for (const key of rootChildren) { | |
| 1529 | w.varUint(key); | |
| 1530 | } | |
| 1531 | } | |
| 1532 | ||
| 1533 | const customEvents = event.filter( | |
| 1534 | (x): x is StreamCustomEvent => Array.isArray(x) && typeof x[0] === "string", | |
| 1535 | ); | |
| 1536 | w.varUint(customEvents.length); | |
| 1537 | for (const [event, ...args] of customEvents) { | |
| 1538 | w.stringWithLength(event); | |
| 1539 | w.stringWithLength(JSON.stringify(args)); | |
| 1540 | } | |
| 1541 | } | |
| 1542 | ||
| 1543 | /** | |
| 1544 | * decodes a stream created by {@linkcode encodeByteStream} into managed calls to | |
| 1545 | * `target.start`. when cancelling, all the managed nodes are destroyed. | |
| 1546 | */ | |
| 1547 | export function decodeByteStream<Result = void, EventMap extends Events.Map = ts.EmptyObject>( | |
| 1548 | encoded: ReadableStream<Uint8Array>, | |
| 1549 | target: Ref, | |
| 1550 | ): async.Cancelable<Result> & Events<EventMap> { | |
| 1551 | let cancelled = false; | |
| 1552 | let reader: stream.BufferedReader | null = null; | |
| 1553 | return decodeEventStream<Result, EventMap>( | |
| 1554 | new ReadableStream<StreamEvent>({ | |
| 1555 | async start(controller) { | |
| 1556 | reader = new stream.BufferedReader(encoded.getReader()); | |
| 1557 | try { | |
| 1558 | // an event boundary with no further bytes is a clean end of stream | |
| 1559 | while (reader && (await reader.ensureAvailableOrFalse(1))) { | |
| 1560 | controller.enqueue(await readStreamEvent(reader)); | |
| 1561 | } | |
| 1562 | if (!cancelled) controller.close(); | |
| 1563 | } catch (e) { | |
| 1564 | if (!cancelled) { | |
| 1565 | reader?.cancel(e); | |
| 1566 | throw e; | |
| 1567 | } | |
| 1568 | } finally { | |
| 1569 | reader?.releaseLock(); | |
| 1570 | reader = null; | |
| 1571 | } | |
| 1572 | }, | |
| 1573 | cancel(reason) { | |
| 1574 | cancelled = true; | |
| 1575 | reader?.releaseLock(); | |
| 1576 | reader = null; | |
| 1577 | encoded.cancel(reason); | |
| 1578 | }, | |
| 1579 | }), | |
| 1580 | target, | |
| 1581 | ); | |
| 1582 | } | |
| 1583 | ||
| 1584 | async function readStreamEvent(r: stream.BufferedReader): Promise<StreamEvent> { | |
| 1585 | const event: StreamEvent = []; | |
| 1586 | const start = await r.peekU8(); | |
| 1587 | if (start === "\n".charCodeAt(0)) { | |
| 1588 | await r.u8(); | |
| 1589 | let deletedCount = await r.varUint(); | |
| 1590 | event.push(deletedCount); | |
| 1591 | while (deletedCount-- > 0) event.push(await r.varUint()); | |
| 1592 | } else { | |
| 1593 | const readHeader = await r.readExactly(header.byteLength); | |
| 1594 | if (!bytes.eql(readHeader, header)) { | |
| 1595 | const m = JSON.stringify(string.decodeUtf8(readHeader)); | |
| 1596 | throw new Error(`Header mismatch, got ${m}`); | |
| 1597 | } | |
| 1598 | event.push(-1); | |
| 1599 | } | |
| 1600 | const changedNodes = await r.varUint(); | |
| 1601 | for (let i = 0; i < changedNodes; i += 1) { | |
| 1602 | const key = await r.varUint(); | |
| 1603 | if (key === 0) { | |
| 1604 | let count = await r.varUint(); | |
| 1605 | const array: StreamRootChildren = []; | |
| 1606 | while (count-- > 0) array.push(await r.varUint()); | |
| 1607 | event.push(array); | |
| 1608 | continue; | |
| 1609 | } | |
| 1610 | const flags = decodeDeltaFlags(await r.u16()); | |
| 1611 | const v = flags.changedValue ? await r.f64() : undefined; | |
| 1612 | const e = flags.changedTotal ? await r.f64() : undefined; | |
| 1613 | const t = flags.changedText ? await r.stringWithLength() : undefined; | |
| 1614 | let l: undefined | log.Message[]; | |
| 1615 | if (flags.changedLogs) { | |
| 1616 | l = []; | |
| 1617 | let len = await r.varUint(); | |
| 1618 | while (len-- > 0) { | |
| 1619 | const msgFlags = await r.u8(); | |
| 1620 | const level = logLevelSerialize[msgFlags & 0b1111] ?? "info"; | |
| 1621 | // bitwise tests: `msgFlags && (1 << 5) > 0` parsed as | |
| 1622 | // `msgFlags && true`, which fabricated scope/stack/custom reads for | |
| 1623 | // any message with a non-zero level and desynced the entire stream | |
| 1624 | const partial = (msgFlags & (1 << 4)) !== 0; | |
| 1625 | const hasScope = (msgFlags & (1 << 5)) !== 0; | |
| 1626 | const hasStack = (msgFlags & (1 << 6)) !== 0; | |
| 1627 | const hasCustom = (msgFlags & (1 << 7)) !== 0; | |
| 1628 | const text = await r.stringWithLength(); | |
| 1629 | const time = await r.varUint(); | |
| 1630 | const scope = hasScope ? await r.stringWithLength() : undefined; | |
| 1631 | let stack: stack.Frame[] | undefined = undefined; | |
| 1632 | if (hasStack) { | |
| 1633 | stack = []; | |
| 1634 | let frames = await r.varUint(); | |
| 1635 | while (frames-- > 0) { | |
| 1636 | const fn = await r.stringWithLength(); | |
| 1637 | const file = await r.stringWithLength(); | |
| 1638 | const line = await r.varUint(); | |
| 1639 | const col = await r.varUint(); | |
| 1640 | stack.push({ | |
| 1641 | fn: fn || null, | |
| 1642 | file: file || null, | |
| 1643 | line: line || null, | |
| 1644 | col: col || null, | |
| 1645 | }); | |
| 1646 | } | |
| 1647 | } | |
| 1648 | const custom = hasCustom ? JSON.parse(await r.stringWithLength()) : undefined; | |
| 1649 | l.push({ level, text, time, scope, stack, custom, newline: partial ? false : undefined }); | |
| 1650 | } | |
| 1651 | } | |
| 1652 | let c: undefined | EncodedKey[] = undefined; | |
| 1653 | if (flags.changedChildren) { | |
| 1654 | c = []; | |
| 1655 | let len = await r.varUint(); | |
| 1656 | while (len-- > 0) c.push((await r.varUint()) as EncodedKey); | |
| 1657 | } | |
| 1658 | const E = flags.changedEstimatedTime | |
| 1659 | ? flags.estimatedTimeNonNull | |
| 1660 | ? await r.varUint() | |
| 1661 | : null | |
| 1662 | : undefined; | |
| 1663 | const V = flags.changedFormattedValue ? await r.stringWithLength() : undefined; | |
| 1664 | ||
| 1665 | // a "v2"'s packet will go here, contents ignored by this version. | |
| 1666 | if (flags.futureProofingPacket) { | |
| 1667 | const len = await r.varUint(); | |
| 1668 | void (await r.readExactly(len)); | |
| 1669 | } | |
| 1670 | ||
| 1671 | event.push({ | |
| 1672 | k: key as EncodedKey, | |
| 1673 | t, | |
| 1674 | v, | |
| 1675 | e, | |
| 1676 | s: flags.showTotal, | |
| 1677 | p: flags.passive, | |
| 1678 | l, | |
| 1679 | h: flags.hidden, | |
| 1680 | c, | |
| 1681 | E, | |
| 1682 | V, | |
| 1683 | }); | |
| 1684 | } | |
| 1685 | ||
| 1686 | // events | |
| 1687 | let eventCount = await r.varUint(); | |
| 1688 | while (eventCount-- > 0) { | |
| 1689 | const name = await r.stringWithLength(); | |
| 1690 | const args = JSON.parse(await r.stringWithLength()); | |
| 1691 | event.push([name, ...args]); | |
| 1692 | } | |
| 1693 | ||
| 1694 | return event; | |
| 1695 | } | |
| 1696 | ||
| 1697 | const logLevelSerialize = ["info", "error", "warn", "debug"] as const; | |
| 1698 | ||
| 1699 | function encodeDeltaFlags(node: Partial<DeltaFlags>) { | |
| 1700 | return ( | |
| 1701 | (node.hidden ? 1 << 0 : 0) + | |
| 1702 | (node.passive ? 1 << 1 : 0) + | |
| 1703 | (node.showTotal ? 1 << 2 : 0) + | |
| 1704 | (node.changedValue ? 1 << 3 : 0) + | |
| 1705 | (node.changedTotal ? 1 << 4 : 0) + | |
| 1706 | (node.changedLogs ? 1 << 6 : 0) + | |
| 1707 | (node.changedText ? 1 << 7 : 0) + | |
| 1708 | (node.changedChildren ? 1 << 8 : 0) + | |
| 1709 | (node.changedEstimatedTime ? 1 << 9 : 0) + | |
| 1710 | (node.changedFormattedValue ? 1 << 10 : 0) + | |
| 1711 | (node.estimatedTimeNonNull ? 1 << 11 : 0) + | |
| 1712 | (node.futureProofingPacket ? 1 << 14 : 0) + | |
| 1713 | (node.deleted ? 1 << 15 : 0) | |
| 1714 | ); | |
| 1715 | } | |
| 1716 | ||
| 1717 | function decodeDeltaFlags(flags: number): DeltaFlags { | |
| 1718 | return { | |
| 1719 | hidden: (flags & (1 << 0)) !== 0, | |
| 1720 | passive: (flags & (1 << 1)) !== 0, | |
| 1721 | showTotal: (flags & (1 << 2)) !== 0, | |
| 1722 | changedValue: (flags & (1 << 3)) !== 0, | |
| 1723 | changedTotal: (flags & (1 << 4)) !== 0, | |
| 1724 | changedLogs: (flags & (1 << 6)) !== 0, | |
| 1725 | changedText: (flags & (1 << 7)) !== 0, | |
| 1726 | changedChildren: (flags & (1 << 8)) !== 0, | |
| 1727 | changedEstimatedTime: (flags & (1 << 9)) !== 0, | |
| 1728 | changedFormattedValue: (flags & (1 << 10)) !== 0, | |
| 1729 | estimatedTimeNonNull: (flags & (1 << 11)) !== 0, | |
| 1730 | futureProofingPacket: (flags & (1 << 14)) !== 0, | |
| 1731 | deleted: (flags & (1 << 15)) !== 0, | |
| 1732 | }; | |
| 1733 | } | |
| 1734 | ||
| 1735 | class KeyPool<T extends number> { | |
| 1736 | next = 0; | |
| 1737 | old: T[] = []; | |
| 1738 | ||
| 1739 | get() { | |
| 1740 | const existing = this.old.shift(); | |
| 1741 | if (existing) return existing as T; | |
| 1742 | return (this.next += 1) as T; | |
| 1743 | } | |
| 1744 | ||
| 1745 | recycle(k: T) { | |
| 1746 | this.old.push(k); | |
| 1747 | } | |
| 1748 | } | |
| 1749 | ||
| 1750 | /** stateful estimation by providing samples */ | |
| 1751 | export interface EstimationAlgorithm { | |
| 1752 | /** | |
| 1753 | * given a timestamp `time` and a progress value `progress`, return the | |
| 1754 | * timestamp that the action will be finished on. called with (t, 0, 0) | |
| 1755 | * to initialize the algorithm. | |
| 1756 | */ | |
| 1757 | sample(time: number, current: number, total: number): number | null; | |
| 1758 | } | |
| 1759 | ||
| 1760 | /** | |
| 1761 | * Exponential Moving Average. | |
| 1762 | * this is the algorithm used for the default progress estimator. | |
| 1763 | * https://en.wikipedia.org/wiki/Exponential_smoothing | |
| 1764 | */ | |
| 1765 | export class Ema implements EstimationAlgorithm { | |
| 1766 | /** milliseconds */ | |
| 1767 | start: number | null = null; | |
| 1768 | estimate: number | null = null; | |
| 1769 | /** smoothing factor (0-1) */ | |
| 1770 | alpha: number; | |
| 1771 | samples = 0; | |
| 1772 | ||
| 1773 | constructor(alpha: number = 0.25) { | |
| 1774 | this.alpha = alpha; | |
| 1775 | } | |
| 1776 | ||
| 1777 | sample(time: number, current: number, total: number): number | null { | |
| 1778 | this.samples += 1; | |
| 1779 | ||
| 1780 | ASSERT(current >= 0 && current <= total); | |
| 1781 | if (this.start == null) { | |
| 1782 | this.start = time; | |
| 1783 | this.estimate = null; | |
| 1784 | return null; | |
| 1785 | } | |
| 1786 | ||
| 1787 | const elapsed = time - this.start; | |
| 1788 | if (elapsed === 0) return null; // too quick | |
| 1789 | ||
| 1790 | const rate = current / elapsed; | |
| 1791 | this.estimate = | |
| 1792 | this.estimate != null ? (1 - this.alpha) * this.estimate + this.alpha * rate : rate; | |
| 1793 | if (elapsed < 10_000 || this.estimate <= 0 || this.samples < 3) return null; | |
| 1794 | const remaining = total - current; | |
| 1795 | return time + remaining / this.estimate; | |
| 1796 | } | |
| 1797 | ||
| 1798 | reset(): void { | |
| 1799 | this.start = null; | |
| 1800 | this.estimate = null; | |
| 1801 | } | |
| 1802 | } | |
| 1803 | ||
| 1804 | /** according to a stream */ | |
| 1805 | type EncodedKey = number & { brand: typeof kNode }; | |
| 1806 | ||
| 1807 | const globalKeyPool = new KeyPool<number>(); | |
| 1808 | ||
| 1809 | /** names the pipe to a parent process that handed this one a place in its progress */ | |
| 1810 | const ipcEnvVar = "CLOVER_PROGRESS"; | |
| 1811 | ||
| 1812 | /** | |
| 1813 | * the environment for a child process whose global root and log are to be | |
| 1814 | * read by {@linkcode decodeAttachToRef}. `fd` is where the child has the pipe. | |
| 1815 | */ | |
| 1816 | export function ipcChildEnvironment(fd: number): Record<string, string> { | |
| 1817 | return { [ipcEnvVar]: `${fd}:${UNWRAP(node.process).pid}` }; | |
| 1818 | } | |
| 1819 | ||
| 1820 | export async function decodeAttachToRef( | |
| 1821 | readable: ReadableStream<Uint8Array>, | |
| 1822 | target: Ref, | |
| 1823 | ): Promise<void> { | |
| 1824 | const decoder = new Decoder(target, new Events(), noop); | |
| 1825 | try { | |
| 1826 | for await (const line of stream.lines(readable)) { | |
| 1827 | const record: StreamEvent | log.Message = JSON.parse(line); | |
| 1828 | if (Array.isArray(record)) decoder.processEvent(record); | |
| 1829 | else log.writeMessage(record); | |
| 1830 | } | |
| 1831 | } finally { | |
| 1832 | decoder.reset(); | |
| 1833 | } | |
| 1834 | } | |
| 1835 | ||
| 1836 | /** | |
| 1837 | * the other end of {@linkcode decodeAttachToRef}. writes are synchronous, so that | |
| 1838 | * the last words of a process are not lost when it exits. | |
| 1839 | */ | |
| 1840 | function tryAttachToParent(root: Root): boolean { | |
| 1841 | const { process } = node; | |
| 1842 | const fs = node.builtin("fs"); | |
| 1843 | const [fd, parent] = (process?.env[ipcEnvVar] ?? "").split(":").map(Number); | |
| 1844 | // every descendant inherits the variable, but only the child has the pipe | |
| 1845 | if (!process || !fs || !fd || process.ppid !== parent) return false; | |
| 1846 | ||
| 1847 | const write = (record: StreamEvent | log.Message) => { | |
| 1848 | try { | |
| 1849 | fs.writeSync(fd, JSON.stringify(record) + "\n"); | |
| 1850 | } catch { | |
| 1851 | // the parent is gone, and so is anyone to tell | |
| 1852 | } | |
| 1853 | }; | |
| 1854 | ||
| 1855 | const encoder = new Encoder(root); | |
| 1856 | let timer: async.Cancelable<void> | null = null; | |
| 1857 | const flush = () => { | |
| 1858 | timer?.cancel(); | |
| 1859 | timer = null; | |
| 1860 | write(encoder.getDelta()); | |
| 1861 | }; | |
| 1862 | const flushSoon = () => { | |
| 1863 | if (timer) return; | |
| 1864 | (timer = root.delay(1000 / 30)).then(flush); | |
| 1865 | }; | |
| 1866 | ||
| 1867 | encoder.observe(flushSoon, flush); | |
| 1868 | process.addListener("exit", () => timer && flush()); | |
| 1869 | // what changed on a node before a message is said stays before it | |
| 1870 | log.replaceGlobalMessageDestination((m) => (timer && flush(), write({ ...m, stack: undefined }))); | |
| 1871 | return true; | |
| 1872 | } | |
| 1873 | ||
| 1874 | const global: Ref = /** @__PURE__ */ ((root = new Root()) => ( | |
| 1875 | tryAttachToParent(root) || attachToScreen(root, log), | |
| 1876 | root | |
| 1877 | ))(); | |
| 1878 | ||
| 1879 | /** | |
| 1880 | * a {@linkcode Ref} to the global progress root. unlike referencing the | |
| 1881 | * namespace import, this value is tree-shakable. | |
| 1882 | */ | |
| 1883 | export const globalRoot: Ref = { start: (text, opts) => global.start(text, opts) }; | |
| 1884 | ||
| 1885 | /** | |
| 1886 | * for testing. not covered by semver | |
| 1887 | * @internal | |
| 1888 | */ | |
| 1889 | export const internals: { | |
| 1890 | readStreamEvent: typeof readStreamEvent; | |
| 1891 | writeStreamEvent: typeof writeStreamEvent; | |
| 1892 | kNode: symbol; | |
| 1893 | EncodedKey: EncodedKey; | |
| 1894 | } = /** @__PURE__ */ (() => ({ | |
| 1895 | readStreamEvent, | |
| 1896 | writeStreamEvent, | |
| 1897 | kNode: kNode as symbol, | |
| 1898 | EncodedKey: 0 as EncodedKey, | |
| 1899 | }))(); | |
| 1900 | ||
| 1901 | import { ASSERT, UNWRAP } from "./assert.ts"; | |
| 1902 | import * as async from "./async.ts"; | |
| 1903 | import * as bytes from "./bytes.ts"; | |
| 1904 | import { Events } from "./Events.ts"; | |
| 1905 | import * as exceptions from "./exception.ts"; | |
| 1906 | import * as log from "./log.ts"; | |
| 1907 | import * as stack from "./log/stack.ts"; | |
| 1908 | import * as math from "./math.ts"; | |
| 1909 | import * as node from "./node.ts"; | |
| 1910 | import * as stream from "./stream.ts"; | |
| 1911 | import * as string from "./string.ts"; | |
| 1912 | import * as ansi from "./string/ansi.ts"; | |
| 1913 | import * as ts from "./ts.ts"; | |
| 1914 | ||
| 1915 | import { isVisible, maxLogLines, remainingSeconds } from "./progress-format.ts"; | |
| 1916 | ||
| 1917 | export { isVisible }; |
packages/clo-lib/stream.ts created+484| ... | ... | @@ -0,0 +1,484 @@ |
| 1 | /** | |
| 2 | * this module contains helpers for creating and consuming `ReadableStream`, | |
| 3 | * particularly with binary payloads. | |
| 4 | * | |
| 5 | * @module | |
| 6 | */ | |
| 7 | ||
| 8 | const shared = new Uint8Array(8); | |
| 9 | const sharedView = new DataView(shared.buffer); | |
| 10 | ||
| 11 | /** write bytes `data` to a destination */ | |
| 12 | export type WriteBytesFn = (data: Uint8Array) => void; | |
| 13 | ||
| 14 | /** something that can be written to */ | |
| 15 | export type WriteTarget = WriteBytesFn | { enqueue(chunk: Uint8Array): void; close?(): void }; | |
| 16 | ||
| 17 | /** | |
| 18 | * `ReadableStreamDefaultController.enqueue` does not buffer data in any way. | |
| 19 | * for use cases such as a streaming write of many small binary chunks, such as | |
| 20 | * differently sized integers in the case of `progress.encodeByteStream`, this | |
| 21 | * batches those many small writes into their own `ArrayBuffer` objects. | |
| 22 | * | |
| 23 | * when passing a `ReadableStreamDefaultController`, the `BufferedWriter` | |
| 24 | * "owns" it, and will close the stream when disposed. to prevent that, pass | |
| 25 | * a `WriteBytesFn` instead. | |
| 26 | * | |
| 27 | * ```ts | |
| 28 | * const w = new BufferedWriter(unbufferedWrite); | |
| 29 | * w.write(string.encodeUtf8("a null-terminated string")); | |
| 30 | * w.u8(0); | |
| 31 | * w.write(string.encodeUtf8("a second null-terminated string")); | |
| 32 | * w.u8(0); | |
| 33 | * | |
| 34 | * w.flush(); // don't forget to flush! | |
| 35 | * // `unbufferedWrite` will be called once with 57 bytes | |
| 36 | * ``` | |
| 37 | */ | |
| 38 | export class BufferedWriter { | |
| 39 | #view: DataView<ArrayBuffer>; | |
| 40 | #written: number = 0; | |
| 41 | #flush: WriteBytesFn; | |
| 42 | #size: number; | |
| 43 | #onClose?: (() => void) | null | undefined; | |
| 44 | ||
| 45 | constructor(flush: WriteTarget, size: number = 8192) { | |
| 46 | this.#flush = typeof flush === "function" ? flush : flush.enqueue.bind(flush); | |
| 47 | this.#view = new DataView(new ArrayBuffer(size)); | |
| 48 | this.#size = size; | |
| 49 | this.#onClose = typeof flush === "function" ? null : flush.close?.bind(flush); | |
| 50 | } | |
| 51 | ||
| 52 | flush() { | |
| 53 | const written = this.#written; | |
| 54 | if (written === 0) return; | |
| 55 | const size = this.#size; | |
| 56 | if (written === size) { | |
| 57 | // transfer | |
| 58 | this.#flush(new Uint8Array(this.#view.buffer)); | |
| 59 | } else { | |
| 60 | // copy | |
| 61 | this.#flush(new Uint8Array(this.#view.buffer.slice(0, written))); | |
| 62 | } | |
| 63 | this.#view = new DataView(new ArrayBuffer(size)); | |
| 64 | this.#written = 0; | |
| 65 | } | |
| 66 | ||
| 67 | close() { | |
| 68 | const written = this.#written; | |
| 69 | if (written === 0) return; | |
| 70 | const size = this.#size; | |
| 71 | if (written === size) { | |
| 72 | // transfer | |
| 73 | this.#flush(new Uint8Array(this.#view.buffer)); | |
| 74 | } else { | |
| 75 | // copy | |
| 76 | this.#flush(new Uint8Array(this.#view.buffer.slice(0, written))); | |
| 77 | } | |
| 78 | this.#written = 0; | |
| 79 | this.#size = 0; | |
| 80 | this.#view = new DataView(new ArrayBuffer(0)); | |
| 81 | this.#onClose?.(); | |
| 82 | } | |
| 83 | ||
| 84 | [Symbol.dispose]() { | |
| 85 | this.close(); | |
| 86 | } | |
| 87 | ||
| 88 | write(bytes: Uint8Array<ArrayBuffer> | ArrayBuffer) { | |
| 89 | const len = bytes.byteLength; | |
| 90 | if (len >= this.size) { | |
| 91 | this.flush(); | |
| 92 | this.#flush("buffer" in bytes ? bytes : new Uint8Array(bytes)); | |
| 93 | return; | |
| 94 | } | |
| 95 | this.ensureUnusedCapacity(len); | |
| 96 | new Uint8Array(this.#view.buffer, this.#written).set( | |
| 97 | bytes instanceof ArrayBuffer ? new Uint8Array(bytes) : bytes, | |
| 98 | ); | |
| 99 | this.#written += bytes.byteLength; | |
| 100 | } | |
| 101 | ||
| 102 | varUint(int: number) { | |
| 103 | ASSERT(int >= 0); | |
| 104 | do { | |
| 105 | const data = int % (1 << 7); | |
| 106 | int = Math.floor(int / (1 << 7)); | |
| 107 | const continuation = int > 0 ? 1 << 7 : 0; | |
| 108 | this.u8(data + continuation); | |
| 109 | } while (int > 0); | |
| 110 | } | |
| 111 | ||
| 112 | u8(int: number) { | |
| 113 | const size = 1; | |
| 114 | this.ensureUnusedCapacity(size); | |
| 115 | this.#view.setUint8(this.#written, int); | |
| 116 | this.#written += size; | |
| 117 | } | |
| 118 | ||
| 119 | u16(int: number) { | |
| 120 | const size = 2; | |
| 121 | this.ensureUnusedCapacity(size); | |
| 122 | this.#view.setUint16(this.#written, int, true); | |
| 123 | this.#written += size; | |
| 124 | } | |
| 125 | ||
| 126 | u32(int: number) { | |
| 127 | const size = 4; | |
| 128 | this.ensureUnusedCapacity(size); | |
| 129 | this.#view.setUint32(this.#written, int, true); | |
| 130 | this.#written += size; | |
| 131 | } | |
| 132 | ||
| 133 | u64(int: bigint) { | |
| 134 | const size = 8; | |
| 135 | this.ensureUnusedCapacity(size); | |
| 136 | this.#view.setBigUint64(this.#written, int, true); | |
| 137 | this.#written += size; | |
| 138 | } | |
| 139 | ||
| 140 | i8(int: number) { | |
| 141 | const size = 1; | |
| 142 | this.ensureUnusedCapacity(size); | |
| 143 | this.#view.setUint8(this.#written, int); | |
| 144 | this.#written += size; | |
| 145 | } | |
| 146 | ||
| 147 | i16(int: number) { | |
| 148 | const size = 2; | |
| 149 | this.ensureUnusedCapacity(size); | |
| 150 | this.#view.setInt16(this.#written, int, true); | |
| 151 | this.#written += size; | |
| 152 | } | |
| 153 | ||
| 154 | i32(int: number) { | |
| 155 | const size = 4; | |
| 156 | this.ensureUnusedCapacity(size); | |
| 157 | this.#view.setInt32(this.#written, int, true); | |
| 158 | this.#written += size; | |
| 159 | } | |
| 160 | ||
| 161 | i64(int: bigint) { | |
| 162 | const size = 8; | |
| 163 | this.ensureUnusedCapacity(size); | |
| 164 | this.#view.setBigInt64(this.#written, int, true); | |
| 165 | this.#written += size; | |
| 166 | } | |
| 167 | ||
| 168 | f16(float: number) { | |
| 169 | const size = 2; | |
| 170 | this.ensureUnusedCapacity(size); | |
| 171 | this.#view.setFloat16(this.#written, float, true); | |
| 172 | this.#written += size; | |
| 173 | } | |
| 174 | ||
| 175 | f32(float: number) { | |
| 176 | const size = 4; | |
| 177 | this.ensureUnusedCapacity(size); | |
| 178 | this.#view.setFloat32(this.#written, float, true); | |
| 179 | this.#written += size; | |
| 180 | } | |
| 181 | ||
| 182 | f64(float: number) { | |
| 183 | const size = 8; | |
| 184 | this.ensureUnusedCapacity(size); | |
| 185 | this.#view.setFloat64(this.#written, float, true); | |
| 186 | this.#written += size; | |
| 187 | } | |
| 188 | ||
| 189 | stringWithLength(text: string) { | |
| 190 | const buf = string.encodeUtf8(text); | |
| 191 | this.varUint(buf.byteLength); | |
| 192 | this.write(buf); | |
| 193 | } | |
| 194 | ||
| 195 | get written(): number { | |
| 196 | return this.#written; | |
| 197 | } | |
| 198 | get size(): number { | |
| 199 | return this.#size; | |
| 200 | } | |
| 201 | ||
| 202 | ensureUnusedCapacity(extra: number) { | |
| 203 | const size = this.size; | |
| 204 | const written = this.#written; | |
| 205 | if (written + extra > size) { | |
| 206 | if (extra > size) { | |
| 207 | this.resize(extra); | |
| 208 | } else { | |
| 209 | this.flush(); | |
| 210 | } | |
| 211 | } | |
| 212 | } | |
| 213 | ||
| 214 | resize(newCapacity: number) { | |
| 215 | newCapacity = Math.round(newCapacity); | |
| 216 | const written = this.#written; | |
| 217 | if (written > 0) { | |
| 218 | // transfer | |
| 219 | this.#flush(new Uint8Array(this.#view.buffer, 0, written)); | |
| 220 | this.#written = 0; | |
| 221 | } | |
| 222 | this.#size = newCapacity; | |
| 223 | this.#view = new DataView(new ArrayBuffer(newCapacity)); | |
| 224 | } | |
| 225 | } | |
| 226 | ||
| 227 | /** | |
| 228 | * a reader for `ReadableStream` which allows the caller to specify the | |
| 229 | * chunking size and read numbers at a time. | |
| 230 | */ | |
| 231 | export class BufferedReader implements Disposable { | |
| 232 | #reader: ReadableStreamDefaultReader<Uint8Array> | null; | |
| 233 | #offset = 0; | |
| 234 | #buffer: Uint8Array[] = []; | |
| 235 | #available = 0; | |
| 236 | ||
| 237 | constructor(reader: ReadableStreamDefaultReader<Uint8Array>) { | |
| 238 | this.#reader = reader; | |
| 239 | } | |
| 240 | ||
| 241 | get available(): number { | |
| 242 | return this.#available; | |
| 243 | } | |
| 244 | ||
| 245 | /** Releasing will discard the bytes remaining in the buffer. */ | |
| 246 | releaseLock() { | |
| 247 | UNWRAP(this.#reader, "reader disposed").releaseLock(); | |
| 248 | this.#reader = null; | |
| 249 | } | |
| 250 | ||
| 251 | cancel(error: unknown) { | |
| 252 | UNWRAP(this.#reader, "reader disposed").cancel(error); | |
| 253 | } | |
| 254 | ||
| 255 | async ensureAvailableOrFalse(size: number): Promise<boolean> { | |
| 256 | if (size <= 0 || this.#available >= size) return true; | |
| 257 | const reader = UNWRAP(this.#reader, "reader disposed"); | |
| 258 | const buffer = this.#buffer; | |
| 259 | while (this.#available < size) { | |
| 260 | const { value, done } = await reader.read(); | |
| 261 | if (done) break; | |
| 262 | buffer.push(value); | |
| 263 | this.#available += value.byteLength; | |
| 264 | } | |
| 265 | return this.#available >= size; | |
| 266 | } | |
| 267 | async ensureAvailable(size: number): Promise<void> { | |
| 268 | ASSERT(await this.ensureAvailableOrFalse(size), "End of stream"); | |
| 269 | } | |
| 270 | ||
| 271 | #collectLimitedView(length: number): DataView { | |
| 272 | ASSERT(this.available >= length); | |
| 273 | const buffer = this.#buffer; | |
| 274 | let chunk = UNWRAP(buffer[0]); | |
| 275 | let offset = this.#offset; | |
| 276 | if (chunk.byteLength - offset >= length) { | |
| 277 | this.#shiftOffset(length); | |
| 278 | return new DataView(chunk.buffer, chunk.byteOffset + offset, length); | |
| 279 | } | |
| 280 | let x = 0; | |
| 281 | while (true) { | |
| 282 | for (; offset < chunk.byteLength && x < length; offset += 1, x += 1) | |
| 283 | shared[x] = chunk[offset]!; | |
| 284 | if (x === length) { | |
| 285 | if (offset === chunk.byteLength) { | |
| 286 | ASSERT(buffer.shift()); | |
| 287 | this.#offset = 0; | |
| 288 | } else this.#offset = offset; | |
| 289 | this.#available -= length; | |
| 290 | return sharedView; | |
| 291 | } | |
| 292 | ASSERT(buffer.shift()); | |
| 293 | chunk = UNWRAP(buffer[0]); | |
| 294 | offset = 0; | |
| 295 | } | |
| 296 | } | |
| 297 | #collectBuffer(length: number) { | |
| 298 | ASSERT(this.available >= length); | |
| 299 | this.#available -= length; | |
| 300 | const buffer = this.#buffer; | |
| 301 | let offset = this.#offset; | |
| 302 | ||
| 303 | // slice an existing data view | |
| 304 | if (buffer[0] && buffer[0].byteLength - offset >= length) { | |
| 305 | const first = buffer[0]; | |
| 306 | if (first.byteLength - offset === length) { | |
| 307 | this.#offset = 0; | |
| 308 | ASSERT(buffer.shift()); | |
| 309 | return offset ? first.subarray(offset) : first; | |
| 310 | } | |
| 311 | this.#offset += length; | |
| 312 | return first.subarray(offset, offset + length); | |
| 313 | } | |
| 314 | ||
| 315 | // join by allocating a new buffer | |
| 316 | const joined = new Uint8Array(length); | |
| 317 | let i = 0; | |
| 318 | do { | |
| 319 | const chunk = UNWRAP(buffer[0]); | |
| 320 | const { byteLength } = chunk; | |
| 321 | const copyLen = Math.min(byteLength, length); | |
| 322 | joined.set(copyLen === byteLength ? chunk : chunk.subarray(offset, offset + copyLen), i); | |
| 323 | i += copyLen; | |
| 324 | length -= copyLen; | |
| 325 | if (copyLen === byteLength) ASSERT(buffer.shift()); | |
| 326 | if (length === 0) this.#offset = copyLen; | |
| 327 | } while (length > 0); | |
| 328 | return joined; | |
| 329 | } | |
| 330 | #shiftOffset(offset: number) { | |
| 331 | const newOffset = (this.#offset += offset); | |
| 332 | this.#available -= offset; | |
| 333 | if (UNWRAP(this.#buffer[0]).byteLength === newOffset) { | |
| 334 | this.#buffer.shift(); | |
| 335 | this.#offset = 0; | |
| 336 | } else { | |
| 337 | this.#offset = newOffset; | |
| 338 | } | |
| 339 | } | |
| 340 | ||
| 341 | async u8(): Promise<number> { | |
| 342 | const int = await this.peekU8(); | |
| 343 | this.#shiftOffset(1); | |
| 344 | return int; | |
| 345 | } | |
| 346 | ||
| 347 | async peekU8(): Promise<number> { | |
| 348 | await this.ensureAvailable(1); | |
| 349 | return UNWRAP(UNWRAP(this.#buffer[0])[this.#offset]); | |
| 350 | } | |
| 351 | ||
| 352 | async i8(): Promise<number> { | |
| 353 | await this.ensureAvailable(2); | |
| 354 | return this.#collectLimitedView(1).getInt8(0); | |
| 355 | } | |
| 356 | ||
| 357 | async u16(): Promise<number> { | |
| 358 | await this.ensureAvailable(2); | |
| 359 | return this.#collectLimitedView(2).getUint16(0, true); | |
| 360 | } | |
| 361 | ||
| 362 | async i16(): Promise<number> { | |
| 363 | await this.ensureAvailable(2); | |
| 364 | return this.#collectLimitedView(2).getInt16(0, true); | |
| 365 | } | |
| 366 | ||
| 367 | async u32(): Promise<number> { | |
| 368 | await this.ensureAvailable(4); | |
| 369 | return this.#collectLimitedView(4).getUint32(0, true); | |
| 370 | } | |
| 371 | ||
| 372 | async i32(): Promise<number> { | |
| 373 | await this.ensureAvailable(4); | |
| 374 | return this.#collectLimitedView(4).getInt32(0, true); | |
| 375 | } | |
| 376 | ||
| 377 | async u64(): Promise<bigint> { | |
| 378 | await this.ensureAvailable(8); | |
| 379 | return this.#collectLimitedView(8).getBigUint64(0, true); | |
| 380 | } | |
| 381 | ||
| 382 | async i64(): Promise<bigint> { | |
| 383 | await this.ensureAvailable(8); | |
| 384 | return this.#collectLimitedView(8).getBigInt64(0, true); | |
| 385 | } | |
| 386 | ||
| 387 | async f16(): Promise<number> { | |
| 388 | await this.ensureAvailable(2); | |
| 389 | return this.#collectLimitedView(2).getFloat16(0, true); | |
| 390 | } | |
| 391 | ||
| 392 | async f32(): Promise<number> { | |
| 393 | await this.ensureAvailable(4); | |
| 394 | return this.#collectLimitedView(4).getFloat32(0, true); | |
| 395 | } | |
| 396 | ||
| 397 | async f64(): Promise<number> { | |
| 398 | await this.ensureAvailable(8); | |
| 399 | return this.#collectLimitedView(8).getFloat64(0, true); | |
| 400 | } | |
| 401 | ||
| 402 | async varUint(): Promise<number> { | |
| 403 | let result = 0; | |
| 404 | let shift = 1; | |
| 405 | let byte; | |
| 406 | do { | |
| 407 | byte = await this.u8(); | |
| 408 | result += (byte & 0x7f) * shift; | |
| 409 | shift *= 128; | |
| 410 | } while (byte & 0x80); | |
| 411 | return result; | |
| 412 | } | |
| 413 | ||
| 414 | async stringWithLength(): Promise<string> { | |
| 415 | const len = await this.varUint(); | |
| 416 | return string.decodeUtf8(await this.readExactly(len)); | |
| 417 | } | |
| 418 | ||
| 419 | /** Read up to a maximum number of bytes */ | |
| 420 | async readUpTo(size: number): Promise<Uint8Array> { | |
| 421 | await this.ensureAvailableOrFalse(size); | |
| 422 | return this.#collectBuffer(Math.min(size, this.#available)); | |
| 423 | } | |
| 424 | async readExactly(size: number): Promise<Uint8Array> { | |
| 425 | await this.ensureAvailable(size); | |
| 426 | return this.#collectBuffer(size); | |
| 427 | } | |
| 428 | ||
| 429 | [Symbol.dispose]() { | |
| 430 | this.releaseLock(); | |
| 431 | } | |
| 432 | } | |
| 433 | ||
| 434 | /** easily create a readable stream with a writer */ | |
| 435 | export function bufferedReadableStream(): [ | |
| 436 | ReadableStream, | |
| 437 | BufferedWriter, | |
| 438 | ReadableStreamDefaultController<Uint8Array>, | |
| 439 | ] { | |
| 440 | let unbuffered: ReadableStreamDefaultController<Uint8Array> | null = null; | |
| 441 | const reader = new ReadableStream<Uint8Array>({ | |
| 442 | start: (controller) => (unbuffered = controller), | |
| 443 | }); | |
| 444 | ASSERT(unbuffered); | |
| 445 | return [reader, new BufferedWriter(unbuffered), unbuffered]; | |
| 446 | } | |
| 447 | ||
| 448 | /** easily create a readable stream with a writer */ | |
| 449 | export function readWritePair(): [ReadableStream, ReadableStreamDefaultController] { | |
| 450 | let unbuffered: ReadableStreamDefaultController<Uint8Array> | null = null; | |
| 451 | const reader = new ReadableStream<Uint8Array>({ | |
| 452 | start: (controller) => (unbuffered = controller), | |
| 453 | }); | |
| 454 | ASSERT(unbuffered); | |
| 455 | return [reader, unbuffered]; | |
| 456 | } | |
| 457 | ||
| 458 | /** | |
| 459 | * decode utf-8 and yield each line without its ending, which is any of `\n`, | |
| 460 | * `\r\n`, or the lone `\r` that command line programs redraw a status with. | |
| 461 | */ | |
| 462 | export async function* lines(readable: ReadableStream<Uint8Array>): AsyncGenerator<string> { | |
| 463 | const decoder = new TextDecoder(); | |
| 464 | let rest = ""; | |
| 465 | let endsInCarriageReturn = false; | |
| 466 | ||
| 467 | for await (const bytes of readable) { | |
| 468 | let chunk = decoder.decode(bytes, { stream: true }); | |
| 469 | if (chunk === "") continue; | |
| 470 | // the `\n` of a `\r\n` that arrived in two chunks | |
| 471 | if (endsInCarriageReturn && chunk[0] === "\n") chunk = chunk.slice(1); | |
| 472 | endsInCarriageReturn = chunk.endsWith("\r"); | |
| 473 | ||
| 474 | const parts = (rest + chunk).split(/\r\n|\r|\n/); | |
| 475 | rest = UNWRAP(parts.pop()); | |
| 476 | yield* parts; | |
| 477 | } | |
| 478 | ||
| 479 | rest += decoder.decode(); | |
| 480 | if (rest) yield rest; | |
| 481 | } | |
| 482 | ||
| 483 | import { ASSERT, UNWRAP } from "./assert.ts"; | |
| 484 | import * as string from "./string.ts"; |
packages/clo-lib/string.ts created+121| ... | ... | @@ -0,0 +1,121 @@ |
| 1 | /** | |
| 2 | * helpers for strings, escaping, and encoding. | |
| 3 | * | |
| 4 | * @module | |
| 5 | */ | |
| 6 | ||
| 7 | /** count the number of `\n` characters are in the string */ | |
| 8 | export function countNewlines(str: string): number { | |
| 9 | let count = 0; | |
| 10 | for (let i = 0, { length } = str; i < length; i += 1) { | |
| 11 | if (str[i] === "\n") count += 1; | |
| 12 | } | |
| 13 | return count; | |
| 14 | } | |
| 15 | ||
| 16 | /** | |
| 17 | * escape a string for use in a regular expression. | |
| 18 | * @deprecated use `RegExp.escape` for browsers past May 2025 | |
| 19 | */ | |
| 20 | export const escapeRegExp: (unsafeText: string) => string = | |
| 21 | RegExp.escape ?? ((unsafeText: string) => unsafeText.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")); | |
| 22 | ||
| 23 | /** escape a string for use in a regular expression. */ | |
| 24 | export function escapeRegExpReplacement(unsafeText: string): string { | |
| 25 | return unsafeText.replace(/\$/g, "$$$$"); | |
| 26 | } | |
| 27 | ||
| 28 | export function escapeHtmlContent(unsafeText: string): string { | |
| 29 | return unsafeText.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;"); | |
| 30 | } | |
| 31 | ||
| 32 | export function escapeHtmlAttr(unsafeText: string): string { | |
| 33 | return escapeHtmlContent(unsafeText).replace(/'/g, "&#x27;").replace(/"/g, "&quot;"); | |
| 34 | } | |
| 35 | ||
| 36 | /** | |
| 37 | * Escape a string for use in a POSIX shell. Prefers easier to read output vs | |
| 38 | * simpler implementation. | |
| 39 | */ | |
| 40 | export function escapeShellArgument(s: string): string { | |
| 41 | if (s.length === 0) return "''"; | |
| 42 | if (/^[a-zA-Z0-9_\-./@,+=%:]+$/.test(s) && s[0] !== ":") return s; | |
| 43 | if (!s.includes("'")) return `'${s}'`; | |
| 44 | if (!/["$`\\]/.test(s)) return `"${s}"`; | |
| 45 | let complex = `'${s.replace(/'/g, "'\\''")}'`; | |
| 46 | if (s.endsWith("'")) complex = complex.slice(0, -2); | |
| 47 | if (s.startsWith("'")) complex = complex.slice(2); | |
| 48 | return complex; | |
| 49 | } | |
| 50 | ||
| 51 | const te = new TextEncoder(); | |
| 52 | export function encodeUtf8(input: string): Uint8Array<ArrayBuffer> { | |
| 53 | return te.encode(input) as Uint8Array<ArrayBuffer>; | |
| 54 | } | |
| 55 | ||
| 56 | const td = new TextDecoder(); | |
| 57 | export function decodeUtf8(input: Uint8Array): string { | |
| 58 | return td.decode(input); | |
| 59 | } | |
| 60 | ||
| 61 | const byteUnits = "kMGTPEZYRQ"; // kilo, mega, giga, etc | |
| 62 | export function formatByteSize(bytes: number): string { | |
| 63 | if (!Number.isFinite(bytes)) return bytes.toString(); | |
| 64 | let prefix = ""; | |
| 65 | if (bytes < 0) ((bytes = -bytes), (prefix = "-")); | |
| 66 | if (bytes < 1000) return Math.round(bytes * 100) / 100 + "B"; | |
| 67 | let unit = 0; | |
| 68 | do { | |
| 69 | bytes /= 1000; | |
| 70 | unit += 1; | |
| 71 | } while (bytes >= 1000); | |
| 72 | return ( | |
| 73 | prefix + | |
| 74 | (unit === 1 | |
| 75 | ? String(+bytes.toFixed(bytes > 100 ? 1 : 2)) | |
| 76 | : bytes.toFixed(Math.floor(bytes) === bytes ? 0 : bytes > 100 ? 1 : 2)) + | |
| 77 | byteUnits.charAt(unit - 1) + | |
| 78 | "B" | |
| 79 | ); | |
| 80 | } | |
| 81 | ||
| 82 | export function formatBinaryByteSize(bytes: number): string { | |
| 83 | let prefix = ""; | |
| 84 | if (bytes < 0) ((bytes = -bytes), (prefix = "-")); | |
| 85 | if (bytes < 1024) return Math.round(bytes * 100) / 100 + "B"; | |
| 86 | let unit = 0; | |
| 87 | do { | |
| 88 | bytes /= 1024; | |
| 89 | unit += 1; | |
| 90 | } while (bytes >= 1024); | |
| 91 | return prefix + Math.round(bytes * 100) / 100 + byteUnits.charAt(unit - 1) + "iB"; | |
| 92 | } | |
| 93 | ||
| 94 | export function formatDurationLetters(seconds: number): string { | |
| 95 | if (seconds === 0) return "now"; | |
| 96 | if (!Number.isFinite(seconds)) { | |
| 97 | if (seconds === Infinity) return "forever"; | |
| 98 | if (seconds === -Infinity) return "forever ago"; | |
| 99 | return "NaN"; | |
| 100 | } | |
| 101 | let ago = ""; | |
| 102 | if (seconds < 0) ((seconds = -seconds), (ago = " ago")); | |
| 103 | const units = [ | |
| 104 | [31536000, "y"], | |
| 105 | [86400, "d"], | |
| 106 | [3600, "h"], | |
| 107 | [60, "m"], | |
| 108 | ] as const; | |
| 109 | let out = ""; | |
| 110 | let remain = seconds; | |
| 111 | let u = 0; | |
| 112 | for (const [size, label] of units) { | |
| 113 | if (remain < size) continue; | |
| 114 | const value = Math.floor(remain / size); | |
| 115 | remain -= value * size; | |
| 116 | out += `${value}${label}`; | |
| 117 | if ((u += 1) === 2) return out + ago; | |
| 118 | } | |
| 119 | if (remain > 0 || out === "") out += `${+remain.toFixed(2)}s`; | |
| 120 | return out + ago; | |
| 121 | } |
packages/clo-lib/string/ansi.ts created+596| ... | ... | @@ -0,0 +1,596 @@ |
| 1 | /** | |
| 2 | * ANSI escape sequences can be used to style text in a tty or perform other | |
| 3 | * interactions. this file contains many constants for escape codes, as well as | |
| 4 | * functions for ones that take an argument, such as `cursorUp`. | |
| 5 | * | |
| 6 | * note that this file only produces escape sequences, and does not yet feature | |
| 7 | * detection or fallback code. | |
| 8 | * | |
| 9 | * @module ansi | |
| 10 | */ | |
| 11 | ||
| 12 | /** resets all ansi styles */ | |
| 13 | export const reset: string = `\x1b[0m`; | |
| 14 | ||
| 15 | /** | |
| 16 | * puts the text in bold. can be combined with `dim` and colors. | |
| 17 | * reset with `resetWeight`. | |
| 18 | */ | |
| 19 | export const bold: string = `\x1b[1m`; | |
| 20 | /** | |
| 21 | * puts the text a more faded color, dimming it. can be combined with `bold` to | |
| 22 | * produce bold-dim text. dim can be combined with any color to make a dimmed | |
| 23 | * version of that color. reset with `resetWeight`. | |
| 24 | */ | |
| 25 | export const dim: string = `\x1b[2m`; | |
| 26 | /** resets `bold` and `dim` */ | |
| 27 | export const resetWeight: string = `\x1b[22m`; | |
| 28 | ||
| 29 | /** | |
| 30 | * swaps the foreground and background colors, which reads as a highlight in | |
| 31 | * any color scheme. reset with `resetInverse`. | |
| 32 | */ | |
| 33 | export const inverse: string = `\x1b[7m`; | |
| 34 | /** resets `inverse` */ | |
| 35 | export const resetInverse: string = `\x1b[27m`; | |
| 36 | ||
| 37 | /** resets foreground color */ | |
| 38 | export const fgReset: string = `\x1b[39m`; | |
| 39 | /** sets the foreground color to black. on light mode terminals, this color may | |
| 40 | * be visible. */ | |
| 41 | export const fgBlack: string = `\x1b[30m`; | |
| 42 | /** sets the foreground color to red */ | |
| 43 | export const fgRed: string = `\x1b[31m`; | |
| 44 | /** sets the foreground color to green */ | |
| 45 | export const fgGreen: string = `\x1b[32m`; | |
| 46 | /** sets the foreground color to yellow */ | |
| 47 | export const fgYellow: string = `\x1b[33m`; | |
| 48 | /** sets the foreground color to blue */ | |
| 49 | export const fgBlue: string = `\x1b[34m`; | |
| 50 | /** sets the foreground color to magenta */ | |
| 51 | export const fgMagenta: string = `\x1b[35m`; | |
| 52 | /** sets the foreground color to cyan */ | |
| 53 | export const fgCyan: string = `\x1b[36m`; | |
| 54 | /** | |
| 55 | * sets the foreground color to (dark) white / grey / gray. this color is | |
| 56 | * brighter than `fgBrightBlack`. consider `dim` for greyed out text and | |
| 57 | * `fgReset` to set the default foreground color. | |
| 58 | */ | |
| 59 | export const fgWhite: string = `\x1b[37m`; | |
| 60 | /** | |
| 61 | * sets the foreground color to bright black / grey / gray. consider `dim` for | |
| 62 | * greyed out text. | |
| 63 | */ | |
| 64 | export const fgBrightBlack: string = `\x1b[90m`; | |
| 65 | /** sets the foreground color to bright red */ | |
| 66 | export const fgBrightRed: string = `\x1b[91m`; | |
| 67 | /** sets the foreground color to bright green */ | |
| 68 | export const fgBrightGreen: string = `\x1b[92m`; | |
| 69 | /** sets the foreground color to bright yellow */ | |
| 70 | export const fgBrightYellow: string = `\x1b[93m`; | |
| 71 | /** sets the foreground color to bright blue */ | |
| 72 | export const fgBrightBlue: string = `\x1b[94m`; | |
| 73 | /** sets the foreground color to bright magenta */ | |
| 74 | export const fgBrightMagenta: string = `\x1b[95m`; | |
| 75 | /** sets the foreground color to bright cyan */ | |
| 76 | export const fgBrightCyan: string = `\x1b[96m`; | |
| 77 | /** | |
| 78 | * sets the foreground color to bright white. consider `dim` for greyed out | |
| 79 | * text and `fgReset` to set the default foreground color. | |
| 80 | */ | |
| 81 | export const fgBrightWhite: string = `\x1b[97m`; | |
| 82 | /** sets the foreground color to a color in the 256 color palette. */ | |
| 83 | export function fg256(n: number): string { | |
| 84 | return `\x1b[38;5;${n}m`; | |
| 85 | } | |
| 86 | /** sets the foreground color to an rgb color. */ | |
| 87 | export function fgRgb(r: number, g: number, b: number): string { | |
| 88 | return `\x1b[38;2;${r};${g};${b}m`; | |
| 89 | } | |
| 90 | ||
| 91 | /** resets the cell background color. */ | |
| 92 | export const bgReset: string = `\x1b[49m`; | |
| 93 | /** sets the cell background to black. consider `bgReset` to clear the background. */ | |
| 94 | export const bgBlack: string = `\x1b[40m`; | |
| 95 | /** sets the cell background to red. */ | |
| 96 | export const bgRed: string = `\x1b[41m`; | |
| 97 | /** sets the cell background to green. */ | |
| 98 | export const bgGreen: string = `\x1b[42m`; | |
| 99 | /** sets the cell background to yellow. */ | |
| 100 | export const bgYellow: string = `\x1b[43m`; | |
| 101 | /** sets the cell background to blue. */ | |
| 102 | export const bgBlue: string = `\x1b[44m`; | |
| 103 | /** sets the cell background to magenta. */ | |
| 104 | export const bgMagenta: string = `\x1b[45m`; | |
| 105 | /** sets the cell background to cyan. */ | |
| 106 | export const bgCyan: string = `\x1b[46m`; | |
| 107 | /** | |
| 108 | * sets the cell background to (dark) white / grey / gray. this color is | |
| 109 | * brighter than `bgBrightBlack`. | |
| 110 | */ | |
| 111 | export const bgWhite: string = `\x1b[47m`; | |
| 112 | /** | |
| 113 | * sets the cell background to bright black. consider `bgReset` to clear the | |
| 114 | * background. | |
| 115 | */ | |
| 116 | export const bgBrightBlack: string = `\x1b[100m`; | |
| 117 | /** sets the cell background to bright red. */ | |
| 118 | export const bgBrightRed: string = `\x1b[101m`; | |
| 119 | /** sets the cell background to bright green. */ | |
| 120 | export const bgBrightGreen: string = `\x1b[102m`; | |
| 121 | /** sets the cell background to bright yellow. */ | |
| 122 | export const bgBrightYellow: string = `\x1b[103m`; | |
| 123 | /** sets the cell background to bright blue. */ | |
| 124 | export const bgBrightBlue: string = `\x1b[104m`; | |
| 125 | /** sets the cell background to bright magenta. */ | |
| 126 | export const bgBrightMagenta: string = `\x1b[105m`; | |
| 127 | /** sets the cell background to bright cyan. */ | |
| 128 | export const bgBrightCyan: string = `\x1b[106m`; | |
| 129 | /** sets the cell background to bright white. */ | |
| 130 | export const bgBrightWhite: string = `\x1b[107m`; | |
| 131 | /** sets the background color to a color in the 256 color palette. */ | |
| 132 | export function bg256(n: number): string { | |
| 133 | return `\x1b[48;5;${n}m`; | |
| 134 | } | |
| 135 | /** sets the background color to an rgb color. */ | |
| 136 | export function bgRgb(r: number, g: number, b: number): string { | |
| 137 | return `\x1b[48;2;${r};${g};${b}m`; | |
| 138 | } | |
| 139 | ||
| 140 | // cursor management | |
| 141 | ||
| 142 | /** save the position of the cursor, restored with {@linkcode cursorRestore}. */ | |
| 143 | export const cursorSave: string = "\x1b7"; | |
| 144 | /** restore the position of the cursor, saved with {@linkcode cursorSave}. */ | |
| 145 | export const cursorRestore: string = "\x1b8"; | |
| 146 | ||
| 147 | /** move the cursor up `n` units. */ | |
| 148 | export function cursorUp(n: number): string { | |
| 149 | return n ? `\x1b[${n}A` : ""; | |
| 150 | } | |
| 151 | /** move the cursor down `n` units. */ | |
| 152 | export function cursorDown(n: number): string { | |
| 153 | return n ? `\x1b[${n}B` : ""; | |
| 154 | } | |
| 155 | /** move the cursor right `n` units. */ | |
| 156 | export function cursorRight(n: number): string { | |
| 157 | return n ? `\x1b[${n}C` : ""; | |
| 158 | } | |
| 159 | /** move the cursor right `n` units. */ | |
| 160 | export function cursorLeft(n: number): string { | |
| 161 | return n ? `\x1b[${n}D` : ""; | |
| 162 | } | |
| 163 | /** move the cursor to the top left of the screen. */ | |
| 164 | export const cursorHome: string = "\x1b[H"; | |
| 165 | /** move the cursor to the start of it's current line. */ | |
| 166 | export const startOfLine: string = "\r"; | |
| 167 | /** move the cursor to the start of the next line. */ | |
| 168 | export const startOfNextLine: string = "\n"; | |
| 169 | ||
| 170 | // erasure | |
| 171 | /** clear from and including the current cell right to the end of the line. */ | |
| 172 | export const clearToEndOfLine: string = "\x1b[K"; | |
| 173 | /** clear from and including the current cell left to the start of the line. */ | |
| 174 | export const clearToStartOfLine: string = "\x1b[1K"; | |
| 175 | /** clear the entire line that the cursor is on without moving it. */ | |
| 176 | export const clearFullLine: string = "\x1b[2K"; | |
| 177 | /** clear from and including the current cell to the end of the screen. */ | |
| 178 | export const clearToEndOfScreen: string = "\x1b[0J"; | |
| 179 | ||
| 180 | /** | |
| 181 | * Begin Synchronized Output | |
| 182 | * https://gist.github.com/christianparpart/d8a62cc1ab659194337d73e399004036 | |
| 183 | */ | |
| 184 | export const syncStart: string = "\x1b[?2026h"; | |
| 185 | /** End Synchronized Output */ | |
| 186 | export const syncEnd: string = "\x1b[?2026l"; | |
| 187 | ||
| 188 | /** | |
| 189 | * Apply ANSI styles to text. `code` is a concatenation of zero or more styles, | |
| 190 | * which will be cleared as required. | |
| 191 | */ | |
| 192 | export function style(code: string, text: string): string { | |
| 193 | code = mergeStyles(code); | |
| 194 | if (!code) return text; | |
| 195 | const fg = /[\[;][39]/.test(code); | |
| 196 | const bg = /[\[;](?:4|10)/.test(code); | |
| 197 | const bold = /[\[;](?:1)[;m]/.test(code); | |
| 198 | const dim = /[\[;](?:2)[;m]/.test(code); | |
| 199 | return ( | |
| 200 | code + | |
| 201 | text + | |
| 202 | mergeStyles( | |
| 203 | (fg ? fgReset : "") + | |
| 204 | (bg ? bgReset : "") + | |
| 205 | (bold ? resetWeight : "") + | |
| 206 | (dim ? resetWeight : ""), | |
| 207 | ) | |
| 208 | ); | |
| 209 | } | |
| 210 | ||
| 211 | /** | |
| 212 | * Given a string of multiple style ansi codes, combines all of them. | |
| 213 | * Must be used on a string of only ansi style codes. | |
| 214 | */ | |
| 215 | export function mergeStyles(code: string): string { | |
| 216 | let styles = []; | |
| 217 | while (code.length) { | |
| 218 | if (!code.startsWith("\x1b[")) { | |
| 219 | throw new Error("invalid style string: " + JSON.stringify(code)); | |
| 220 | } | |
| 221 | const end = code.indexOf("m"); | |
| 222 | if (end === -1) { | |
| 223 | throw new Error("invalid style string: " + JSON.stringify(code)); | |
| 224 | } | |
| 225 | styles.push(code.slice(2, end)); | |
| 226 | code = code.slice(end + 1); | |
| 227 | } | |
| 228 | return `\x1b[${styles.join(";")}m`; | |
| 229 | } | |
| 230 | ||
| 231 | /** | |
| 232 | * Convert a string with ansi text into a string where all the codes have been | |
| 233 | * turned into their original names. | |
| 234 | */ | |
| 235 | export function debugAnsi(text: string): string { | |
| 236 | // deno-fmt-ignore | |
| 237 | const verbaitim: (keyof typeof self)[] = [ | |
| 238 | "fgCyan", | |
| 239 | "fgBlue", | |
| 240 | "bgReset", | |
| 241 | "bgBlack", | |
| 242 | "bgRed", | |
| 243 | "bgGreen", | |
| 244 | "bgYellow", | |
| 245 | "bgBlue", | |
| 246 | "bgMagenta", | |
| 247 | "bgCyan", | |
| 248 | "bgWhite", | |
| 249 | "bgBrightBlack", | |
| 250 | "bgBrightRed", | |
| 251 | "bgBrightGreen", | |
| 252 | "bgBrightYellow", | |
| 253 | "bgBrightBlue", | |
| 254 | "bgBrightMagenta", | |
| 255 | "bgBrightCyan", | |
| 256 | "bgBrightWhite", | |
| 257 | "fgReset", | |
| 258 | "fgBlack", | |
| 259 | "fgRed", | |
| 260 | "fgGreen", | |
| 261 | "fgYellow", | |
| 262 | "fgMagenta", | |
| 263 | "fgWhite", | |
| 264 | "fgBrightBlack", | |
| 265 | "fgBrightRed", | |
| 266 | "fgBrightGreen", | |
| 267 | "fgBrightYellow", | |
| 268 | "fgBrightBlue", | |
| 269 | "fgBrightMagenta", | |
| 270 | "fgBrightCyan", | |
| 271 | "fgBrightWhite", | |
| 272 | "clearToEndOfLine", | |
| 273 | "clearToStartOfLine", | |
| 274 | "clearFullLine", | |
| 275 | "clearToEndOfScreen", | |
| 276 | "syncStart", | |
| 277 | "syncEnd", | |
| 278 | "bold", | |
| 279 | "dim", | |
| 280 | "resetWeight", | |
| 281 | "cursorSave", | |
| 282 | "cursorRestore", | |
| 283 | ]; | |
| 284 | for (const key of verbaitim) { | |
| 285 | text = text.replaceAll( | |
| 286 | String(self[key]), | |
| 287 | style(key.includes("cursor") ? fgBlue : fgCyan, `\${${key}}`), | |
| 288 | ); | |
| 289 | } | |
| 290 | text = text.replace(/\x1b\[(\d+)A/g, style(fgYellow, "$${cursorUp($1)}")); | |
| 291 | text = text.replace(/\x1b\[(\d+)B/g, style(fgYellow, "$${cursorDown($1)}")); | |
| 292 | text = text.replace(/\x1b\[(\d+)C/g, style(fgYellow, "$${cursorRight($1)}")); | |
| 293 | text = text.replace(/\x1b\[(\d+)D/g, style(fgYellow, "$${cursorLeft($1)}")); | |
| 294 | text = text.replace(/\n/g, style(fgRed, "\\n")); | |
| 295 | text = text.replace(/\r/g, style(fgMagenta, "\\r")); | |
| 296 | return text; | |
| 297 | } | |
| 298 | ||
| 299 | /** Measure the visible length of a string, ignoring ansi codes. */ | |
| 300 | export function widthInTerminal(str: string): number { | |
| 301 | segmenter ??= new Intl.Segmenter(); | |
| 302 | let width = 0; | |
| 303 | if (str.includes("\x1b")) str = str.replace(ansiEscapes, ""); | |
| 304 | for (let i = 0, { length } = str; i < length; i += 1) { | |
| 305 | const code = str.charCodeAt(i); | |
| 306 | if (code < 32) continue; | |
| 307 | if (code < 127) { | |
| 308 | width += 1; | |
| 309 | continue; | |
| 310 | } | |
| 311 | // the remainder of the string is measured with the segmenter, which | |
| 312 | // handles ascii correctly via segmentWidth (controls are zero-width). | |
| 313 | for (const { segment } of segmenter.segment(str.slice(i))) { | |
| 314 | width += segmentWidth(segment); | |
| 315 | } | |
| 316 | return width; | |
| 317 | } | |
| 318 | return width; | |
| 319 | } | |
| 320 | ||
| 321 | /** | |
| 322 | * Like `str.slice(0, columns)`, but respecting width in a terminal. | |
| 323 | * preserves ansi escape sequences. | |
| 324 | */ | |
| 325 | export function trimForTerminal(str: string, columns: number): string { | |
| 326 | segmenter ??= new Intl.Segmenter(); | |
| 327 | let width = 0; | |
| 328 | let ansi = false; | |
| 329 | for (let i = 0, { length } = str; i < length; i += 1) { | |
| 330 | const code = str.charCodeAt(i); | |
| 331 | if (code < 32) { | |
| 332 | if (code === 0x1b) { | |
| 333 | const esc = str.slice(i).match(ansiEscapes); | |
| 334 | if (esc) ((i += esc[0].length - 1), (ansi = true)); | |
| 335 | } | |
| 336 | continue; | |
| 337 | } | |
| 338 | if (code < 127) { | |
| 339 | width += 1; | |
| 340 | if (width >= columns) { | |
| 341 | const result = str.slice(0, i + 1); | |
| 342 | return ansi ? result + "\x1b[0m" : result; | |
| 343 | } | |
| 344 | continue; | |
| 345 | } | |
| 346 | // non-ascii path | |
| 347 | let out = str.slice(0, i); | |
| 348 | str = str.slice(i); | |
| 349 | const segments = segmenter.segment(str); | |
| 350 | { | |
| 351 | const { segment } = UNWRAP(segments.containing(0)); | |
| 352 | width += segmentWidth(segment); | |
| 353 | if (width > columns) return ansi ? out + "\x1b[0m" : out; | |
| 354 | out += segment; | |
| 355 | i = segment.length; | |
| 356 | } | |
| 357 | for ({ length } = str; i < length; i += 1) { | |
| 358 | const code = str.charCodeAt(i); | |
| 359 | if (code === 0x1b) { | |
| 360 | const esc = str.slice(i).match(ansiEscapes); | |
| 361 | if (esc) { | |
| 362 | ((i += esc[0].length - 1), (ansi = true)); | |
| 363 | out += esc[0]; | |
| 364 | continue; | |
| 365 | } | |
| 366 | } | |
| 367 | const { segment } = UNWRAP(segments.containing(i)); | |
| 368 | width += segmentWidth(segment); | |
| 369 | if (width > columns) return ansi ? out + "\x1b[0m" : out; | |
| 370 | out += segment; | |
| 371 | } | |
| 372 | return out; | |
| 373 | } | |
| 374 | return str; | |
| 375 | } | |
| 376 | ||
| 377 | // derived from sindresorhus's wonderful `string-width` | |
| 378 | // https://github.com/sindresorhus/string-width/blob/42e7b697393a9b9ff732f9bd3ab1db87c3c208b2/index.js | |
| 379 | // https://github.com/sindresorhus/get-east-asian-width/blob/6aee3824b5e2ade910a38ed93cf9d89b7b5a8e5b/index.js | |
| 380 | // MIT license | |
| 381 | let segmenter: Intl.Segmenter | null = null; | |
| 382 | const ansiEscapes = | |
| 383 | /[\u001B\u009B][[\]()#;?]*(?:(?:(?:(?:;[-a-zA-Z\d\/\#&.:=?%@~_]+)*|[a-zA-Z\d]+(?:;[-a-zA-Z\d\/\#&.:=?%@~_]*)*)?(?:\u0007|\u001B\u005C|\u009C))|(?:(?:\d{1,4}(?:;\d{0,4})*)?[\dA-PR-TZcf-nq-uy=><~]))/g; | |
| 384 | const zeroWidthClusterRegex = | |
| 385 | /^(?:\p{Default_Ignorable_Code_Point}|\p{Control}|\p{Mark}|\p{Surrogate})+$/v; | |
| 386 | const leadingNonPrintingRegex = | |
| 387 | /^[\p{Default_Ignorable_Code_Point}\p{Control}\p{Format}\p{Mark}\p{Surrogate}]+/v; | |
| 388 | const rgiEmojiRegex = /^\p{RGI_Emoji}$/v; | |
| 389 | ||
| 390 | /** remove all ansi escape sequences */ | |
| 391 | export function strip(text: string): string { | |
| 392 | return text.replace(ansiEscapes, ""); | |
| 393 | } | |
| 394 | ||
| 395 | /** | |
| 396 | * ansi text as html, for a browser. the sixteen colors are `var(--ansi0)` to | |
| 397 | * `var(--ansi15)`, which {@linkcode htmlColors} defines. no element spans a | |
| 398 | * line break, so the html splits into lines where the text does. | |
| 399 | */ | |
| 400 | export function toHtml(text: string): string { | |
| 401 | const style = plainStyle(); | |
| 402 | let html = ""; | |
| 403 | let last = 0; | |
| 404 | for (const match of text.matchAll(sequences)) { | |
| 405 | html += formatRun(text.slice(last, match.index), style); | |
| 406 | last = match.index + match[0].length; | |
| 407 | if (match[2] === "m") applySgr(style, (match[1] || "0").split(/[;:]/).map(Number)); | |
| 408 | } | |
| 409 | return html + formatRun(text.slice(last), style); | |
| 410 | } | |
| 411 | ||
| 412 | /** the colors that {@linkcode toHtml} refers to, as css declarations that follow `color-scheme` */ | |
| 413 | export const htmlColors: string = | |
| 414 | "--ansi0:light-dark(#000,#666);--ansi1:#cd3131;" + | |
| 415 | "--ansi2:light-dark(#00a000,#0dbc79);--ansi3:light-dark(#949800,#e5e510);" + | |
| 416 | "--ansi4:light-dark(#0451a5,#2472c8);--ansi5:#bc3fbc;--ansi6:light-dark(#0598bc,#11a8cd);" + | |
| 417 | "--ansi7:light-dark(#555,#e5e5e5);--ansi8:#666;--ansi9:light-dark(#cd3131,#f14c4c);" + | |
| 418 | "--ansi10:light-dark(#14ce14,#23d18b);--ansi11:light-dark(#b5ba00,#f5f543);" + | |
| 419 | "--ansi12:light-dark(#0451a5,#3b8eea);--ansi13:light-dark(#bc05bc,#d670d6);" + | |
| 420 | "--ansi14:light-dark(#0598bc,#29b8db);--ansi15:light-dark(#a5a5a5,#e5e5e5);"; | |
| 421 | ||
| 422 | interface HtmlStyle { | |
| 423 | fg: string; | |
| 424 | bg: string; | |
| 425 | weight: string; | |
| 426 | italic: boolean; | |
| 427 | underline: boolean; | |
| 428 | } | |
| 429 | const plainStyle = (): HtmlStyle => ({ | |
| 430 | fg: "", | |
| 431 | bg: "", | |
| 432 | weight: "", | |
| 433 | italic: false, | |
| 434 | underline: false, | |
| 435 | }); | |
| 436 | ||
| 437 | const sequences = | |
| 438 | /\x1b(?:\[([\x30-\x3f]*)[\x20-\x2f]*([\x40-\x7e])|\][^\x07\x1b]*(?:\x07|\x1b\\)?|.)/g; | |
| 439 | ||
| 440 | function formatRun(text: string, { fg, bg, weight, italic, underline }: HtmlStyle) { | |
| 441 | if (!text) return ""; | |
| 442 | const css = | |
| 443 | (fg && `color:${fg};`) + | |
| 444 | (bg && `background:${bg};`) + | |
| 445 | weight + | |
| 446 | (italic ? "font-style:italic;" : "") + | |
| 447 | (underline ? "text-decoration:underline;" : ""); | |
| 448 | if (!css) return escapeHtmlContent(text); | |
| 449 | return text | |
| 450 | .split("\n") | |
| 451 | .map((line) => line && `<span style="${css}">${escapeHtmlContent(line)}</span>`) | |
| 452 | .join("\n"); | |
| 453 | } | |
| 454 | ||
| 455 | function applySgr(style: HtmlStyle, codes: number[]) { | |
| 456 | for (let i = 0; i < codes.length; i += 1) { | |
| 457 | const code = UNWRAP(codes[i]); | |
| 458 | if (code === 0) Object.assign(style, plainStyle()); | |
| 459 | else if (code === 1) style.weight = "font-weight:bold;"; | |
| 460 | else if (code === 2) style.weight = "opacity:.65;"; | |
| 461 | else if (code === 22) style.weight = ""; | |
| 462 | else if (code === 3 || code === 23) style.italic = code === 3; | |
| 463 | else if (code === 4 || code === 24) style.underline = code === 4; | |
| 464 | else if (code === 39) style.fg = ""; | |
| 465 | else if (code === 49) style.bg = ""; | |
| 466 | else if ((code >= 30 && code <= 37) || (code >= 90 && code <= 97)) { | |
| 467 | style.fg = `var(--ansi${(code % 10) + (code >= 90 ? 8 : 0)})`; | |
| 468 | } else if ((code >= 40 && code <= 47) || (code >= 100 && code <= 107)) { | |
| 469 | style.bg = `var(--ansi${(code % 10) + (code >= 100 ? 8 : 0)})`; | |
| 470 | } else if (code === 38 || code === 48) { | |
| 471 | const indexed = codes[i + 1] === 5; | |
| 472 | const [r = 0, g = 0, b = 0] = codes.slice(i + 2, i + 5); | |
| 473 | const color = indexed ? color256(r) : `rgb(${r} ${g} ${b})`; | |
| 474 | i += indexed ? 2 : 4; | |
| 475 | if (code === 38) style.fg = color; | |
| 476 | else style.bg = color; | |
| 477 | } | |
| 478 | } | |
| 479 | } | |
| 480 | ||
| 481 | function color256(n: number) { | |
| 482 | if (n < 16) return `var(--ansi${n})`; | |
| 483 | if (n >= 232) | |
| 484 | return `rgb(${Array(3) | |
| 485 | .fill(8 + (n - 232) * 10) | |
| 486 | .join(" ")})`; | |
| 487 | n -= 16; | |
| 488 | const level = (c: number) => c && 55 + c * 40; | |
| 489 | return `rgb(${level(Math.floor(n / 36))} ${level(Math.floor(n / 6) % 6)} ${level(n % 6)})`; | |
| 490 | } | |
| 491 | ||
| 492 | function segmentWidth(segment: string) { | |
| 493 | if (zeroWidthClusterRegex.test(segment)) return 0; | |
| 494 | if (rgiEmojiRegex.test(segment)) return 2; | |
| 495 | ||
| 496 | // deno-fmt-ignore | |
| 497 | const list = [ | |
| 498 | 0x3000, 0x231a, 0x231b, 0x2329, 0x232a, 0x23f0, 0x23f3, 0x25fd, 0x25fe, 0x2614, 0x2615, 0x267f, | |
| 499 | 0x2693, 0x26a1, 0x26aa, 0x26ab, 0x26bd, 0x26be, 0x26c4, 0x26c5, 0x26ce, 0x26d4, 0x26ea, 0x26f2, | |
| 500 | 0x26f3, 0x26f5, 0x26fa, 0x26fd, 0x2705, 0x270a, 0x270b, 0x2728, 0x274c, 0x274e, 0x2757, 0x27b0, | |
| 501 | 0x27bf, 0x2b1b, 0x2b1c, 0x2b50, 0x2b55, 0x1affd, 0x1affe, 0x1b132, 0x1b155, 0x1f004, 0x1f0cf, | |
| 502 | 0x1f18e, 0x1f250, 0x1f251, 0x1f3f4, 0x1f440, 0x1f57a, 0x1f595, 0x1f596, 0x1f5a4, 0x1f6cc, | |
| 503 | 0x1f6eb, 0x1f6ec, 0x1f7f0, 0x1fac8, | |
| 504 | ]; | |
| 505 | const x = segment.replace(leadingNonPrintingRegex, "").codePointAt(0) ?? 0; | |
| 506 | return ( | |
| 507 | 1 + | |
| 508 | +( | |
| 509 | // full | |
| 510 | (x >= 0xff01 && x <= 0xff60) || | |
| 511 | (x >= 0xffe0 && x <= 0xffe6) || | |
| 512 | // wide | |
| 513 | (x >= 0x1100 && x <= 0x115f) || | |
| 514 | (x >= 0x23e9 && x <= 0x23ec) || | |
| 515 | (x >= 0x2630 && x <= 0x2637) || | |
| 516 | (x >= 0x2648 && x <= 0x2653) || | |
| 517 | (x >= 0x268a && x <= 0x268f) || | |
| 518 | (x >= 0x2753 && x <= 0x2755) || | |
| 519 | (x >= 0x2795 && x <= 0x2797) || | |
| 520 | (x >= 0x2e80 && x <= 0x2e99) || | |
| 521 | (x >= 0x2e9b && x <= 0x2ef3) || | |
| 522 | (x >= 0x2f00 && x <= 0x2fd5) || | |
| 523 | (x >= 0x2ff0 && x <= 0x2fff) || | |
| 524 | (x >= 0x3001 && x <= 0x303e) || | |
| 525 | (x >= 0x3041 && x <= 0x3096) || | |
| 526 | (x >= 0x3099 && x <= 0x30ff) || | |
| 527 | (x >= 0x3105 && x <= 0x312f) || | |
| 528 | (x >= 0x3131 && x <= 0x318e) || | |
| 529 | (x >= 0x3190 && x <= 0x31e5) || | |
| 530 | (x >= 0x31ef && x <= 0x321e) || | |
| 531 | (x >= 0x3220 && x <= 0x3247) || | |
| 532 | (x >= 0x3250 && x <= 0xa48c) || | |
| 533 | (x >= 0xa490 && x <= 0xa4c6) || | |
| 534 | (x >= 0xa960 && x <= 0xa97c) || | |
| 535 | (x >= 0xac00 && x <= 0xd7a3) || | |
| 536 | (x >= 0xf900 && x <= 0xfaff) || | |
| 537 | (x >= 0xfe10 && x <= 0xfe19) || | |
| 538 | (x >= 0xfe30 && x <= 0xfe52) || | |
| 539 | (x >= 0xfe54 && x <= 0xfe66) || | |
| 540 | (x >= 0xfe68 && x <= 0xfe6b) || | |
| 541 | (x >= 0x16fe0 && x <= 0x16fe4) || | |
| 542 | (x >= 0x16ff0 && x <= 0x16ff6) || | |
| 543 | (x >= 0x17000 && x <= 0x18cd5) || | |
| 544 | (x >= 0x18cff && x <= 0x18d1e) || | |
| 545 | (x >= 0x18d80 && x <= 0x18df2) || | |
| 546 | (x >= 0x1aff0 && x <= 0x1aff3) || | |
| 547 | (x >= 0x1aff5 && x <= 0x1affb) || | |
| 548 | (x >= 0x1b150 && x <= 0x1b152) || | |
| 549 | (x >= 0x1b164 && x <= 0x1b167) || | |
| 550 | (x >= 0x1b170 && x <= 0x1b2fb) || | |
| 551 | (x >= 0x1d300 && x <= 0x1d356) || | |
| 552 | (x >= 0x1d360 && x <= 0x1d376) || | |
| 553 | (x >= 0x1f191 && x <= 0x1f19a) || | |
| 554 | (x >= 0x1f200 && x <= 0x1f202) || | |
| 555 | (x >= 0x1f210 && x <= 0x1f23b) || | |
| 556 | (x >= 0x1f240 && x <= 0x1f248) || | |
| 557 | (x >= 0x1f260 && x <= 0x1f265) || | |
| 558 | (x >= 0x1f300 && x <= 0x1f320) || | |
| 559 | (x >= 0x1f32d && x <= 0x1f335) || | |
| 560 | (x >= 0x1f337 && x <= 0x1f37c) || | |
| 561 | (x >= 0x1f37e && x <= 0x1f393) || | |
| 562 | (x >= 0x1f3a0 && x <= 0x1f3ca) || | |
| 563 | (x >= 0x1f3cf && x <= 0x1f3d3) || | |
| 564 | (x >= 0x1f3e0 && x <= 0x1f3f0) || | |
| 565 | (x >= 0x1f3f8 && x <= 0x1f43e) || | |
| 566 | (x >= 0x1f442 && x <= 0x1f4fc) || | |
| 567 | (x >= 0x1f4ff && x <= 0x1f53d) || | |
| 568 | (x >= 0x1f54b && x <= 0x1f54e) || | |
| 569 | (x >= 0x1f550 && x <= 0x1f567) || | |
| 570 | (x >= 0x1f5fb && x <= 0x1f64f) || | |
| 571 | (x >= 0x1f680 && x <= 0x1f6c5) || | |
| 572 | (x >= 0x1f6d0 && x <= 0x1f6d2) || | |
| 573 | (x >= 0x1f6d5 && x <= 0x1f6d8) || | |
| 574 | (x >= 0x1f6dc && x <= 0x1f6df) || | |
| 575 | (x >= 0x1f6f4 && x <= 0x1f6fc) || | |
| 576 | (x >= 0x1f7e0 && x <= 0x1f7eb) || | |
| 577 | (x >= 0x1f90c && x <= 0x1f93a) || | |
| 578 | (x >= 0x1f93c && x <= 0x1f945) || | |
| 579 | (x >= 0x1f947 && x <= 0x1f9ff) || | |
| 580 | (x >= 0x1fa70 && x <= 0x1fa7c) || | |
| 581 | (x >= 0x1fa80 && x <= 0x1fa8a) || | |
| 582 | (x >= 0x1fa8e && x <= 0x1fac6) || | |
| 583 | (x >= 0x1facd && x <= 0x1fadc) || | |
| 584 | (x >= 0x1fadf && x <= 0x1faea) || | |
| 585 | (x >= 0x1faef && x <= 0x1faf8) || | |
| 586 | (x >= 0x20000 && x <= 0x2fffd) || | |
| 587 | (x >= 0x30000 && x <= 0x3fffd) || | |
| 588 | (x >= 0x1b000 && x <= 0x1b122) || | |
| 589 | list.includes(x) | |
| 590 | ) | |
| 591 | ); | |
| 592 | } | |
| 593 | ||
| 594 | import { UNWRAP } from "../assert.ts"; | |
| 595 | import { escapeHtmlContent } from "../string.ts"; | |
| 596 | import * as self from "./ansi.ts"; |
packages/clo-lib/ts.ts created+107| ... | ... | @@ -0,0 +1,107 @@ |
| 1 | /** | |
| 2 | * helpers for the typescript language | |
| 3 | * | |
| 4 | * @module ts | |
| 5 | */ | |
| 6 | ||
| 7 | /** return type of `setTimeout` regardless of the environment */ | |
| 8 | export type Timer = ReturnType<typeof setTimeout>; | |
| 9 | /** return type of `setInterval` regardless of the environment */ | |
| 10 | export type Interval = ReturnType<typeof setInterval>; | |
| 11 | /** opposite of the built-in `Readonly` type */ | |
| 12 | export type Writeable<T> = { -readonly [P in keyof T]: T[P] }; | |
| 13 | /** A type that can be `await`ed to return `T` */ | |
| 14 | export type Awaitable<T> = T | PromiseLike<T>; | |
| 15 | /** opposite of the built-in `Readonly` type, but recursive */ | |
| 16 | // TODO: tuples? | |
| 17 | export type DeepWriteable<T> = { | |
| 18 | -readonly [P in keyof T]: T[P] extends ReadonlyArray<infer I> ? Array<I> : DeepWriteable<T[P]>; | |
| 19 | }; | |
| 20 | /** opposite of `DeepWriteable`, for values that are shared and must not change */ | |
| 21 | export type DeepReadonly<T> = T extends Date | |
| 22 | ? T | |
| 23 | : T extends Uint8Array | |
| 24 | ? Readonly<T> | |
| 25 | : T extends Map<infer K, infer V> | |
| 26 | ? ReadonlyMap<DeepReadonly<K>, DeepReadonly<V>> | |
| 27 | : T extends Set<infer V> | |
| 28 | ? ReadonlySet<DeepReadonly<V>> | |
| 29 | : T extends ReadonlyArray<infer I> | |
| 30 | ? ReadonlyArray<DeepReadonly<I>> | |
| 31 | : T extends object | |
| 32 | ? { readonly [P in keyof T]: DeepReadonly<T[P]> } | |
| 33 | : T; | |
| 34 | ||
| 35 | declare const brand: unique symbol; | |
| 36 | export type Brand<T> = { [brand]: T }; | |
| 37 | ||
| 38 | /** | |
| 39 | * redeclared here because it is only provided by `lib: ["dom"]` and not | |
| 40 | * the JavaScript standard types. | |
| 41 | */ | |
| 42 | export interface VoidFunction { | |
| 43 | (): void; | |
| 44 | } | |
| 45 | ||
| 46 | /** convenience type. Construct with `defer` */ | |
| 47 | export interface Dispose extends Disposable, VoidFunction {} | |
| 48 | ||
| 49 | /** | |
| 50 | * ```ts | |
| 51 | * using _ = ts.defer(() => { ... }); | |
| 52 | * ``` | |
| 53 | * Additionally, this can be used to construct the convenience type | |
| 54 | * `ts.Dispose` | |
| 55 | */ | |
| 56 | export function defer(fn: () => void): Dispose { | |
| 57 | const f = fn as Dispose; | |
| 58 | f[Symbol.dispose] = f; | |
| 59 | return f; | |
| 60 | } | |
| 61 | ||
| 62 | export type Mixin<A, B> = A & B; | |
| 63 | ||
| 64 | /** | |
| 65 | * mutates `base` to implement all methods of `other`, using `other` as state. | |
| 66 | */ | |
| 67 | export function mixin<A extends object, B extends object>(base: A, other: B): Mixin<A, B> { | |
| 68 | const prototype = Object.getPrototypeOf(other); | |
| 69 | for (const key of [ | |
| 70 | ...Object.getOwnPropertyNames(prototype), | |
| 71 | ...Object.getOwnPropertySymbols(prototype), | |
| 72 | ]) { | |
| 73 | if (key === "constructor") continue; | |
| 74 | if (key in base) continue; | |
| 75 | const fn = prototype[key]; | |
| 76 | if (typeof fn !== "function") continue; | |
| 77 | (base as Record<string | symbol, unknown>)[key] = fn.bind(other); | |
| 78 | } | |
| 79 | return base as A & B; | |
| 80 | } | |
| 81 | ||
| 82 | export type EmptyObject = Record<never, unknown>; | |
| 83 | ||
| 84 | /** TODO: this type has many subtle bugs */ | |
| 85 | export type ToJson<T> = T extends JsonValue | |
| 86 | ? T | |
| 87 | : T extends { toJSON(): infer J } | |
| 88 | ? ToJson<J> | |
| 89 | : T extends Set<unknown> | Map<unknown, unknown> | Record<string, never> | |
| 90 | ? EmptyObject | |
| 91 | : T extends unknown[] | |
| 92 | ? { | |
| 93 | [K in keyof Omit<T, keyof unknown[]>]: T[K] extends undefined | void | |
| 94 | ? null | |
| 95 | : ToJson<T[K]>; | |
| 96 | } | |
| 97 | : T extends object | |
| 98 | ? { [K in keyof Omit<T, symbol>]: ToJson<T[K]> } | |
| 99 | : never; | |
| 100 | export type Json = JsonValue | undefined | void; | |
| 101 | export type JsonValue = | |
| 102 | | string | |
| 103 | | number | |
| 104 | | boolean | |
| 105 | | null | |
| 106 | | JsonValue[] | |
| 107 | | { [k: string]: Exclude<JsonValue, undefined> }; |
packages/clo-lib/tsconfig.json created+7| ... | ... | @@ -0,0 +1,7 @@ |
| 1 | { | |
| 2 | "extends": "../../tsconfig.base.json", | |
| 3 | "compilerOptions": { | |
| 4 | "types": ["node"] | |
| 5 | }, | |
| 6 | "include": ["**/*.ts"] | |
| 7 | } |
packages/contracts/src/index.ts+1| ... | ... | @@ -66,3 +66,4 @@ export * from "./worktreeSetup.ts"; |
| 66 | 66 | export * from "./secretRequest.ts"; |
| 67 | 67 | export * from "./clientRpcPermissions.ts"; |
| 68 | 68 | export * from "./desktopTerminal.ts"; |
| 69 | export * from "./scriptProgress.ts"; |
packages/contracts/src/rpc.ts+14| ... | ... | @@ -1,3 +1,8 @@ |
| 1 | import { | |
| 2 | ScriptProgressSubscribeInput, | |
| 3 | ScriptProgressEvent, | |
| 4 | ScriptProgressStreamError, | |
| 5 | } from "./scriptProgress.ts"; | |
| 1 | 6 | import { |
| 2 | 7 | OrchestrationV2SearchThreadError, |
| 3 | 8 | OrchestrationV2SearchThreadInput, |
| ... | ... | @@ -578,6 +583,7 @@ export const WS_METHODS = { |
| 578 | 583 | worktreeSetupCancel: "worktreeSetup.cancel", |
| 579 | 584 | subscribeTerminalEvents: "subscribeTerminalEvents", |
| 580 | 585 | subscribeTerminalMetadata: "subscribeTerminalMetadata", |
| 586 | subscribeScriptProgress: "subscribeScriptProgress", | |
| 581 | 587 | subscribePreviewEvents: "subscribePreviewEvents", |
| 582 | 588 | subscribeDiscoveredLocalServers: "subscribeDiscoveredLocalServers", |
| 583 | 589 | subscribeDeviceState: "subscribeDeviceState", |
| ... | ... | @@ -1745,6 +1751,13 @@ const WsSubscribeTerminalEventsRpc = Rpc.make(WS_METHODS.subscribeTerminalEvents |
| 1745 | 1751 | stream: true, |
| 1746 | 1752 | }); |
| 1747 | 1753 | |
| 1754 | const WsSubscribeScriptProgressRpc = Rpc.make(WS_METHODS.subscribeScriptProgress, { | |
| 1755 | payload: ScriptProgressSubscribeInput, | |
| 1756 | success: ScriptProgressEvent, | |
| 1757 | error: Schema.Union([ScriptProgressStreamError, EnvironmentAuthorizationError]), | |
| 1758 | stream: true, | |
| 1759 | }); | |
| 1760 | ||
| 1748 | 1761 | const WsSubscribeTerminalMetadataRpc = Rpc.make(WS_METHODS.subscribeTerminalMetadata, { |
| 1749 | 1762 | payload: Schema.Struct({}), |
| 1750 | 1763 | success: TerminalMetadataStreamEvent, |
| ... | ... | @@ -2041,6 +2054,7 @@ export const WsRpcGroup = RpcGroup.make( |
| 2041 | 2054 | WsReviewGetDiffPreviewRpc, |
| 2042 | 2055 | WsReviewGetDiffFileContentsRpc, |
| 2043 | 2056 | WsSubscribeTerminalMetadataRpc, |
| 2057 | WsSubscribeScriptProgressRpc, | |
| 2044 | 2058 | WsPreviewOpenRpc, |
| 2045 | 2059 | WsPreviewNavigateRpc, |
| 2046 | 2060 | WsPreviewResizeRpc, |
packages/contracts/src/scriptProgress.ts created+44| ... | ... | @@ -0,0 +1,44 @@ |
| 1 | import * as Schema from "effect/Schema"; | |
| 2 | import { ThreadId } from "./baseSchemas.ts"; | |
| 3 | ||
| 4 | export const ScriptProgressSubscribeInput = Schema.Struct({ threadId: ThreadId }); | |
| 5 | ||
| 6 | export const ScriptProgressSummary = Schema.Array( | |
| 7 | Schema.Struct({ | |
| 8 | text: Schema.String, | |
| 9 | value: Schema.Number, | |
| 10 | total: Schema.Number, | |
| 11 | detail: Schema.String, | |
| 12 | }), | |
| 13 | ); | |
| 14 | ||
| 15 | export const ScriptProgressStream = Schema.Struct({ | |
| 16 | id: Schema.String, | |
| 17 | pid: Schema.Int, | |
| 18 | label: Schema.String, | |
| 19 | status: Schema.Literals(["connected", "disconnected"]), | |
| 20 | summary: ScriptProgressSummary, | |
| 21 | }); | |
| 22 | export type ScriptProgressStream = typeof ScriptProgressStream.Type; | |
| 23 | ||
| 24 | export const ScriptProgressEvent = Schema.Union([ | |
| 25 | Schema.Struct({ type: Schema.Literal("snapshot"), streams: Schema.Array(ScriptProgressStream) }), | |
| 26 | Schema.Struct({ type: Schema.Literal("upsert"), stream: ScriptProgressStream }), | |
| 27 | Schema.Struct({ | |
| 28 | type: Schema.Literal("progress"), | |
| 29 | id: Schema.String, | |
| 30 | event: Schema.Array(Schema.Json), | |
| 31 | summary: ScriptProgressSummary, | |
| 32 | }), | |
| 33 | Schema.Struct({ type: Schema.Literal("remove"), id: Schema.String }), | |
| 34 | ]); | |
| 35 | export type ScriptProgressEvent = typeof ScriptProgressEvent.Type; | |
| 36 | ||
| 37 | export class ScriptProgressStreamError extends Schema.TaggedError<ScriptProgressStreamError>()( | |
| 38 | "ScriptProgressStreamError", | |
| 39 | {}, | |
| 40 | ) { | |
| 41 | override get message(): string { | |
| 42 | return "Progress updates fell behind. Reconnect to refresh them."; | |
| 43 | } | |
| 44 | } |
packages/provider-acp-registry/src/server/adapter.ts+6-1| ... | ... | @@ -1,3 +1,4 @@ |
| 1 | import * as ScriptProgress from "@t3tools/provider-core/server/ScriptProgress"; | |
| 1 | 2 | import { |
| 2 | 3 | normalizeDevinSessionUpdate, |
| 3 | 4 | normalizeDevinToolCall, |
| ... | ... | @@ -166,7 +167,10 @@ function makeAcpRegistryRuntime( |
| 166 | 167 | ? resolved.spawn |
| 167 | 168 | : { |
| 168 | 169 | ...resolved.spawn, |
| 169 | env: { ...resolved.spawn.env, ...processEnvironment }, | |
| 170 | env: ScriptProgress.withScriptProgressEnvironment( | |
| 171 | resolved.spawn.env ?? {}, | |
| 172 | processEnvironment, | |
| 173 | ), | |
| 170 | 174 | }, |
| 171 | 175 | ...(options.settings.authMethodId ? { authMethodId: options.settings.authMethodId } : {}), |
| 172 | 176 | customModels: readCustomModelEntries(options.settings.customModels).map( |
| ... | ... | @@ -281,6 +285,7 @@ export type AcpRegistryAdapterV2DriverEnv = |
| 281 | 285 | | AcpRegistrySupport.AcpRegistryCatalog |
| 282 | 286 | | IdAllocator.IdAllocatorV2 |
| 283 | 287 | | Path.Path |
| 288 | | ScriptProgress.ScriptProgress | |
| 284 | 289 | | McpProviderSessions.McpProviderSessions |
| 285 | 290 | | ProviderEventLoggers.ProviderEventLoggers |
| 286 | 291 | | ProviderHost.ProviderHost; |
packages/provider-acp/src/server/adapter.ts+15-1| ... | ... | @@ -1,3 +1,4 @@ |
| 1 | import * as ScriptProgress from "@t3tools/provider-core/server/ScriptProgress"; | |
| 1 | 2 | // @effect-diagnostics nodeBuiltinImport:off |
| 2 | 3 | |
| 3 | 4 | import { |
| ... | ... | @@ -1493,11 +1494,23 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( |
| 1493 | 1494 | const idAllocator = yield* IdAllocator.IdAllocatorV2; |
| 1494 | 1495 | const host = yield* ProviderHost.ProviderHost; |
| 1495 | 1496 | const mcpSessions = yield* McpProviderSessions.McpProviderSessions; |
| 1497 | const scriptProgress = yield* ScriptProgress.ScriptProgress; | |
| 1496 | 1498 | const { flavor, selfInvocation: self } = options; |
| 1497 | 1499 | const readMcpContext = (threadId: ThreadId | null) => |
| 1498 | 1500 | threadId === null |
| 1499 | 1501 | ? Effect.succeed(acpMcpContext(undefined, self)) |
| 1500 | : mcpSessions.read(threadId).pipe(Effect.map((session) => acpMcpContext(session, self))); | |
| 1502 | : Effect.gen(function* () { | |
| 1503 | const context = acpMcpContext(yield* mcpSessions.read(threadId), self); | |
| 1504 | const environment = yield* scriptProgress.environmentForThread(threadId); | |
| 1505 | if (Object.keys(environment).length === 0) return context; | |
| 1506 | return { | |
| 1507 | ...context, | |
| 1508 | processEnvironment: ScriptProgress.withScriptProgressEnvironment( | |
| 1509 | context.processEnvironment ?? {}, | |
| 1510 | environment, | |
| 1511 | ), | |
| 1512 | }; | |
| 1513 | }); | |
| 1501 | 1514 | const driver = flavor.driver; |
| 1502 | 1515 | const continuationRequests = options.continuationRequests; |
| 1503 | 1516 | const postSettleContinuationEnabled = |
| ... | ... | @@ -8085,4 +8098,5 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( |
| 8085 | 8098 | export type AcpAdapterV2Env = |
| 8086 | 8099 | | FileSystem.FileSystem |
| 8087 | 8100 | | IdAllocator.IdAllocatorV2 |
| 8101 | | ScriptProgress.ScriptProgress | |
| 8088 | 8102 | | McpProviderSessions.McpProviderSessions; |
packages/provider-acp/src/server/clientTerminals.test.ts+47| ... | ... | @@ -71,6 +71,53 @@ describe("AcpClientTerminals", () => { |
| 71 | 71 | }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), |
| 72 | 72 | ); |
| 73 | 73 | |
| 74 | it.effect("loads per-session progress without losing provider or command Node options", () => | |
| 75 | Effect.gen(function* () { | |
| 76 | const environment = { NODE_OPTIONS: "--no-warnings", PROVIDER_SETTING: "keep" }; | |
| 77 | const preload = "--import=data:text/javascript,globalThis.progressPreloaded=true"; | |
| 78 | const terminals = yield* makeAcpClientTerminals({ | |
| 79 | defaultCwd: process.cwd(), | |
| 80 | environment, | |
| 81 | environmentForSession: (sessionId) => ({ | |
| 82 | NODE_OPTIONS: preload, | |
| 83 | T3_SCRIPT_PROGRESS_TOKEN: sessionId, | |
| 84 | }), | |
| 85 | }); | |
| 86 | yield* Effect.addFinalizer(() => terminals.disposeAll); | |
| 87 | const results = yield* Effect.all( | |
| 88 | ["session-one", "session-two"].map((sessionId) => | |
| 89 | Effect.gen(function* () { | |
| 90 | const created = yield* terminals.create({ | |
| 91 | sessionId, | |
| 92 | command: process.execPath, | |
| 93 | args: [ | |
| 94 | "-e", | |
| 95 | "console.log(JSON.stringify({token:process.env.T3_SCRIPT_PROGRESS_TOKEN, options:process.env.NODE_OPTIONS, provider:process.env.PROVIDER_SETTING, loaded:globalThis.progressPreloaded}))", | |
| 96 | ], | |
| 97 | env: [ | |
| 98 | { name: "NODE_OPTIONS", value: "--trace-warnings" }, | |
| 99 | { name: "T3_SCRIPT_PROGRESS_TOKEN", value: "stale-inherited-token" }, | |
| 100 | ], | |
| 101 | }); | |
| 102 | yield* terminals.waitForExit({ sessionId, terminalId: created.terminalId }); | |
| 103 | const output = yield* terminals.output({ sessionId, terminalId: created.terminalId }); | |
| 104 | return JSON.parse(output.output); | |
| 105 | }), | |
| 106 | ), | |
| 107 | { concurrency: "unbounded" }, | |
| 108 | ); | |
| 109 | expect(results).toEqual( | |
| 110 | ["session-one", "session-two"].map((token) => ({ | |
| 111 | token, | |
| 112 | options: `${preload} --trace-warnings --no-warnings`, | |
| 113 | provider: "keep", | |
| 114 | loaded: true, | |
| 115 | })), | |
| 116 | ); | |
| 117 | expect(environment).toEqual({ NODE_OPTIONS: "--no-warnings", PROVIDER_SETTING: "keep" }); | |
| 118 | }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), | |
| 119 | ); | |
| 120 | ||
| 74 | 121 | it.effect("runs Devin shell source with arguments, pipes, and session fallback variables", () => |
| 75 | 122 | Effect.gen(function* () { |
| 76 | 123 | const terminals = yield* makeAcpClientTerminals({ |
packages/provider-acp/src/server/clientTerminals.ts+8-1| ... | ... | @@ -1,3 +1,4 @@ |
| 1 | import * as ScriptProgress from "@t3tools/provider-core/server/ScriptProgress"; | |
| 1 | 2 | import * as Deferred from "effect/Deferred"; |
| 2 | 3 | import * as Duration from "effect/Duration"; |
| 3 | 4 | import * as Effect from "effect/Effect"; |
| ... | ... | @@ -236,7 +237,13 @@ export const makeAcpClientTerminals = Effect.fn("makeAcpClientTerminals")(functi |
| 236 | 237 | sessionEnvironment === undefined && |
| 237 | 238 | (request.env?.length ?? 0) === 0 |
| 238 | 239 | ? undefined |
| 239 | : { ...options.environment, ...sessionEnvironment, ...requestEnvironment }; | |
| 240 | : ScriptProgress.withScriptProgressEnvironment( | |
| 241 | ScriptProgress.withScriptProgressEnvironment( | |
| 242 | options.environment ?? {}, | |
| 243 | requestEnvironment, | |
| 244 | ), | |
| 245 | sessionEnvironment ?? {}, | |
| 246 | ); | |
| 240 | 247 | // Each terminal owns a scope so the spawned process is reliably reaped: |
| 241 | 248 | // closing the scope kills a still-running command and frees the handle. |
| 242 | 249 | const terminalScope = yield* Scope.make(); |
packages/provider-core/package.json+4| ... | ... | @@ -39,6 +39,10 @@ |
| 39 | 39 | "types": "./src/server/handoffBudget.ts", |
| 40 | 40 | "import": "./src/server/handoffBudget.ts" |
| 41 | 41 | }, |
| 42 | "./server/ScriptProgress": { | |
| 43 | "types": "./src/server/ScriptProgress.ts", | |
| 44 | "import": "./src/server/ScriptProgress.ts" | |
| 45 | }, | |
| 42 | 46 | "./server/IdAllocator": { |
| 43 | 47 | "types": "./src/server/IdAllocator.ts", |
| 44 | 48 | "import": "./src/server/IdAllocator.ts" |
packages/provider-core/src/server/ScriptProgress.ts created+50| ... | ... | @@ -0,0 +1,50 @@ |
| 1 | import type { ScriptProgressEvent, ScriptProgressStreamError, ThreadId } from "@t3tools/contracts"; | |
| 2 | import * as Context from "effect/Context"; | |
| 3 | import * as Effect from "effect/Effect"; | |
| 4 | import * as Layer from "effect/Layer"; | |
| 5 | import * as Stream from "effect/Stream"; | |
| 6 | ||
| 7 | export class ScriptProgress extends Context.Service< | |
| 8 | ScriptProgress, | |
| 9 | { | |
| 10 | readonly environmentForThread: (threadId: ThreadId) => Effect.Effect<Record<string, string>>; | |
| 11 | readonly subscribe: ( | |
| 12 | threadId: ThreadId, | |
| 13 | ) => Stream.Stream<ScriptProgressEvent, ScriptProgressStreamError>; | |
| 14 | } | |
| 15 | >()("@t3tools/provider-core/server/ScriptProgress") {} | |
| 16 | ||
| 17 | export function withScriptProgressEnvironment( | |
| 18 | environment: Record<string, string>, | |
| 19 | progress: Readonly<Record<string, string>>, | |
| 20 | ): Record<string, string>; | |
| 21 | export function withScriptProgressEnvironment( | |
| 22 | environment: NodeJS.ProcessEnv, | |
| 23 | progress: NodeJS.ProcessEnv, | |
| 24 | ): NodeJS.ProcessEnv; | |
| 25 | export function withScriptProgressEnvironment( | |
| 26 | environment: NodeJS.ProcessEnv, | |
| 27 | progress: NodeJS.ProcessEnv, | |
| 28 | ): NodeJS.ProcessEnv { | |
| 29 | return { | |
| 30 | ...environment, | |
| 31 | ...progress, | |
| 32 | ...(progress.NODE_OPTIONS | |
| 33 | ? { | |
| 34 | NODE_OPTIONS: | |
| 35 | environment.NODE_OPTIONS === progress.NODE_OPTIONS || | |
| 36 | environment.NODE_OPTIONS?.startsWith(`${progress.NODE_OPTIONS} `) | |
| 37 | ? environment.NODE_OPTIONS | |
| 38 | : [progress.NODE_OPTIONS, environment.NODE_OPTIONS].filter(Boolean).join(" "), | |
| 39 | } | |
| 40 | : {}), | |
| 41 | }; | |
| 42 | } | |
| 43 | ||
| 44 | export const layerUnavailable = Layer.succeed( | |
| 45 | ScriptProgress, | |
| 46 | ScriptProgress.of({ | |
| 47 | environmentForThread: () => Effect.succeed({}), | |
| 48 | subscribe: () => Stream.succeed({ type: "snapshot", streams: [] }), | |
| 49 | }), | |
| 50 | ); |
packages/provider-grok/src/server/adapter.ts+7-1| ... | ... | @@ -1,3 +1,4 @@ |
| 1 | import * as ScriptProgress from "@t3tools/provider-core/server/ScriptProgress"; | |
| 1 | 2 | import { makeProviderFailure } from "@t3tools/provider-core/server/failure"; |
| 2 | 3 | import { |
| 3 | 4 | XAiPromptFailureText, |
| ... | ... | @@ -280,7 +281,10 @@ export function makeGrokAcpAdapterFlavor( |
| 280 | 281 | ...input, |
| 281 | 282 | interruptPromptOnCancel: input.interruptPromptOnCancel ?? false, |
| 282 | 283 | grokSettings: options.settings, |
| 283 | environment: options.environment, | |
| 284 | environment: ScriptProgress.withScriptProgressEnvironment( | |
| 285 | options.environment, | |
| 286 | input.processEnvironment ?? {}, | |
| 287 | ), | |
| 284 | 288 | runtimeMode: grokLaunchRuntimeMode(runtimePolicy), |
| 285 | 289 | })), |
| 286 | 290 | // In its Auto mode Grok decides routine actions itself and only asks about |
| ... | ... | @@ -351,6 +355,7 @@ export type GrokAdapterV2DriverEnv = |
| 351 | 355 | | FileSystem.FileSystem |
| 352 | 356 | | IdAllocator.IdAllocatorV2 |
| 353 | 357 | | Path.Path |
| 358 | | ScriptProgress.ScriptProgress | |
| 354 | 359 | | McpProviderSessions.McpProviderSessions |
| 355 | 360 | | ProviderEventLoggers.ProviderEventLoggers |
| 356 | 361 | | ProviderHost.ProviderHost; |
| ... | ... | @@ -405,6 +410,7 @@ const layer: Layer.Layer< |
| 405 | 410 | | Crypto.Crypto |
| 406 | 411 | | FileSystem.FileSystem |
| 407 | 412 | | IdAllocator.IdAllocatorV2 |
| 413 | | ScriptProgress.ScriptProgress | |
| 408 | 414 | | McpProviderSessions.McpProviderSessions |
| 409 | 415 | | ProviderEventLoggers.ProviderEventLoggers |
| 410 | 416 | | ProviderHost.ProviderHost |
packages/provider-grok/src/server/driver.test.ts+2-1| ... | ... | @@ -1,3 +1,4 @@ |
| 1 | import * as ScriptProgress from "@t3tools/provider-core/server/ScriptProgress"; | |
| 1 | 2 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 2 | 3 | import { expect, it } from "@effect/vitest"; |
| 3 | 4 | import { ProviderInstanceId } from "@t3tools/contracts"; |
| ... | ... | @@ -20,7 +21,7 @@ import * as McpProviderSessions from "@t3tools/provider-core/server/McpProviderS |
| 20 | 21 | const layerTest = TestProviderHost.layer({ runBackgroundWork: false }).pipe( |
| 21 | 22 | Layer.provideMerge(NodeServices.layer), |
| 22 | 23 | Layer.provideMerge(IdAllocator.layer), |
| 23 | Layer.provideMerge(McpProviderSessions.layer), | |
| 24 | Layer.provideMerge(Layer.merge(McpProviderSessions.layer, ScriptProgress.layerUnavailable)), | |
| 24 | 25 | Layer.provideMerge(ProviderLatestVersions.layer), |
| 25 | 26 | Layer.provideMerge( |
| 26 | 27 | Layer.succeed( |
packages/provider-muse/src/server/adapter.test.ts+2| ... | ... | @@ -1,3 +1,4 @@ |
| 1 | import * as ScriptProgress from "@t3tools/provider-core/server/ScriptProgress"; | |
| 1 | 2 | import { assert, describe, it } from "@effect/vitest"; |
| 2 | 3 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 3 | 4 | import { MspError } from "@muse-code/sdk"; |
| ... | ... | @@ -44,6 +45,7 @@ const testLayer = Layer.mergeAll( |
| 44 | 45 | NodeServices.layer, |
| 45 | 46 | IdAllocator.layer, |
| 46 | 47 | McpProviderSessions.layer, |
| 48 | ScriptProgress.layerUnavailable, | |
| 47 | 49 | TestProviderHost.layer().pipe(Layer.provide(NodeServices.layer)), |
| 48 | 50 | ); |
| 49 | 51 | const MUSE_PROVIDER = ProviderDriverKind.make("muse"); |
packages/provider-muse/src/server/adapter.ts+5-3| ... | ... | @@ -1,3 +1,4 @@ |
| 1 | import * as ScriptProgress from "@t3tools/provider-core/server/ScriptProgress"; | |
| 1 | 2 | import { MspError, type SendUserTurnOptions } from "@muse-code/sdk"; |
| 2 | 3 | import { |
| 3 | 4 | MUSE_DEFAULT_MODEL, |
| ... | ... | @@ -276,6 +277,7 @@ export const makeMuseAdapterV2 = Effect.fn("makeMuseAdapterV2")(function* ( |
| 276 | 277 | const providerHost = yield* ProviderHost.ProviderHost; |
| 277 | 278 | const fileSystem = yield* FileSystem.FileSystem; |
| 278 | 279 | const mcpSessions = yield* McpProviderSessions.McpProviderSessions; |
| 280 | const scriptProgress = yield* ScriptProgress.ScriptProgress; | |
| 279 | 281 | const agentScope = yield* AgentScope; |
| 280 | 282 | |
| 281 | 283 | const protocolError = (detail: string, payload?: unknown) => |
| ... | ... | @@ -1650,9 +1652,9 @@ export const makeMuseAdapterV2 = Effect.fn("makeMuseAdapterV2")(function* ( |
| 1650 | 1652 | const launchHost = Effect.fnUntraced(function* () { |
| 1651 | 1653 | const epoch = ++hostEpoch; |
| 1652 | 1654 | const mcpSession = yield* mcpSessions.read(input.threadId); |
| 1653 | const environment = McpProviderSession.withAgentDeviceEnvironment( | |
| 1654 | options.environment, | |
| 1655 | mcpSession, | |
| 1655 | const environment = ScriptProgress.withScriptProgressEnvironment( | |
| 1656 | McpProviderSession.withAgentDeviceEnvironment(options.environment, mcpSession), | |
| 1657 | yield* scriptProgress.environmentForThread(input.threadId), | |
| 1656 | 1658 | ); |
| 1657 | 1659 | const launch = yield* agentScope.wrap({ |
| 1658 | 1660 | command: options.settings.binaryPath || "muse", |
packages/provider-muse/src/server/driver.ts+2| ... | ... | @@ -1,3 +1,4 @@ |
| 1 | import type * as ScriptProgress from "@t3tools/provider-core/server/ScriptProgress"; | |
| 1 | 2 | import { ProviderDriverKind } from "@t3tools/contracts"; |
| 2 | 3 | import { MuseSettings } from "../settings.ts"; |
| 3 | 4 | import * as HostProcess from "@t3tools/shared/HostProcess"; |
| ... | ... | @@ -44,6 +45,7 @@ const decodeMuseSettings = Schema.decodeSync(MuseSettings); |
| 44 | 45 | |
| 45 | 46 | export type MuseDriverEnv = |
| 46 | 47 | | IdAllocator.IdAllocatorV2 |
| 48 | | ScriptProgress.ScriptProgress | |
| 47 | 49 | | McpProviderSessions.McpProviderSessions |
| 48 | 50 | | ProviderHost.ProviderHost |
| 49 | 51 | | ChildProcessSpawner.ChildProcessSpawner |
packages/provider-pi/src/server/adapter.test.ts+2| ... | ... | @@ -1,3 +1,4 @@ |
| 1 | import * as ScriptProgress from "@t3tools/provider-core/server/ScriptProgress"; | |
| 1 | 2 | import { assert, describe, it } from "@effect/vitest"; |
| 2 | 3 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 3 | 4 | import { |
| ... | ... | @@ -54,6 +55,7 @@ const layerTest = Layer.mergeAll( |
| 54 | 55 | NodeServices.layer, |
| 55 | 56 | IdAllocator.layer, |
| 56 | 57 | McpProviderSessions.layer, |
| 58 | ScriptProgress.layerUnavailable, | |
| 57 | 59 | TestProviderHost.layer().pipe(Layer.provide(NodeServices.layer)), |
| 58 | 60 | ); |
| 59 | 61 |
packages/provider-pi/src/server/adapter.ts+7-1| ... | ... | @@ -1,3 +1,4 @@ |
| 1 | import * as ScriptProgress from "@t3tools/provider-core/server/ScriptProgress"; | |
| 1 | 2 | /** |
| 2 | 3 | * PiAdapterV2 — orchestrator-v2 adapter for the Pi coding agent |
| 3 | 4 | * (https://pi.dev), driving `pi --mode rpc` over stdio JSONL via `PiRpc.ts`. |
| ... | ... | @@ -498,6 +499,7 @@ export const makePiAdapterV2 = Effect.fn("makePiAdapterV2")(function* ( |
| 498 | 499 | const idAllocator = yield* IdAllocator.IdAllocatorV2; |
| 499 | 500 | const host = yield* ProviderHost.ProviderHost; |
| 500 | 501 | const mcpSessions = yield* McpProviderSessions.McpProviderSessions; |
| 502 | const scriptProgress = yield* ScriptProgress.ScriptProgress; | |
| 501 | 503 | const { continuationRequests } = options; |
| 502 | 504 | |
| 503 | 505 | const protocolError = (detail: string, payload?: unknown) => |
| ... | ... | @@ -542,7 +544,10 @@ export const makePiAdapterV2 = Effect.fn("makePiAdapterV2")(function* ( |
| 542 | 544 | } |
| 543 | 545 | const launch = buildPiRpcLaunch({ |
| 544 | 546 | launchArgs: resolvedLaunchArgs.args, |
| 545 | environment: options.environment, | |
| 547 | environment: ScriptProgress.withScriptProgressEnvironment( | |
| 548 | options.environment, | |
| 549 | yield* scriptProgress.environmentForThread(input.threadId), | |
| 550 | ), | |
| 546 | 551 | mcpSession, |
| 547 | 552 | extensionPath, |
| 548 | 553 | runtimeMode: input.runtimePolicy.runtimeMode, |
| ... | ... | @@ -3428,6 +3433,7 @@ export type PiAdapterV2DriverEnv = |
| 3428 | 3433 | | ChildProcessSpawner.ChildProcessSpawner |
| 3429 | 3434 | | FileSystem.FileSystem |
| 3430 | 3435 | | IdAllocator.IdAllocatorV2 |
| 3436 | | ScriptProgress.ScriptProgress | |
| 3431 | 3437 | | McpProviderSessions.McpProviderSessions |
| 3432 | 3438 | | ProviderHost.ProviderHost; |
| 3433 | 3439 |
packages/provider-pi/src/server/driver.test.ts+2| ... | ... | @@ -1,3 +1,4 @@ |
| 1 | import * as ScriptProgress from "@t3tools/provider-core/server/ScriptProgress"; | |
| 1 | 2 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 2 | 3 | import { assert, it } from "@effect/vitest"; |
| 3 | 4 | import { DEFAULT_SERVER_SETTINGS, ProviderInstanceId } from "@t3tools/contracts"; |
| ... | ... | @@ -29,6 +30,7 @@ const layerTest = Layer.mergeAll( |
| 29 | 30 | }), |
| 30 | 31 | IdAllocator.layer, |
| 31 | 32 | McpProviderSessions.layer, |
| 33 | ScriptProgress.layerUnavailable, | |
| 32 | 34 | ProviderLatestVersions.layer, |
| 33 | 35 | Layer.succeed( |
| 34 | 36 | HttpClient.HttpClient, |
packages/shared/package.json+4| ... | ... | @@ -494,6 +494,10 @@ |
| 494 | 494 | "./browserChromeIdentity": { |
| 495 | 495 | "types": "./src/browserChromeIdentity.ts", |
| 496 | 496 | "import": "./src/browserChromeIdentity.ts" |
| 497 | }, | |
| 498 | "./progressPreload": { | |
| 499 | "types": "./src/progressPreload.ts", | |
| 500 | "import": "./src/progressPreload.ts" | |
| 497 | 501 | } |
| 498 | 502 | }, |
| 499 | 503 | "scripts": { |
packages/shared/src/progressPreload.test.ts created+559| ... | ... | @@ -0,0 +1,559 @@ |
| 1 | // @effect-diagnostics nodeBuiltinImport:off - Exercise the actual standalone Node preload and TCP transport. | |
| 2 | import * as NodeChildProcess from "node:child_process"; | |
| 3 | import * as NodeEvents from "node:events"; | |
| 4 | import * as NodeFSP from "node:fs/promises"; | |
| 5 | import * as NodeNet from "node:net"; | |
| 6 | import * as NodeOS from "node:os"; | |
| 7 | import * as NodePath from "node:path"; | |
| 8 | import * as NodeURL from "node:url"; | |
| 9 | import { afterEach, describe, expect, it } from "vite-plus/test"; | |
| 10 | ||
| 11 | import { decodeEventStream, Root, type StreamEvent } from "../../clo-lib/progress.ts"; | |
| 12 | import { progressPreloadSource } from "./progressPreload.ts"; | |
| 13 | ||
| 14 | interface Packet { | |
| 15 | type: "hello" | "progress"; | |
| 16 | token?: string; | |
| 17 | pid?: number; | |
| 18 | producerId?: string; | |
| 19 | streamId?: string; | |
| 20 | event?: StreamEvent; | |
| 21 | } | |
| 22 | ||
| 23 | const cleanups: Array<() => Promise<unknown>> = []; | |
| 24 | afterEach(async () => { | |
| 25 | for (const cleanup of cleanups.splice(0).toReversed()) await cleanup(); | |
| 26 | }); | |
| 27 | ||
| 28 | async function fixture() { | |
| 29 | const directory = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3 progress preload ")); | |
| 30 | cleanups.push(() => NodeFSP.rm(directory, { recursive: true, force: true })); | |
| 31 | const preload = NodePath.join(directory, "preload.cjs"); | |
| 32 | await NodeFSP.writeFile(preload, progressPreloadSource); | |
| 33 | const packets: Packet[] = []; | |
| 34 | const sockets = new Set<NodeNet.Socket>(); | |
| 35 | const changes = new Set<() => void>(); | |
| 36 | const server = NodeNet.createServer((socket) => { | |
| 37 | sockets.add(socket); | |
| 38 | socket.on("close", () => sockets.delete(socket)); | |
| 39 | socket.on("error", () => {}); | |
| 40 | let pending = ""; | |
| 41 | socket.on("data", (chunk) => { | |
| 42 | pending += chunk.toString(); | |
| 43 | let index: number; | |
| 44 | while ((index = pending.indexOf("\n")) !== -1) { | |
| 45 | packets.push(JSON.parse(pending.slice(0, index)) as Packet); | |
| 46 | pending = pending.slice(index + 1); | |
| 47 | } | |
| 48 | for (const changed of changes) changed(); | |
| 49 | }); | |
| 50 | }); | |
| 51 | server.listen(0, "127.0.0.1"); | |
| 52 | await NodeEvents.EventEmitter.once(server, "listening"); | |
| 53 | const address = server.address(); | |
| 54 | if (!address || typeof address === "string") throw new Error("Missing listener port"); | |
| 55 | cleanups.push(async () => { | |
| 56 | for (const socket of sockets) socket.destroy(); | |
| 57 | await new Promise<void>((resolve) => server.close(() => resolve())); | |
| 58 | }); | |
| 59 | const environment = { | |
| 60 | ...process.env, | |
| 61 | NODE_OPTIONS: `--require=${JSON.stringify(preload)}`, | |
| 62 | T3_SCRIPT_PROGRESS_ENDPOINT: `127.0.0.1:${address.port}`, | |
| 63 | T3_SCRIPT_PROGRESS_TOKEN: "test-progress-token", | |
| 64 | }; | |
| 65 | const waitFor = (predicate: (rows: readonly Packet[]) => boolean) => { | |
| 66 | if (predicate(packets)) return Promise.resolve(); | |
| 67 | return new Promise<void>((resolve) => { | |
| 68 | const changed = () => { | |
| 69 | if (!predicate(packets)) return; | |
| 70 | changes.delete(changed); | |
| 71 | resolve(); | |
| 72 | }; | |
| 73 | changes.add(changed); | |
| 74 | }); | |
| 75 | }; | |
| 76 | let scriptNumber = 0; | |
| 77 | function run( | |
| 78 | source: string, | |
| 79 | options: { endpoint?: boolean; shebang?: boolean; preload?: boolean } = {}, | |
| 80 | ) { | |
| 81 | const script = NodePath.join(directory, `script-${++scriptNumber}.mjs`); | |
| 82 | const childReady = Promise.withResolvers<void>(); | |
| 83 | const children: NodeChildProcess.ChildProcess[] = []; | |
| 84 | const completion = (async () => { | |
| 85 | await NodeFSP.writeFile( | |
| 86 | script, | |
| 87 | `${options.shebang ? `#!${process.execPath}\n` : ""}${source}`, | |
| 88 | ); | |
| 89 | if (options.shebang) await NodeFSP.chmod(script, 0o700); | |
| 90 | const child = NodeChildProcess.spawn( | |
| 91 | options.shebang ? script : process.execPath, | |
| 92 | options.shebang ? [] : [script], | |
| 93 | { | |
| 94 | cwd: NodeOS.tmpdir(), | |
| 95 | env: { | |
| 96 | ...environment, | |
| 97 | ...(options.endpoint === false ? { T3_SCRIPT_PROGRESS_ENDPOINT: "" } : {}), | |
| 98 | ...(options.preload === false ? { NODE_OPTIONS: "" } : {}), | |
| 99 | }, | |
| 100 | stdio: ["ignore", "pipe", "pipe", "ipc"], | |
| 101 | }, | |
| 102 | ); | |
| 103 | children.push(child); | |
| 104 | child.on("message", () => childReady.resolve()); | |
| 105 | let stdout = ""; | |
| 106 | let stderr = ""; | |
| 107 | child.stdout?.on("data", (chunk) => { | |
| 108 | stdout += chunk; | |
| 109 | }); | |
| 110 | child.stderr?.on("data", (chunk) => { | |
| 111 | stderr += chunk; | |
| 112 | }); | |
| 113 | const [code] = await NodeEvents.EventEmitter.once(child, "close"); | |
| 114 | return { code, stdout, stderr }; | |
| 115 | })(); | |
| 116 | cleanups.push(async () => { | |
| 117 | for (const child of children) | |
| 118 | if (child.exitCode === null && child.signalCode === null) child.kill(); | |
| 119 | await completion; | |
| 120 | }); | |
| 121 | return { | |
| 122 | completion, | |
| 123 | ready: childReady.promise, | |
| 124 | send: () => { | |
| 125 | const child = children[0]; | |
| 126 | if (!child) throw new Error("Child has not started"); | |
| 127 | child.send("continue"); | |
| 128 | }, | |
| 129 | }; | |
| 130 | } | |
| 131 | return { directory, environment, packets, sockets, waitFor, run }; | |
| 132 | } | |
| 133 | ||
| 134 | const localCopies: string[] = JSON.parse(process.env.T3_PROGRESS_TEST_MODULES ?? "[]"); | |
| 135 | const copies = [ | |
| 136 | NodeURL.fileURLToPath(new URL("../../clo-lib/progress.ts", import.meta.url)), | |
| 137 | ...localCopies, | |
| 138 | ]; | |
| 139 | const hasNode = (packets: readonly Packet[], label: string) => | |
| 140 | packets.some((packet) => | |
| 141 | packet.event?.some( | |
| 142 | (part) => | |
| 143 | typeof part === "object" && part !== null && !Array.isArray(part) && part.t === label, | |
| 144 | ), | |
| 145 | ); | |
| 146 | ||
| 147 | async function decodeSnapshot(events: StreamEvent[]) { | |
| 148 | const root = new Root(); | |
| 149 | let controller: ReadableStreamDefaultController<StreamEvent>; | |
| 150 | const input = new ReadableStream<StreamEvent>({ | |
| 151 | start(value) { | |
| 152 | controller = value; | |
| 153 | }, | |
| 154 | }); | |
| 155 | const decoding = decodeEventStream<void, { checkpoint: [] }>(input, root); | |
| 156 | const ready = new Promise<void>((resolve) => decoding.on("checkpoint", resolve)); | |
| 157 | for (const event of events) controller!.enqueue(event); | |
| 158 | controller!.enqueue([0, ["checkpoint"]]); | |
| 159 | await ready; | |
| 160 | const result = root.active.map((node) => ({ | |
| 161 | text: node.text, | |
| 162 | value: node.value, | |
| 163 | children: node.children.map((child) => ({ text: child.text, value: child.value })), | |
| 164 | })); | |
| 165 | decoding.cancel(); | |
| 166 | return result; | |
| 167 | } | |
| 168 | ||
| 169 | describe.each(copies)("progress preload using %s", (copy) => { | |
| 170 | it("captures global and custom roots, nested updates, and preserves normal output", async () => { | |
| 171 | const f = await fixture(); | |
| 172 | const child = f.run(` | |
| 173 | const p = await import(${JSON.stringify(NodeURL.pathToFileURL(copy).href)}); | |
| 174 | p.start("global task"); | |
| 175 | const root = new p.Root(); | |
| 176 | const parent = root.start("custom task", { total: 10, value: 4 }); | |
| 177 | parent.start("nested task", { total: 3, value: 1 }); | |
| 178 | console.log("script stdout"); | |
| 179 | console.error("script stderr"); | |
| 180 | process.send("ready"); | |
| 181 | await new Promise(resolve => process.once("message", resolve)); | |
| 182 | parent.value = 0; | |
| 183 | parent.text = ""; | |
| 184 | process.send("updated"); | |
| 185 | await new Promise(resolve => process.once("message", resolve)); | |
| 186 | process.disconnect(); | |
| 187 | `); | |
| 188 | await child.ready; | |
| 189 | await f.waitFor((rows) => hasNode(rows, "global task") && hasNode(rows, "nested task")); | |
| 190 | expect(f.packets.find((packet) => packet.type === "hello")).toMatchObject({ | |
| 191 | token: "test-progress-token", | |
| 192 | producerId: expect.any(String), | |
| 193 | }); | |
| 194 | child.send(); | |
| 195 | await f.waitFor((rows) => | |
| 196 | rows.some((row) => | |
| 197 | row.event?.some( | |
| 198 | (part) => | |
| 199 | typeof part === "object" && | |
| 200 | part !== null && | |
| 201 | !Array.isArray(part) && | |
| 202 | part.t === "" && | |
| 203 | part.v === 0, | |
| 204 | ), | |
| 205 | ), | |
| 206 | ); | |
| 207 | const stream = f.packets.find((packet) => hasNode([packet], "custom task"))?.streamId; | |
| 208 | expect(stream).toBeDefined(); | |
| 209 | const events = f.packets.flatMap((packet) => | |
| 210 | packet.streamId === stream && packet.event ? [packet.event] : [], | |
| 211 | ); | |
| 212 | expect(await decodeSnapshot(events)).toEqual([ | |
| 213 | { text: "", value: 0, children: [{ text: "nested task", value: 1 }] }, | |
| 214 | ]); | |
| 215 | child.send(); | |
| 216 | const baseline = f.run( | |
| 217 | `const p = await import(${JSON.stringify(NodeURL.pathToFileURL(copy).href)}); p.start("global task"); console.log("script stdout"); console.error("script stderr"); process.disconnect();`, | |
| 218 | { preload: false }, | |
| 219 | ); | |
| 220 | expect(await child.completion).toEqual(await baseline.completion); | |
| 221 | }); | |
| 222 | ||
| 223 | it("fails open without a configured endpoint", async () => { | |
| 224 | const f = await fixture(); | |
| 225 | const child = f.run( | |
| 226 | `const p = await import(${JSON.stringify(NodeURL.pathToFileURL(copy).href)}); p.start("unobserved"); console.log("unchanged"); process.disconnect();`, | |
| 227 | { endpoint: false }, | |
| 228 | ); | |
| 229 | const baseline = f.run( | |
| 230 | `const p = await import(${JSON.stringify(NodeURL.pathToFileURL(copy).href)}); p.start("unobserved"); console.log("unchanged"); process.disconnect();`, | |
| 231 | { preload: false }, | |
| 232 | ); | |
| 233 | expect(await child.completion).toEqual(await baseline.completion); | |
| 234 | expect(f.packets).toHaveLength(0); | |
| 235 | }); | |
| 236 | }); | |
| 237 | ||
| 238 | it("captures inherited preload in nested absolute Node and executable shebang scripts", async () => { | |
| 239 | const f = await fixture(); | |
| 240 | const nested = NodePath.join(f.directory, "nested.mjs"); | |
| 241 | await NodeFSP.writeFile( | |
| 242 | nested, | |
| 243 | `import { start } from ${JSON.stringify(NodeURL.pathToFileURL(copies[0]!).href)}; start("nested process"); console.log("nested stdout");`, | |
| 244 | ); | |
| 245 | const child = f.run( | |
| 246 | ` | |
| 247 | import { spawn } from "node:child_process"; | |
| 248 | const child = spawn(process.execPath, [${JSON.stringify(nested)}], { stdio: ["ignore", "inherit", "inherit"] }); | |
| 249 | child.on("exit", code => { process.exitCode = code; process.disconnect(); }); | |
| 250 | `, | |
| 251 | { shebang: true }, | |
| 252 | ); | |
| 253 | expect(await child.completion).toEqual({ code: 0, stdout: "nested stdout\n", stderr: "" }); | |
| 254 | expect(hasNode(f.packets, "nested process")).toBe(true); | |
| 255 | expect(f.packets.filter((packet) => packet.type === "hello")).toHaveLength(1); | |
| 256 | }); | |
| 257 | ||
| 258 | it("leaves unrelated Node modules and exit status alone", async () => { | |
| 259 | const f = await fixture(); | |
| 260 | const child = f.run( | |
| 261 | `import { basename } from "node:path"; console.log(basename("/some/file")); process.disconnect(); process.exit(7);`, | |
| 262 | ); | |
| 263 | expect(await child.completion).toEqual({ code: 7, stdout: "file\n", stderr: "" }); | |
| 264 | expect(f.packets).toHaveLength(0); | |
| 265 | }); | |
| 266 | ||
| 267 | it("resnapshots active roots after the collector connection is replaced", async () => { | |
| 268 | const f = await fixture(); | |
| 269 | const child = f.run(` | |
| 270 | import { Root } from ${JSON.stringify(NodeURL.pathToFileURL(copies[0]!).href)}; | |
| 271 | const root = new Root(); | |
| 272 | root.start("reconnect parent", { total: 8, value: 3 }).start("reconnect child"); | |
| 273 | process.send("ready"); | |
| 274 | await new Promise(resolve => process.once("message", resolve)); | |
| 275 | process.disconnect(); | |
| 276 | `); | |
| 277 | await child.ready; | |
| 278 | await f.waitFor((rows) => hasNode(rows, "reconnect child")); | |
| 279 | const firstCount = f.packets.length; | |
| 280 | for (const socket of f.sockets) socket.destroy(); | |
| 281 | await f.waitFor( | |
| 282 | (rows) => | |
| 283 | rows.filter((row) => row.type === "hello").length === 2 && | |
| 284 | hasNode(rows.slice(firstCount), "reconnect child"), | |
| 285 | ); | |
| 286 | expect( | |
| 287 | new Set(f.packets.filter((row) => row.type === "hello").map((row) => row.producerId)).size, | |
| 288 | ).toBe(1); | |
| 289 | const snapshot = f.packets.slice(firstCount).find((row) => row.event)?.event; | |
| 290 | expect(snapshot?.[0]).toBe(-1); | |
| 291 | expect(await decodeSnapshot(snapshot ? [snapshot] : [])).toEqual([ | |
| 292 | { text: "reconnect parent", value: 3, children: [{ text: "reconnect child", value: 0 }] }, | |
| 293 | ]); | |
| 294 | child.send(); | |
| 295 | expect((await child.completion).code).toBe(0); | |
| 296 | }); | |
| 297 | ||
| 298 | it("observes separate loaded copies without sharing root identities", async () => { | |
| 299 | const f = await fixture(); | |
| 300 | const url = NodeURL.pathToFileURL(copies[0]!).href; | |
| 301 | const child = f.run(` | |
| 302 | const first = await import(${JSON.stringify(url)}); | |
| 303 | const second = await import(${JSON.stringify(`${url}?second-copy`)}); | |
| 304 | first.start("first copy"); | |
| 305 | second.start("second copy"); | |
| 306 | process.send("ready"); | |
| 307 | await new Promise(resolve => process.once("message", resolve)); | |
| 308 | process.disconnect(); | |
| 309 | `); | |
| 310 | await child.ready; | |
| 311 | await f.waitFor((rows) => hasNode(rows, "first copy") && hasNode(rows, "second copy")); | |
| 312 | const ids = f.packets | |
| 313 | .filter((row) => hasNode([row], "first copy") || hasNode([row], "second copy")) | |
| 314 | .map((row) => row.streamId); | |
| 315 | expect(new Set(ids).size).toBe(2); | |
| 316 | expect(f.packets.filter((row) => row.type === "hello")).toHaveLength(1); | |
| 317 | child.send(); | |
| 318 | expect((await child.completion).code).toBe(0); | |
| 319 | }); | |
| 320 | ||
| 321 | it("fails open when the Node runtime cannot install synchronous module hooks", async () => { | |
| 322 | const f = await fixture(); | |
| 323 | const shim = NodePath.join(f.directory, "old-runtime.cjs"); | |
| 324 | await NodeFSP.writeFile( | |
| 325 | shim, | |
| 326 | `const modules = require("node:module"); modules.registerHooks = undefined; modules.syncBuiltinESMExports();`, | |
| 327 | ); | |
| 328 | f.environment.NODE_OPTIONS = `--require=${JSON.stringify(shim)} ${f.environment.NODE_OPTIONS}`; | |
| 329 | const child = f.run( | |
| 330 | `import { start } from ${JSON.stringify(NodeURL.pathToFileURL(copies[0]!).href)}; start("not captured"); console.log("still works"); process.disconnect();`, | |
| 331 | ); | |
| 332 | expect(await child.completion).toEqual({ code: 0, stdout: "still works\n", stderr: "" }); | |
| 333 | expect(f.packets).toHaveLength(0); | |
| 334 | }); | |
| 335 | ||
| 336 | it("preserves genuine parent FD forwarding while capturing an independent child root", async () => { | |
| 337 | const f = await fixture(); | |
| 338 | const url = NodeURL.pathToFileURL(copies[0]!).href; | |
| 339 | const nested = NodePath.join(f.directory, "forwarded.mjs"); | |
| 340 | await NodeFSP.writeFile( | |
| 341 | nested, | |
| 342 | ` | |
| 343 | import * as p from ${JSON.stringify(url)}; | |
| 344 | p.start("forwarded global"); | |
| 345 | new p.Root().start("independent child"); | |
| 346 | process.send("ready"); | |
| 347 | await new Promise(resolve => process.once("message", resolve)); | |
| 348 | process.disconnect(); | |
| 349 | `, | |
| 350 | ); | |
| 351 | const child = f.run(` | |
| 352 | import { spawn } from "node:child_process"; | |
| 353 | import { Readable } from "node:stream"; | |
| 354 | import * as p from ${JSON.stringify(url)}; | |
| 355 | const parent = new p.Root().start("parent owns progress"); | |
| 356 | const child = spawn(process.execPath, [${JSON.stringify(nested)}], { | |
| 357 | env: { ...process.env, ...p.ipcChildEnvironment(3) }, | |
| 358 | stdio: ["ignore", "inherit", "inherit", "pipe", "ipc"], | |
| 359 | }); | |
| 360 | const decoding = p.decodeAttachToRef(Readable.toWeb(child.stdio[3]), parent); | |
| 361 | child.on("message", () => process.send("ready")); | |
| 362 | await new Promise(resolve => process.once("message", resolve)); | |
| 363 | child.send("finish"); | |
| 364 | await new Promise(resolve => child.once("close", resolve)); | |
| 365 | await decoding; | |
| 366 | process.disconnect(); | |
| 367 | `); | |
| 368 | await child.ready; | |
| 369 | await f.waitFor( | |
| 370 | (rows) => hasNode(rows, "forwarded global") && hasNode(rows, "independent child"), | |
| 371 | ); | |
| 372 | const forwarded = f.packets.filter((row) => hasNode([row], "forwarded global")); | |
| 373 | expect(forwarded).toHaveLength(1); | |
| 374 | const parentStream = f.packets.find((row) => hasNode([row], "parent owns progress"))?.streamId; | |
| 375 | expect(forwarded[0]?.streamId).toBe(parentStream); | |
| 376 | expect(f.packets.filter((row) => row.type === "hello")).toHaveLength(2); | |
| 377 | child.send(); | |
| 378 | expect((await child.completion).code).toBe(0); | |
| 379 | }); | |
| 380 | ||
| 381 | it("resolves package imports from the script location independently of the current directory", async () => { | |
| 382 | const f = await fixture(); | |
| 383 | const packageDirectory = NodePath.join(f.directory, "node_modules", "@clo"); | |
| 384 | await NodeFSP.mkdir(packageDirectory, { recursive: true }); | |
| 385 | await NodeFSP.symlink( | |
| 386 | NodePath.dirname(copies[0]!), | |
| 387 | NodePath.join(packageDirectory, "lib"), | |
| 388 | "dir", | |
| 389 | ); | |
| 390 | const child = f.run( | |
| 391 | `import { start } from "@clo/lib/progress"; start("package import"); process.send("ready"); await new Promise(resolve => process.once("message", resolve)); process.disconnect();`, | |
| 392 | ); | |
| 393 | await child.ready; | |
| 394 | await f.waitFor((rows) => hasNode(rows, "package import")); | |
| 395 | child.send(); | |
| 396 | expect((await child.completion).code).toBe(0); | |
| 397 | }); | |
| 398 | ||
| 399 | describe.each(copies)("completed root using %s", (copy) => { | |
| 400 | it("sends exactly one terminal event and closes the observed root", async () => { | |
| 401 | const f = await fixture(); | |
| 402 | const child = f.run(` | |
| 403 | const { Root } = await import(${JSON.stringify(NodeURL.pathToFileURL(copy).href)}); | |
| 404 | const root = new Root(); | |
| 405 | root.start("finite custom task"); | |
| 406 | process.send("ready"); | |
| 407 | await new Promise(resolve => process.once("message", resolve)); | |
| 408 | root.end("finished"); | |
| 409 | process.send("ended"); | |
| 410 | await new Promise(resolve => process.once("message", resolve)); | |
| 411 | process.disconnect(); | |
| 412 | `); | |
| 413 | await child.ready; | |
| 414 | await f.waitFor((rows) => hasNode(rows, "finite custom task")); | |
| 415 | child.send(); | |
| 416 | await f.waitFor((rows) => | |
| 417 | rows.some((row) => row.event?.some((part) => Array.isArray(part) && part[0] === "end")), | |
| 418 | ); | |
| 419 | const terminals = f.packets.flatMap( | |
| 420 | (row) => row.event?.filter((part) => Array.isArray(part) && part[0] === "end") ?? [], | |
| 421 | ); | |
| 422 | expect(terminals).toEqual([["end", "finished"]]); | |
| 423 | child.send(); | |
| 424 | expect((await child.completion).code).toBe(0); | |
| 425 | }); | |
| 426 | }); | |
| 427 | ||
| 428 | describe.each(copies)("error semantics using %s", (copy) => { | |
| 429 | it("preserves the original unhandled root error and nonzero exit status", async () => { | |
| 430 | const f = await fixture(); | |
| 431 | const source = ` | |
| 432 | const { Root } = await import(${JSON.stringify(NodeURL.pathToFileURL(copy).href)}); | |
| 433 | const root = new Root(); | |
| 434 | root.start("task"); | |
| 435 | root.error(new Error("deliberate failure")); | |
| 436 | console.log("SURVIVED"); | |
| 437 | `; | |
| 438 | const child = f.run(source); | |
| 439 | const baseline = f.run(source, { preload: false }); | |
| 440 | const original = await baseline.completion; | |
| 441 | const observed = await child.completion; | |
| 442 | expect(original.code).toBe(1); | |
| 443 | expect(observed.code).toBe(original.code); | |
| 444 | expect(observed.stdout).toBe(original.stdout); | |
| 445 | expect(observed.stdout).not.toContain("SURVIVED"); | |
| 446 | expect(observed.stderr).toContain("deliberate failure"); | |
| 447 | }); | |
| 448 | ||
| 449 | it("preserves existing handled root errors", async () => { | |
| 450 | const f = await fixture(); | |
| 451 | const source = ` | |
| 452 | const { Root } = await import(${JSON.stringify(NodeURL.pathToFileURL(copy).href)}); | |
| 453 | const root = new Root(); | |
| 454 | root.on("error", () => console.log("handled")); | |
| 455 | root.start("task"); | |
| 456 | root.error(new Error("expected failure")); | |
| 457 | console.log("continued"); | |
| 458 | process.disconnect(); | |
| 459 | `; | |
| 460 | const child = f.run(source); | |
| 461 | const baseline = f.run(source, { preload: false }); | |
| 462 | expect(await child.completion).toEqual(await baseline.completion); | |
| 463 | expect((await child.completion).stdout).toBe("handled\ncontinued\n"); | |
| 464 | }); | |
| 465 | }); | |
| 466 | ||
| 467 | it("captures a new root after reconnecting and then losing an idle collector connection", async () => { | |
| 468 | const f = await fixture(); | |
| 469 | const child = f.run(` | |
| 470 | import { Root } from ${JSON.stringify(NodeURL.pathToFileURL(copies[0]!).href)}; | |
| 471 | const first = new Root(); | |
| 472 | first.start("first active root"); | |
| 473 | process.send("ready"); | |
| 474 | await new Promise(resolve => process.once("message", resolve)); | |
| 475 | first.end(); | |
| 476 | await new Promise(resolve => process.once("message", resolve)); | |
| 477 | new Root().start("later root"); | |
| 478 | process.send("ready"); | |
| 479 | await new Promise(resolve => process.once("message", resolve)); | |
| 480 | process.disconnect(); | |
| 481 | `); | |
| 482 | await child.ready; | |
| 483 | await f.waitFor((rows) => hasNode(rows, "first active root")); | |
| 484 | for (const socket of f.sockets) socket.destroy(); | |
| 485 | await f.waitFor((rows) => rows.filter((row) => row.type === "hello").length === 2); | |
| 486 | child.send(); | |
| 487 | await f.waitFor((rows) => | |
| 488 | rows.some((row) => row.event?.some((part) => Array.isArray(part) && part[0] === "end")), | |
| 489 | ); | |
| 490 | await Promise.all( | |
| 491 | [...f.sockets].map(async (socket) => { | |
| 492 | const closed = NodeEvents.EventEmitter.once(socket, "close"); | |
| 493 | socket.destroy(); | |
| 494 | await closed; | |
| 495 | }), | |
| 496 | ); | |
| 497 | child.send(); | |
| 498 | await f.waitFor( | |
| 499 | (rows) => | |
| 500 | rows.filter((row) => row.type === "hello").length === 3 && hasNode(rows, "later root"), | |
| 501 | ); | |
| 502 | child.send(); | |
| 503 | expect((await child.completion).code).toBe(0); | |
| 504 | }); | |
| 505 | ||
| 506 | it("installs hooks before an existing require preload imports progress", async () => { | |
| 507 | const f = await fixture(); | |
| 508 | const userPreload = NodePath.join(f.directory, "existing user preload.cjs"); | |
| 509 | await NodeFSP.writeFile( | |
| 510 | userPreload, | |
| 511 | `const progress = require(${JSON.stringify(copies[0]!)}); progress.start("user preload task");`, | |
| 512 | ); | |
| 513 | f.environment.NODE_OPTIONS += ` --require=${JSON.stringify(userPreload)}`; | |
| 514 | const child = f.run(` | |
| 515 | import { start } from ${JSON.stringify(NodeURL.pathToFileURL(copies[0]!).href)}; | |
| 516 | start("main task after preload"); | |
| 517 | process.send("ready"); | |
| 518 | await new Promise(resolve => process.once("message", resolve)); | |
| 519 | process.disconnect(); | |
| 520 | `); | |
| 521 | await child.ready; | |
| 522 | await f.waitFor( | |
| 523 | (rows) => hasNode(rows, "user preload task") && hasNode(rows, "main task after preload"), | |
| 524 | ); | |
| 525 | child.send(); | |
| 526 | expect((await child.completion).code).toBe(0); | |
| 527 | }); | |
| 528 | ||
| 529 | it("distinguishes worker-thread producers sharing one process ID", async () => { | |
| 530 | const f = await fixture(); | |
| 531 | const url = NodeURL.pathToFileURL(copies[0]!).href; | |
| 532 | const workerSource = ` | |
| 533 | const { parentPort } = require("node:worker_threads"); | |
| 534 | import(${JSON.stringify(url)}).then(({ Root }) => { | |
| 535 | new Root().start("worker task"); | |
| 536 | parentPort.postMessage("ready"); | |
| 537 | parentPort.once("message", () => parentPort.close()); | |
| 538 | }); | |
| 539 | `; | |
| 540 | const child = f.run(` | |
| 541 | import { Worker } from "node:worker_threads"; | |
| 542 | import { Root } from ${JSON.stringify(url)}; | |
| 543 | new Root().start("main thread task"); | |
| 544 | const worker = new Worker(${JSON.stringify(workerSource)}, { eval: true }); | |
| 545 | worker.once("message", () => process.send("ready")); | |
| 546 | await new Promise(resolve => process.once("message", resolve)); | |
| 547 | worker.postMessage("finish"); | |
| 548 | await new Promise(resolve => worker.once("exit", resolve)); | |
| 549 | process.disconnect(); | |
| 550 | `); | |
| 551 | await child.ready; | |
| 552 | await f.waitFor((rows) => hasNode(rows, "main thread task") && hasNode(rows, "worker task")); | |
| 553 | const producers = f.packets.filter((packet) => packet.type === "hello"); | |
| 554 | expect(producers).toHaveLength(2); | |
| 555 | expect(new Set(producers.map((packet) => packet.pid)).size).toBe(1); | |
| 556 | expect(new Set(producers.map((packet) => packet.producerId)).size).toBe(2); | |
| 557 | child.send(); | |
| 558 | expect((await child.completion).code).toBe(0); | |
| 559 | }); |
packages/shared/src/progressPreload.ts created+169| ... | ... | @@ -0,0 +1,169 @@ |
| 1 | /** Standalone Node preload written outside packaged application archives. */ | |
| 2 | export const progressPreloadSource = String.raw` | |
| 3 | const modules = require("node:module"); | |
| 4 | const { connect } = require("node:net"); | |
| 5 | const { fstatSync } = require("node:fs"); | |
| 6 | const { basename } = require("node:path"); | |
| 7 | const { randomUUID } = require("node:crypto"); | |
| 8 | ||
| 9 | const endpoint = /^127\.0\.0\.1:(\d+)$/.exec(process.env.T3_SCRIPT_PROGRESS_ENDPOINT ?? ""); | |
| 10 | const token = process.env.T3_SCRIPT_PROGRESS_TOKEN; | |
| 11 | const key = Symbol.for("t3.script-progress.observe"); | |
| 12 | if (endpoint && token && typeof modules.registerHooks === "function" && !globalThis[key]) { | |
| 13 | const port = Number(endpoint[1]); | |
| 14 | if (port > 0 && port <= 65535) { | |
| 15 | try { install(port, token); } catch {} | |
| 16 | } | |
| 17 | } | |
| 18 | ||
| 19 | function install(port, token) { | |
| 20 | const producerId = randomUUID(); | |
| 21 | const roots = new Map(); | |
| 22 | const observed = new WeakSet(); | |
| 23 | let nextId = 0; | |
| 24 | let socket; | |
| 25 | let connected = false; | |
| 26 | let retry; | |
| 27 | let queue = []; | |
| 28 | let queuedBytes = 0; | |
| 29 | let exiting = false; | |
| 30 | let flushed = false; | |
| 31 | const limit = 1024 * 1024; | |
| 32 | ||
| 33 | function send(record) { | |
| 34 | try { | |
| 35 | const line = JSON.stringify(record) + "\n"; | |
| 36 | if (Buffer.byteLength(line) > limit) return; | |
| 37 | if (connected && socket) { | |
| 38 | if (socket.writableLength + Buffer.byteLength(line) <= limit) socket.write(line); | |
| 39 | else socket.destroy(); | |
| 40 | } else if (queuedBytes + Buffer.byteLength(line) <= limit) { | |
| 41 | queue.push(line); | |
| 42 | queuedBytes += Buffer.byteLength(line); | |
| 43 | } else { | |
| 44 | socket?.destroy(); | |
| 45 | } | |
| 46 | } catch {} | |
| 47 | } | |
| 48 | ||
| 49 | async function read(root, state) { | |
| 50 | try { | |
| 51 | // The server coalesces updates; capture must not add a script-side timer. | |
| 52 | // Observing an error must not consume the emitter's unhandled-error throw. | |
| 53 | const source = { | |
| 54 | get active() { return root.active; }, | |
| 55 | delay: root.delay, | |
| 56 | now: root.now, | |
| 57 | on(channel, callback) { | |
| 58 | return channel === "error" | |
| 59 | ? root.onAny((event, args) => { if (event === "error") callback(...args); }) | |
| 60 | : root.on(channel, callback); | |
| 61 | }, | |
| 62 | onAny: root.onAny.bind(root), | |
| 63 | }; | |
| 64 | const reader = state.encode(source, { throttleMs: -Infinity }).getReader(); | |
| 65 | state.reader = reader; | |
| 66 | while (state.reader === reader) { | |
| 67 | const { done, value } = await reader.read(); | |
| 68 | if (state.reader !== reader) break; | |
| 69 | if (done) { | |
| 70 | roots.delete(root); | |
| 71 | break; | |
| 72 | } | |
| 73 | send({ type: "progress", streamId: state.id, event: value }); | |
| 74 | } | |
| 75 | } catch {} | |
| 76 | } | |
| 77 | ||
| 78 | function open() { | |
| 79 | if (exiting) return; | |
| 80 | retry = undefined; | |
| 81 | const connection = connect({ host: "127.0.0.1", port }); | |
| 82 | socket = connection; | |
| 83 | connection.unref(); | |
| 84 | connection.on("error", () => {}); | |
| 85 | connection.on("connect", () => { | |
| 86 | connected = true; | |
| 87 | connection.write(JSON.stringify({ | |
| 88 | type: "hello", token, producerId, pid: process.pid, | |
| 89 | label: process.argv[1] ? basename(process.argv[1]) : undefined, | |
| 90 | }) + "\n"); | |
| 91 | for (const line of queue) connection.write(line); | |
| 92 | queue = []; | |
| 93 | queuedBytes = 0; | |
| 94 | for (const [root, state] of roots) if (!state.reader) void read(root, state); | |
| 95 | }); | |
| 96 | connection.on("close", () => { | |
| 97 | if (socket !== connection) return; | |
| 98 | socket = undefined; | |
| 99 | connected = false; | |
| 100 | queue = []; | |
| 101 | queuedBytes = 0; | |
| 102 | for (const state of roots.values()) { | |
| 103 | const reader = state.reader; | |
| 104 | state.reader = undefined; | |
| 105 | void reader?.cancel().catch(() => {}); | |
| 106 | } | |
| 107 | if (!exiting && roots.size) { | |
| 108 | retry = setTimeout(open, 500); | |
| 109 | retry.unref(); | |
| 110 | } | |
| 111 | }); | |
| 112 | } | |
| 113 | ||
| 114 | Object.defineProperty(globalThis, key, { value(Root, encode, forwardedRoot) { | |
| 115 | if (typeof Root?.prototype?.start !== "function" || typeof Root.prototype.onAny !== "function" || typeof encode !== "function") return; | |
| 116 | let forwarded = false; | |
| 117 | const [fd, parent] = (process.env.CLOVER_PROGRESS ?? "").split(":").map(Number); | |
| 118 | if (forwardedRoot && parent === process.ppid && fd > 2) { | |
| 119 | try { forwarded = fstatSync(fd).isFIFO() || fstatSync(fd).isSocket(); } catch {} | |
| 120 | } | |
| 121 | const start = Root.prototype.start; | |
| 122 | Root.prototype.start = function (...args) { | |
| 123 | if (!(forwarded && this === forwardedRoot) && !observed.has(this)) { | |
| 124 | observed.add(this); | |
| 125 | try { | |
| 126 | const state = { id: String(++nextId), encode }; | |
| 127 | roots.set(this, state); | |
| 128 | void read(this, state); | |
| 129 | const off = this.onAny((channel, args) => { | |
| 130 | if (channel !== "end" && channel !== "error") return; | |
| 131 | const reader = state.reader; | |
| 132 | state.reader = undefined; | |
| 133 | roots.delete(this); | |
| 134 | try { | |
| 135 | send({ type: "progress", streamId: state.id, event: [0, | |
| 136 | channel === "error" ? ["error", String(args[0])] : ["end", args[0] ?? null], | |
| 137 | ] }); | |
| 138 | } catch {} | |
| 139 | queueMicrotask(() => { off(); void reader?.cancel().catch(() => {}); }); | |
| 140 | }); | |
| 141 | if (!socket && !retry) open(); | |
| 142 | } catch {} | |
| 143 | } | |
| 144 | return start.apply(this, args); | |
| 145 | }; | |
| 146 | }}); | |
| 147 | ||
| 148 | modules.registerHooks({ load(url, context, nextLoad) { | |
| 149 | const loaded = nextLoad(url, context); | |
| 150 | if (!/\/progress\.(?:[cm]?js|ts)(?:\?|$)/.test(url) || !loaded.source) return loaded; | |
| 151 | const source = typeof loaded.source === "string" ? loaded.source : Buffer.from(loaded.source).toString("utf8"); | |
| 152 | if (!/export\s+class\s+Root\b/.test(source) || !/export\s+function\s+encodeEventStream\b/.test(source)) return loaded; | |
| 153 | return { ...loaded, source: source + "\n;try { globalThis[Symbol.for('t3.script-progress.observe')]?.(Root, encodeEventStream, typeof tryAttachToParent === 'function' ? global : null); } catch {}\n" }; | |
| 154 | }}); | |
| 155 | ||
| 156 | process.on("beforeExit", () => { | |
| 157 | if (flushed || !socket) return; | |
| 158 | flushed = true; | |
| 159 | exiting = true; | |
| 160 | clearTimeout(retry); | |
| 161 | const connection = socket; | |
| 162 | connection.ref(); | |
| 163 | const deadline = setTimeout(() => connection.destroy(), 100); | |
| 164 | connection.once("close", () => clearTimeout(deadline)); | |
| 165 | if (connected) connection.end(); | |
| 166 | else connection.once("connect", () => connection.end()); | |
| 167 | }); | |
| 168 | } | |
| 169 | `; |
pnpm-lock.yaml+15| ... | ... | @@ -609,6 +609,9 @@ importers: |
| 609 | 609 | '@anthropic-ai/claude-agent-sdk': |
| 610 | 610 | specifier: ^0.3.276 |
| 611 | 611 | version: 0.3.276(@anthropic-ai/sdk@0.93.0(zod@4.6.5))(@modelcontextprotocol/sdk@1.29.0(zod@4.6.5))(zod@4.6.5) |
| 612 | '@clo/lib': | |
| 613 | specifier: workspace:* | |
| 614 | version: link:../../packages/clo-lib | |
| 612 | 615 | '@cursor/sdk': |
| 613 | 616 | specifier: 1.0.35 |
| 614 | 617 | version: 1.0.35 |
| ... | ... | @@ -760,6 +763,9 @@ importers: |
| 760 | 763 | '@clerk/react': |
| 761 | 764 | specifier: 6.16.1 |
| 762 | 765 | version: 6.16.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6) |
| 766 | '@clo/lib': | |
| 767 | specifier: workspace:* | |
| 768 | version: link:../../packages/clo-lib | |
| 763 | 769 | '@clo/markdown-editor': |
| 764 | 770 | specifier: workspace:* |
| 765 | 771 | version: link:../../packages/markdown-editor |
| ... | ... | @@ -1116,6 +1122,15 @@ importers: |
| 1116 | 1122 | specifier: 1.0.0 |
| 1117 | 1123 | version: 1.0.0(@types/node@24.12.4)(@vitest/ui@5.0.1(vitest@5.0.1))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2))(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) |
| 1118 | 1124 | |
| 1125 | packages/clo-lib: | |
| 1126 | devDependencies: | |
| 1127 | '@types/node': | |
| 1128 | specifier: 24.12.4 | |
| 1129 | version: 24.12.4 | |
| 1130 | vite-plus: | |
| 1131 | specifier: 1.0.0 | |
| 1132 | version: 1.0.0(@types/node@24.12.4)(@vitest/ui@5.0.1)(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2))(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) | |
| 1133 | ||
| 1119 | 1134 | packages/contracts: |
| 1120 | 1135 | dependencies: |
| 1121 | 1136 | effect: |