@jmoyers/harness 0.1.11 → 0.1.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (232) hide show
  1. package/README.md +31 -39
  2. package/package.json +31 -11
  3. package/packages/harness-ai/src/anthropic-protocol.ts +68 -68
  4. package/packages/harness-ai/src/stream-text.ts +13 -91
  5. package/packages/harness-ui/src/frame-primitives.ts +158 -0
  6. package/packages/harness-ui/src/index.ts +18 -0
  7. package/packages/harness-ui/src/interaction/conversation-input-forwarder.ts +221 -0
  8. package/packages/harness-ui/src/interaction/conversation-selection-input.ts +213 -0
  9. package/packages/harness-ui/src/interaction/global-shortcut-input.ts +172 -0
  10. package/{src/ui → packages/harness-ui/src/interaction}/input-preflight.ts +10 -12
  11. package/{src/ui → packages/harness-ui/src/interaction}/input-token-router.ts +120 -69
  12. package/packages/harness-ui/src/interaction/input.ts +420 -0
  13. package/packages/harness-ui/src/interaction/left-nav-input.ts +166 -0
  14. package/{src/ui → packages/harness-ui/src/interaction}/main-pane-pointer-input.ts +91 -23
  15. package/{src/ui → packages/harness-ui/src/interaction}/pointer-routing-input.ts +112 -48
  16. package/packages/harness-ui/src/interaction/rail-pointer-input.ts +62 -0
  17. package/packages/harness-ui/src/interaction/repository-fold-input.ts +118 -0
  18. package/packages/harness-ui/src/kit.ts +476 -0
  19. package/packages/harness-ui/src/layout.ts +238 -0
  20. package/packages/harness-ui/src/modal-manager.ts +222 -0
  21. package/{src/ui → packages/harness-ui/src}/screen.ts +53 -26
  22. package/packages/harness-ui/src/surface.ts +252 -0
  23. package/packages/harness-ui/src/text-layout.ts +210 -0
  24. package/packages/nim-core/src/contracts.ts +239 -0
  25. package/packages/nim-core/src/event-store.ts +299 -0
  26. package/packages/nim-core/src/events.ts +53 -0
  27. package/packages/nim-core/src/index.ts +9 -0
  28. package/packages/nim-core/src/provider-router.ts +129 -0
  29. package/packages/nim-core/src/providers/anthropic-driver.ts +291 -0
  30. package/packages/nim-core/src/runtime-factory.ts +49 -0
  31. package/packages/nim-core/src/runtime.ts +1797 -0
  32. package/packages/nim-core/src/session-store.ts +516 -0
  33. package/packages/nim-core/src/telemetry.ts +48 -0
  34. package/packages/nim-test-tui/src/index.ts +150 -0
  35. package/packages/nim-ui-core/src/index.ts +1 -0
  36. package/packages/nim-ui-core/src/projection.ts +87 -0
  37. package/scripts/codex-live-mux-runtime.ts +2 -3872
  38. package/scripts/control-plane-daemon.ts +11 -0
  39. package/scripts/harness-bin.js +5 -0
  40. package/scripts/harness-commands.ts +300 -0
  41. package/scripts/harness-runtime.ts +82 -0
  42. package/scripts/harness.ts +33 -3019
  43. package/scripts/nim-tui-smoke.ts +748 -0
  44. package/src/cli/auth/runtime.ts +948 -0
  45. package/src/cli/gateway/runtime.ts +1872 -0
  46. package/src/cli/parsing/flags.ts +23 -0
  47. package/src/cli/parsing/session.ts +42 -0
  48. package/src/cli/runtime/context.ts +193 -0
  49. package/src/cli/runtime-app/application.ts +392 -0
  50. package/src/cli/runtime-infra/gateway-control.ts +729 -0
  51. package/{scripts/harness-inspector.ts → src/cli/workflows/inspector.ts} +14 -11
  52. package/src/cli/workflows/runtime.ts +965 -0
  53. package/src/clients/tui/left-rail-interactions.ts +519 -0
  54. package/src/clients/tui/main-pane-interactions.ts +509 -0
  55. package/src/clients/tui/modal-input-routing.ts +71 -0
  56. package/src/clients/tui/render-snapshot-adapter.ts +88 -0
  57. package/src/clients/web/synced-selectors.ts +132 -0
  58. package/src/codex/live-session.ts +82 -29
  59. package/src/config/config-core.ts +348 -8
  60. package/src/config/harness.config.template.jsonc +33 -0
  61. package/src/control-plane/agent-realtime-api.ts +82 -427
  62. package/src/control-plane/session-summary.ts +10 -81
  63. package/src/control-plane/status/reducer-base.ts +12 -12
  64. package/src/control-plane/status/reducers/claude-status-reducer.ts +3 -3
  65. package/src/control-plane/status/reducers/codex-status-reducer.ts +4 -4
  66. package/src/control-plane/status/reducers/cursor-status-reducer.ts +3 -3
  67. package/src/control-plane/stream-client.ts +12 -2
  68. package/src/control-plane/stream-command-parser.ts +83 -143
  69. package/src/control-plane/stream-protocol.ts +53 -37
  70. package/src/control-plane/stream-server-command.ts +376 -69
  71. package/src/control-plane/stream-server-session-runtime.ts +3 -2
  72. package/src/control-plane/stream-server.ts +864 -70
  73. package/src/control-plane/stream-session-runtime-types.ts +41 -0
  74. package/src/{mux/live-mux/control-plane-records.ts → core/contracts/records.ts} +24 -97
  75. package/src/core/state/observed-stream-cursor.ts +43 -0
  76. package/src/core/state/synced-observed-state.ts +273 -0
  77. package/src/core/store/harness-synced-store.ts +81 -0
  78. package/src/diff/budget.ts +136 -0
  79. package/src/diff/build.ts +289 -0
  80. package/src/diff/chunker.ts +146 -0
  81. package/src/diff/git-invoke.ts +315 -0
  82. package/src/diff/git-parse.ts +472 -0
  83. package/src/diff/hash.ts +70 -0
  84. package/src/diff/index.ts +24 -0
  85. package/src/diff/normalize.ts +134 -0
  86. package/src/diff/types.ts +178 -0
  87. package/src/diff-ui/args.ts +346 -0
  88. package/src/diff-ui/commands.ts +123 -0
  89. package/src/diff-ui/finder.ts +94 -0
  90. package/src/diff-ui/highlight.ts +127 -0
  91. package/src/diff-ui/index.ts +2 -0
  92. package/src/diff-ui/model.ts +141 -0
  93. package/src/diff-ui/pager.ts +412 -0
  94. package/src/diff-ui/render.ts +337 -0
  95. package/src/diff-ui/runtime.ts +379 -0
  96. package/src/diff-ui/state.ts +224 -0
  97. package/src/diff-ui/types.ts +236 -0
  98. package/src/domain/workspace.ts +68 -5
  99. package/src/mux/control-plane-op-queue.ts +93 -7
  100. package/src/mux/conversation-rail.ts +28 -71
  101. package/src/mux/dual-pane-core.ts +13 -13
  102. package/src/mux/harness-core-ui.ts +313 -42
  103. package/src/mux/input-shortcuts.ts +13 -131
  104. package/src/mux/keybinding-catalog.ts +340 -0
  105. package/src/mux/keybinding-registry.ts +103 -0
  106. package/src/mux/live-mux/command-menu-open-in.ts +280 -0
  107. package/src/mux/live-mux/command-menu.ts +167 -4
  108. package/src/mux/live-mux/conversation-state.ts +13 -0
  109. package/src/mux/live-mux/directory-resolution.ts +1 -1
  110. package/src/mux/live-mux/git-snapshot.ts +33 -2
  111. package/src/mux/live-mux/global-shortcut-handlers.ts +6 -0
  112. package/src/mux/live-mux/home-pane-drop.ts +1 -1
  113. package/src/mux/live-mux/home-pane-pointer.ts +10 -0
  114. package/src/mux/live-mux/input-forwarding.ts +59 -2
  115. package/src/mux/live-mux/left-nav-activation.ts +124 -7
  116. package/src/mux/live-mux/left-nav.ts +35 -0
  117. package/src/mux/live-mux/link-click.ts +292 -0
  118. package/src/mux/live-mux/modal-command-menu-handler.ts +46 -9
  119. package/src/mux/live-mux/modal-conversation-handlers.ts +5 -1
  120. package/src/mux/live-mux/modal-input-reducers.ts +77 -12
  121. package/src/mux/live-mux/modal-overlays.ts +168 -34
  122. package/src/mux/live-mux/modal-pointer.ts +3 -7
  123. package/src/mux/live-mux/modal-prompt-handlers.ts +23 -2
  124. package/src/mux/live-mux/modal-release-notes-handler.ts +111 -0
  125. package/src/mux/live-mux/modal-task-editor-handler.ts +16 -11
  126. package/src/mux/live-mux/pointer-routing.ts +5 -2
  127. package/src/mux/live-mux/project-pane-pointer.ts +8 -0
  128. package/src/mux/live-mux/rail-layout.ts +33 -30
  129. package/src/mux/live-mux/release-notes.ts +383 -0
  130. package/src/mux/live-mux/render-trace-analysis.ts +52 -7
  131. package/src/mux/live-mux/repository-folding.ts +3 -0
  132. package/src/mux/live-mux/selection.ts +0 -4
  133. package/src/mux/live-mux/session-diagnostics-paths.ts +21 -0
  134. package/src/mux/project-pane-github-review.ts +271 -0
  135. package/src/mux/render-frame.ts +4 -0
  136. package/src/mux/runtime-app/codex-live-mux-runtime.ts +5191 -0
  137. package/src/mux/task-composer.ts +21 -14
  138. package/src/mux/task-focused-pane.ts +118 -117
  139. package/src/mux/task-screen-keybindings.ts +10 -101
  140. package/src/mux/workspace-rail-model.ts +270 -104
  141. package/src/mux/workspace-rail.ts +45 -22
  142. package/src/pty/session-broker.ts +1 -1
  143. package/{scripts → src/recording}/terminal-recording-gif-lib.ts +2 -2
  144. package/src/services/control-plane.ts +50 -32
  145. package/src/services/conversation-lifecycle.ts +118 -87
  146. package/src/services/conversation-startup-hydration.ts +20 -12
  147. package/src/services/directory-hydration.ts +21 -16
  148. package/src/services/event-persistence.ts +7 -0
  149. package/src/services/left-rail-pointer-handler.ts +329 -0
  150. package/src/services/mux-ui-state-persistence.ts +5 -1
  151. package/src/services/recording.ts +34 -26
  152. package/src/services/runtime-command-menu-agent-tools.ts +1 -1
  153. package/src/services/runtime-control-actions.ts +79 -61
  154. package/src/services/runtime-control-plane-ops.ts +122 -83
  155. package/src/services/runtime-conversation-actions.ts +40 -26
  156. package/src/services/runtime-conversation-activation.ts +73 -46
  157. package/src/services/runtime-conversation-starter.ts +53 -45
  158. package/src/services/runtime-conversation-title-edit.ts +91 -80
  159. package/src/services/runtime-envelope-handler.ts +107 -105
  160. package/src/services/runtime-git-state.ts +42 -29
  161. package/src/services/runtime-layout-resize.ts +3 -1
  162. package/src/services/runtime-left-rail-render.ts +99 -63
  163. package/src/services/runtime-nim-cli-session.ts +438 -0
  164. package/src/services/runtime-nim-session.ts +705 -0
  165. package/src/services/runtime-nim-tool-bridge.ts +141 -0
  166. package/src/services/runtime-observed-event-projection-pipeline.ts +45 -0
  167. package/src/services/runtime-process-wiring.ts +29 -36
  168. package/src/services/runtime-project-pane-github-review-cache.ts +164 -0
  169. package/src/services/runtime-render-flush.ts +63 -70
  170. package/src/services/runtime-render-lifecycle.ts +65 -64
  171. package/src/services/runtime-render-orchestrator.ts +55 -45
  172. package/src/services/runtime-render-pipeline.ts +106 -103
  173. package/src/services/runtime-render-state.ts +62 -49
  174. package/src/services/runtime-repository-actions.ts +97 -72
  175. package/src/services/runtime-right-pane-render.ts +80 -53
  176. package/src/services/runtime-shutdown.ts +38 -35
  177. package/src/services/runtime-stream-subscriptions.ts +35 -27
  178. package/src/services/runtime-task-composer-persistence.ts +71 -59
  179. package/src/services/runtime-task-composer-snapshot.ts +14 -0
  180. package/src/services/runtime-task-editor-actions.ts +46 -29
  181. package/src/services/runtime-task-pane-actions.ts +220 -134
  182. package/src/services/runtime-task-pane-shortcuts.ts +323 -123
  183. package/src/services/runtime-workspace-observed-effect-queue.ts +25 -0
  184. package/src/services/runtime-workspace-observed-events.ts +33 -184
  185. package/src/services/runtime-workspace-observed-transition-policy.ts +228 -0
  186. package/src/services/session-diagnostics-store.ts +217 -0
  187. package/src/services/startup-background-resume.ts +26 -21
  188. package/src/services/startup-orchestrator.ts +16 -13
  189. package/src/services/startup-paint-tracker.ts +29 -21
  190. package/src/services/startup-persisted-conversation-queue.ts +19 -13
  191. package/src/services/startup-settled-gate.ts +25 -15
  192. package/src/services/startup-shutdown.ts +18 -22
  193. package/src/services/startup-state-hydration.ts +44 -34
  194. package/src/services/startup-visibility.ts +12 -4
  195. package/src/services/task-pane-selection-actions.ts +89 -72
  196. package/src/services/task-planning-hydration.ts +24 -18
  197. package/src/services/task-planning-observed-events.ts +50 -52
  198. package/src/services/workspace-observed-events.ts +66 -63
  199. package/src/storage/storage-lifecycle-core.ts +438 -0
  200. package/src/store/control-plane-store-normalize.ts +33 -242
  201. package/src/store/control-plane-store-types.ts +1 -35
  202. package/src/store/control-plane-store.ts +360 -56
  203. package/src/store/event-store.ts +366 -8
  204. package/src/terminal/snapshot-oracle.ts +207 -94
  205. package/src/ui/mux-theme.ts +112 -8
  206. package/src/ui/panes/home-gridfire.ts +40 -31
  207. package/src/ui/panes/home.ts +10 -2
  208. package/src/ui/panes/nim.ts +315 -0
  209. package/src/mux/live-mux/actions-task.ts +0 -115
  210. package/src/mux/live-mux/left-rail-actions.ts +0 -118
  211. package/src/mux/live-mux/left-rail-conversation-click.ts +0 -85
  212. package/src/mux/live-mux/left-rail-pointer.ts +0 -74
  213. package/src/mux/live-mux/task-pane-shortcuts.ts +0 -206
  214. package/src/services/runtime-directory-actions.ts +0 -164
  215. package/src/services/runtime-input-pipeline.ts +0 -50
  216. package/src/services/runtime-input-router.ts +0 -195
  217. package/src/services/runtime-main-pane-input.ts +0 -230
  218. package/src/services/runtime-modal-input.ts +0 -137
  219. package/src/services/runtime-navigation-input.ts +0 -197
  220. package/src/services/runtime-rail-input.ts +0 -279
  221. package/src/services/runtime-task-pane.ts +0 -62
  222. package/src/services/runtime-workspace-actions.ts +0 -158
  223. package/src/ui/conversation-input-forwarder.ts +0 -114
  224. package/src/ui/conversation-selection-input.ts +0 -103
  225. package/src/ui/global-shortcut-input.ts +0 -89
  226. package/src/ui/input.ts +0 -269
  227. package/src/ui/kit.ts +0 -509
  228. package/src/ui/left-nav-input.ts +0 -80
  229. package/src/ui/left-rail-pointer-input.ts +0 -148
  230. package/src/ui/modals/manager.ts +0 -218
  231. package/src/ui/repository-fold-input.ts +0 -91
  232. package/src/ui/surface.ts +0 -224
@@ -0,0 +1,516 @@
1
+ import { mkdirSync } from 'node:fs';
2
+ import { createRequire } from 'node:module';
3
+ import { dirname } from 'node:path';
4
+ import type { NimModelRef } from './contracts.ts';
5
+
6
+ export type NimPersistedSession = {
7
+ readonly sessionId: string;
8
+ readonly tenantId: string;
9
+ readonly userId: string;
10
+ readonly model: NimModelRef;
11
+ readonly lane: string;
12
+ readonly soulHash?: string;
13
+ readonly skillsSnapshotVersion?: number;
14
+ readonly eventSeq: number;
15
+ readonly lastRunId?: string;
16
+ readonly followups: readonly NimPersistedFollowUp[];
17
+ };
18
+
19
+ export type NimPersistedIdempotency = {
20
+ readonly idempotencyKey: string;
21
+ readonly runId: string;
22
+ };
23
+
24
+ export type NimPersistedFollowUp = {
25
+ readonly queueId: string;
26
+ readonly text: string;
27
+ readonly priority: 'normal' | 'high';
28
+ readonly dedupeKey: string;
29
+ };
30
+
31
+ export interface NimSessionStore {
32
+ upsertSession(session: NimPersistedSession): void;
33
+ getSession(sessionId: string): NimPersistedSession | undefined;
34
+ listSessions(tenantId: string, userId: string): readonly NimPersistedSession[];
35
+ upsertIdempotency(sessionId: string, idempotencyKey: string, runId: string): void;
36
+ getRunIdByIdempotency(sessionId: string, idempotencyKey: string): string | undefined;
37
+ listIdempotency(sessionId: string): readonly NimPersistedIdempotency[];
38
+ }
39
+
40
+ export class InMemoryNimSessionStore implements NimSessionStore {
41
+ private sessions = new Map<string, NimPersistedSession>();
42
+ private idempotencyBySession = new Map<string, Map<string, string>>();
43
+
44
+ public upsertSession(session: NimPersistedSession): void {
45
+ this.sessions.set(session.sessionId, session);
46
+ }
47
+
48
+ public getSession(sessionId: string): NimPersistedSession | undefined {
49
+ return this.sessions.get(sessionId);
50
+ }
51
+
52
+ public listSessions(tenantId: string, userId: string): readonly NimPersistedSession[] {
53
+ return Array.from(this.sessions.values()).filter(
54
+ (session) => session.tenantId === tenantId && session.userId === userId,
55
+ );
56
+ }
57
+
58
+ public upsertIdempotency(sessionId: string, idempotencyKey: string, runId: string): void {
59
+ let map = this.idempotencyBySession.get(sessionId);
60
+ if (map === undefined) {
61
+ map = new Map<string, string>();
62
+ this.idempotencyBySession.set(sessionId, map);
63
+ }
64
+ if (map.has(idempotencyKey)) {
65
+ return;
66
+ }
67
+ map.set(idempotencyKey, runId);
68
+ }
69
+
70
+ public getRunIdByIdempotency(sessionId: string, idempotencyKey: string): string | undefined {
71
+ return this.idempotencyBySession.get(sessionId)?.get(idempotencyKey);
72
+ }
73
+
74
+ public listIdempotency(sessionId: string): readonly NimPersistedIdempotency[] {
75
+ const map = this.idempotencyBySession.get(sessionId);
76
+ if (map === undefined) {
77
+ return [];
78
+ }
79
+ return Array.from(map.entries()).map(([idempotencyKey, runId]) => ({
80
+ idempotencyKey,
81
+ runId,
82
+ }));
83
+ }
84
+ }
85
+
86
+ interface StatementLike {
87
+ run: (...params: unknown[]) => unknown;
88
+ get: (...params: unknown[]) => unknown;
89
+ all: (...params: unknown[]) => unknown[];
90
+ }
91
+
92
+ class WrappedStatement {
93
+ private readonly statement: StatementLike;
94
+
95
+ public constructor(statement: StatementLike) {
96
+ this.statement = statement;
97
+ }
98
+
99
+ public run(...params: unknown[]): unknown {
100
+ return this.statement.run(...params);
101
+ }
102
+
103
+ public get(...params: unknown[]): unknown {
104
+ const value = this.statement.get(...params);
105
+ return value === null ? undefined : value;
106
+ }
107
+
108
+ public all(...params: unknown[]): unknown[] {
109
+ return this.statement.all(...params);
110
+ }
111
+ }
112
+
113
+ interface SqliteDatabaseLike {
114
+ close: () => void;
115
+ exec: (sql: string) => unknown;
116
+ prepare: (sql: string) => StatementLike;
117
+ }
118
+
119
+ type BunSqliteModule = {
120
+ Database: new (path: string) => SqliteDatabaseLike;
121
+ };
122
+
123
+ interface SqliteRuntime {
124
+ readonly bunVersion: string | undefined;
125
+ readonly loadModule: (specifier: 'bun:sqlite') => unknown;
126
+ }
127
+
128
+ const require = createRequire(import.meta.url);
129
+ const defaultRuntime: SqliteRuntime = {
130
+ bunVersion: process.versions.bun,
131
+ loadModule: (specifier) => require(specifier) as unknown,
132
+ };
133
+
134
+ function createDatabaseForRuntime(
135
+ path: string,
136
+ runtime: SqliteRuntime = defaultRuntime,
137
+ ): SqliteDatabaseLike {
138
+ if (runtime.bunVersion === undefined) {
139
+ throw new Error('bun runtime is required for sqlite access');
140
+ }
141
+ const module = runtime.loadModule('bun:sqlite') as BunSqliteModule;
142
+ return new module.Database(path);
143
+ }
144
+
145
+ class DatabaseSync {
146
+ private readonly database: SqliteDatabaseLike;
147
+
148
+ public constructor(path: string, runtime: SqliteRuntime = defaultRuntime) {
149
+ this.database = createDatabaseForRuntime(path, runtime);
150
+ }
151
+
152
+ public close(): void {
153
+ this.database.close();
154
+ }
155
+
156
+ public exec(sql: string): void {
157
+ this.database.exec(sql);
158
+ }
159
+
160
+ public prepare(sql: string): WrappedStatement {
161
+ return new WrappedStatement(this.database.prepare(sql));
162
+ }
163
+ }
164
+
165
+ const NIM_SESSION_STORE_SCHEMA_VERSION = 2;
166
+
167
+ function asRecord(value: unknown): Record<string, unknown> {
168
+ if (typeof value !== 'object' || value === null) {
169
+ throw new Error('expected sqlite row object');
170
+ }
171
+ return value as Record<string, unknown>;
172
+ }
173
+
174
+ function asString(value: unknown, field: string): string {
175
+ if (typeof value !== 'string') {
176
+ throw new Error(`expected string for ${field}`);
177
+ }
178
+ return value;
179
+ }
180
+
181
+ function asFollowupPriority(value: unknown, field: string): 'normal' | 'high' {
182
+ if (value === 'normal' || value === 'high') {
183
+ return value;
184
+ }
185
+ throw new Error(`expected follow-up priority for ${field}`);
186
+ }
187
+
188
+ function asStringOrUndefined(value: unknown, field: string): string | undefined {
189
+ if (value === null || value === undefined) {
190
+ return undefined;
191
+ }
192
+ return asString(value, field);
193
+ }
194
+
195
+ function asNonNegativeInteger(value: unknown, field: string): number {
196
+ if (typeof value !== 'number' || !Number.isInteger(value) || value < 0) {
197
+ throw new Error(`expected non-negative integer for ${field}`);
198
+ }
199
+ return value;
200
+ }
201
+
202
+ function asNonNegativeIntegerOrUndefined(value: unknown, field: string): number | undefined {
203
+ if (value === null || value === undefined) {
204
+ return undefined;
205
+ }
206
+ return asNonNegativeInteger(value, field);
207
+ }
208
+
209
+ function parseFollowupsJson(value: unknown): readonly NimPersistedFollowUp[] {
210
+ const json = asString(value, 'followups_json');
211
+ let parsed: unknown;
212
+ try {
213
+ parsed = JSON.parse(json);
214
+ } catch {
215
+ throw new Error('invalid followups_json');
216
+ }
217
+ if (!Array.isArray(parsed)) {
218
+ throw new Error('expected followups_json array');
219
+ }
220
+ return parsed.map((item, index) => {
221
+ const row = asRecord(item);
222
+ return {
223
+ queueId: asString(row.queueId, `followups_json[${String(index)}].queueId`),
224
+ text: asString(row.text, `followups_json[${String(index)}].text`),
225
+ priority: asFollowupPriority(row.priority, `followups_json[${String(index)}].priority`),
226
+ dedupeKey: asString(row.dedupeKey, `followups_json[${String(index)}].dedupeKey`),
227
+ };
228
+ });
229
+ }
230
+
231
+ function parsePersistedSessionRow(row: unknown): NimPersistedSession {
232
+ const value = asRecord(row);
233
+ const soulHash = asStringOrUndefined(value.soul_hash, 'soul_hash');
234
+ const skillsSnapshotVersion = asNonNegativeIntegerOrUndefined(
235
+ value.skills_snapshot_version,
236
+ 'skills_snapshot_version',
237
+ );
238
+ const lastRunId = asStringOrUndefined(value.last_run_id, 'last_run_id');
239
+ const followups = parseFollowupsJson(value.followups_json);
240
+ return {
241
+ sessionId: asString(value.session_id, 'session_id'),
242
+ tenantId: asString(value.tenant_id, 'tenant_id'),
243
+ userId: asString(value.user_id, 'user_id'),
244
+ model: asString(value.model, 'model') as NimModelRef,
245
+ lane: asString(value.lane, 'lane'),
246
+ ...(soulHash !== undefined ? { soulHash } : {}),
247
+ ...(skillsSnapshotVersion !== undefined ? { skillsSnapshotVersion } : {}),
248
+ eventSeq: asNonNegativeInteger(value.event_seq, 'event_seq'),
249
+ ...(lastRunId !== undefined ? { lastRunId } : {}),
250
+ followups,
251
+ };
252
+ }
253
+
254
+ function parseIdempotencyRow(row: unknown): NimPersistedIdempotency {
255
+ const value = asRecord(row);
256
+ return {
257
+ idempotencyKey: asString(value.idempotency_key, 'idempotency_key'),
258
+ runId: asString(value.run_id, 'run_id'),
259
+ };
260
+ }
261
+
262
+ function preparePath(filePath: string): string {
263
+ if (filePath === ':memory:') {
264
+ return filePath;
265
+ }
266
+ mkdirSync(dirname(filePath), { recursive: true });
267
+ return filePath;
268
+ }
269
+
270
+ export class NimSqliteSessionStore implements NimSessionStore {
271
+ private readonly db: DatabaseSync;
272
+
273
+ public constructor(filePath = ':memory:') {
274
+ this.db = new DatabaseSync(preparePath(filePath));
275
+ this.configureConnection();
276
+ this.initializeSchema();
277
+ }
278
+
279
+ public close(): void {
280
+ this.db.close();
281
+ }
282
+
283
+ public upsertSession(session: NimPersistedSession): void {
284
+ this.db
285
+ .prepare(
286
+ `
287
+ INSERT INTO nim_sessions (
288
+ session_id,
289
+ tenant_id,
290
+ user_id,
291
+ model,
292
+ lane,
293
+ soul_hash,
294
+ skills_snapshot_version,
295
+ event_seq,
296
+ last_run_id,
297
+ followups_json
298
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
299
+ ON CONFLICT(session_id) DO UPDATE SET
300
+ tenant_id = excluded.tenant_id,
301
+ user_id = excluded.user_id,
302
+ model = excluded.model,
303
+ lane = excluded.lane,
304
+ soul_hash = excluded.soul_hash,
305
+ skills_snapshot_version = excluded.skills_snapshot_version,
306
+ event_seq = excluded.event_seq,
307
+ last_run_id = excluded.last_run_id,
308
+ followups_json = excluded.followups_json
309
+ `,
310
+ )
311
+ .run(
312
+ session.sessionId,
313
+ session.tenantId,
314
+ session.userId,
315
+ session.model,
316
+ session.lane,
317
+ session.soulHash ?? null,
318
+ session.skillsSnapshotVersion ?? null,
319
+ session.eventSeq,
320
+ session.lastRunId ?? null,
321
+ JSON.stringify(session.followups),
322
+ );
323
+ }
324
+
325
+ public getSession(sessionId: string): NimPersistedSession | undefined {
326
+ const row = this.db
327
+ .prepare(
328
+ `
329
+ SELECT
330
+ session_id,
331
+ tenant_id,
332
+ user_id,
333
+ model,
334
+ lane,
335
+ soul_hash,
336
+ skills_snapshot_version,
337
+ event_seq,
338
+ last_run_id,
339
+ followups_json
340
+ FROM nim_sessions
341
+ WHERE session_id = ?
342
+ LIMIT 1
343
+ `,
344
+ )
345
+ .get(sessionId);
346
+ if (row === undefined) {
347
+ return undefined;
348
+ }
349
+ return parsePersistedSessionRow(row);
350
+ }
351
+
352
+ public listSessions(tenantId: string, userId: string): readonly NimPersistedSession[] {
353
+ const rows = this.db
354
+ .prepare(
355
+ `
356
+ SELECT
357
+ session_id,
358
+ tenant_id,
359
+ user_id,
360
+ model,
361
+ lane,
362
+ soul_hash,
363
+ skills_snapshot_version,
364
+ event_seq,
365
+ last_run_id,
366
+ followups_json
367
+ FROM nim_sessions
368
+ WHERE tenant_id = ? AND user_id = ?
369
+ ORDER BY session_id ASC
370
+ `,
371
+ )
372
+ .all(tenantId, userId);
373
+ return rows.map((row) => parsePersistedSessionRow(row));
374
+ }
375
+
376
+ public upsertIdempotency(sessionId: string, idempotencyKey: string, runId: string): void {
377
+ this.db
378
+ .prepare(
379
+ `
380
+ INSERT OR IGNORE INTO nim_session_idempotency (
381
+ session_id,
382
+ idempotency_key,
383
+ run_id
384
+ ) VALUES (?, ?, ?)
385
+ `,
386
+ )
387
+ .run(sessionId, idempotencyKey, runId);
388
+ }
389
+
390
+ public getRunIdByIdempotency(sessionId: string, idempotencyKey: string): string | undefined {
391
+ const row = this.db
392
+ .prepare(
393
+ `
394
+ SELECT run_id
395
+ FROM nim_session_idempotency
396
+ WHERE session_id = ? AND idempotency_key = ?
397
+ LIMIT 1
398
+ `,
399
+ )
400
+ .get(sessionId, idempotencyKey);
401
+ if (row === undefined) {
402
+ return undefined;
403
+ }
404
+ return asString(asRecord(row).run_id, 'run_id');
405
+ }
406
+
407
+ public listIdempotency(sessionId: string): readonly NimPersistedIdempotency[] {
408
+ const rows = this.db
409
+ .prepare(
410
+ `
411
+ SELECT idempotency_key, run_id
412
+ FROM nim_session_idempotency
413
+ WHERE session_id = ?
414
+ ORDER BY idempotency_key ASC
415
+ `,
416
+ )
417
+ .all(sessionId);
418
+ return rows.map((row) => parseIdempotencyRow(row));
419
+ }
420
+
421
+ private configureConnection(): void {
422
+ this.db.exec('PRAGMA journal_mode = WAL;');
423
+ this.db.exec('PRAGMA synchronous = NORMAL;');
424
+ this.db.exec('PRAGMA busy_timeout = 5000;');
425
+ this.db.exec('PRAGMA foreign_keys = ON;');
426
+ }
427
+
428
+ private initializeSchema(): void {
429
+ this.db.exec('BEGIN IMMEDIATE TRANSACTION');
430
+ try {
431
+ const currentVersion = this.readSchemaVersion();
432
+ if (currentVersion > NIM_SESSION_STORE_SCHEMA_VERSION) {
433
+ throw new Error(
434
+ `nim session store schema version ${String(currentVersion)} is newer than supported version ${String(NIM_SESSION_STORE_SCHEMA_VERSION)}`,
435
+ );
436
+ }
437
+ if (currentVersion < 1) {
438
+ this.applySchemaV1();
439
+ }
440
+ if (currentVersion < 2) {
441
+ this.applySchemaV2();
442
+ }
443
+ this.writeSchemaVersion(NIM_SESSION_STORE_SCHEMA_VERSION);
444
+ this.db.exec('COMMIT');
445
+ } catch (error) {
446
+ this.db.exec('ROLLBACK');
447
+ throw error;
448
+ }
449
+ }
450
+
451
+ private applySchemaV1(): void {
452
+ this.db.exec(`
453
+ CREATE TABLE IF NOT EXISTS nim_sessions (
454
+ session_id TEXT PRIMARY KEY,
455
+ tenant_id TEXT NOT NULL,
456
+ user_id TEXT NOT NULL,
457
+ model TEXT NOT NULL,
458
+ lane TEXT NOT NULL,
459
+ soul_hash TEXT,
460
+ skills_snapshot_version INTEGER,
461
+ event_seq INTEGER NOT NULL,
462
+ last_run_id TEXT
463
+ );
464
+ `);
465
+ this.db.exec(`
466
+ CREATE INDEX IF NOT EXISTS idx_nim_sessions_scope
467
+ ON nim_sessions (tenant_id, user_id, session_id);
468
+ `);
469
+ this.db.exec(`
470
+ CREATE TABLE IF NOT EXISTS nim_session_idempotency (
471
+ session_id TEXT NOT NULL,
472
+ idempotency_key TEXT NOT NULL,
473
+ run_id TEXT NOT NULL,
474
+ PRIMARY KEY (session_id, idempotency_key),
475
+ FOREIGN KEY (session_id) REFERENCES nim_sessions(session_id) ON DELETE CASCADE
476
+ );
477
+ `);
478
+ this.db.exec(`
479
+ CREATE INDEX IF NOT EXISTS idx_nim_session_idempotency_session
480
+ ON nim_session_idempotency (session_id, idempotency_key);
481
+ `);
482
+ }
483
+
484
+ private applySchemaV2(): void {
485
+ if (!this.tableHasColumn('nim_sessions', 'followups_json')) {
486
+ this.db.exec(`
487
+ ALTER TABLE nim_sessions
488
+ ADD COLUMN followups_json TEXT NOT NULL DEFAULT '[]';
489
+ `);
490
+ }
491
+ }
492
+
493
+ private readSchemaVersion(): number {
494
+ const row = this.db.prepare('PRAGMA user_version;').get();
495
+ if (row === undefined) {
496
+ throw new Error('failed to read nim session store schema version');
497
+ }
498
+ const version = asRecord(row).user_version;
499
+ if (typeof version !== 'number' || !Number.isInteger(version) || version < 0) {
500
+ throw new Error(`invalid nim session store schema version value: ${String(version)}`);
501
+ }
502
+ return version;
503
+ }
504
+
505
+ private writeSchemaVersion(version: number): void {
506
+ this.db.exec(`PRAGMA user_version = ${String(version)};`);
507
+ }
508
+
509
+ private tableHasColumn(tableName: string, columnName: string): boolean {
510
+ const rows = this.db.prepare(`PRAGMA table_info(${tableName});`).all();
511
+ return rows.some((row) => {
512
+ const name = asRecord(row).name;
513
+ return typeof name === 'string' && name === columnName;
514
+ });
515
+ }
516
+ }
@@ -0,0 +1,48 @@
1
+ import { appendFileSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import { dirname } from 'node:path';
3
+ import type { NimTelemetrySink } from './contracts.ts';
4
+ import { parseNimEventEnvelope, type NimEventEnvelope } from './events.ts';
5
+
6
+ export type NimJsonlTelemetrySinkInput = {
7
+ readonly filePath: string;
8
+ readonly mode?: 'append' | 'truncate';
9
+ };
10
+
11
+ export class NimJsonlTelemetrySink implements NimTelemetrySink {
12
+ public readonly name: string;
13
+ private readonly filePath: string;
14
+
15
+ public constructor(input: NimJsonlTelemetrySinkInput) {
16
+ this.filePath = input.filePath;
17
+ this.name = `jsonl:${this.filePath}`;
18
+ mkdirSync(dirname(this.filePath), { recursive: true });
19
+ if (input.mode !== 'append') {
20
+ writeFileSync(this.filePath, '', 'utf8');
21
+ }
22
+ }
23
+
24
+ public record(event: NimEventEnvelope): void {
25
+ appendFileSync(this.filePath, `${JSON.stringify(event)}\n`, 'utf8');
26
+ }
27
+ }
28
+
29
+ export function readNimJsonlTelemetry(filePath: string): NimEventEnvelope[] {
30
+ const content = readFileSync(filePath, 'utf8').trim();
31
+ if (content.length === 0) {
32
+ return [];
33
+ }
34
+ const lines = content.split('\n');
35
+ return lines.map((line, index) => {
36
+ let parsed: unknown;
37
+ try {
38
+ parsed = JSON.parse(line);
39
+ } catch {
40
+ throw new Error(`invalid Nim telemetry JSONL at line ${String(index + 1)}`);
41
+ }
42
+ try {
43
+ return parseNimEventEnvelope(parsed);
44
+ } catch {
45
+ throw new Error(`invalid Nim telemetry event envelope at line ${String(index + 1)}`);
46
+ }
47
+ });
48
+ }
@@ -0,0 +1,150 @@
1
+ import type { NimEventEnvelope } from '../../nim-core/src/events.ts';
2
+ import type { NimRuntime, NimUiEvent } from '../../nim-core/src/contracts.ts';
3
+ import { projectEventToUiEvents, type NimUiMode } from '../../nim-ui-core/src/projection.ts';
4
+
5
+ export type TestTuiFrame = {
6
+ readonly mode: NimUiMode;
7
+ readonly runId: string;
8
+ readonly lines: readonly string[];
9
+ readonly state: 'thinking' | 'tool-calling' | 'responding' | 'idle';
10
+ };
11
+
12
+ export type CollectNimTestTuiFrameInput = {
13
+ readonly runtime: NimRuntime;
14
+ readonly tenantId: string;
15
+ readonly sessionId: string;
16
+ readonly mode: NimUiMode;
17
+ readonly fromEventIdExclusive?: string;
18
+ readonly timeoutMs?: number;
19
+ };
20
+
21
+ export type CollectNimTestTuiFrameResult = {
22
+ readonly frame: TestTuiFrame;
23
+ readonly lastEventId?: string;
24
+ readonly projectedEventCount: number;
25
+ };
26
+
27
+ export class NimTestTuiController {
28
+ private mode: NimUiMode;
29
+ private runId: string;
30
+ private state: 'thinking' | 'tool-calling' | 'responding' | 'idle';
31
+ private lines: string[];
32
+ private pendingAssistantText: string;
33
+
34
+ public constructor(input: { mode: NimUiMode; runId: string }) {
35
+ this.mode = input.mode;
36
+ this.runId = input.runId;
37
+ this.state = 'idle';
38
+ this.lines = [];
39
+ this.pendingAssistantText = '';
40
+ }
41
+
42
+ public consume(event: NimEventEnvelope): readonly NimUiEvent[] {
43
+ if (event.run_id.length > 0) {
44
+ this.runId = event.run_id;
45
+ }
46
+ const projected = projectEventToUiEvents(event, this.mode);
47
+ for (const item of projected) {
48
+ if (item.type === 'assistant.state') {
49
+ this.state = item.state;
50
+ continue;
51
+ }
52
+ if (item.type === 'assistant.text.delta') {
53
+ this.pendingAssistantText += item.text;
54
+ continue;
55
+ }
56
+ if (item.type === 'assistant.text.message') {
57
+ this.pendingAssistantText = '';
58
+ this.lines.push(item.text);
59
+ continue;
60
+ }
61
+ if (item.type === 'tool.activity') {
62
+ this.lines.push(`[tool:${item.phase}] ${item.toolName}`);
63
+ continue;
64
+ }
65
+ if (item.type === 'system.notice') {
66
+ this.lines.push(`[notice] ${item.text}`);
67
+ }
68
+ }
69
+ return projected;
70
+ }
71
+
72
+ public snapshot(): TestTuiFrame {
73
+ const lines =
74
+ this.pendingAssistantText.length > 0
75
+ ? [...this.lines, this.pendingAssistantText]
76
+ : this.lines.slice();
77
+ return {
78
+ mode: this.mode,
79
+ runId: this.runId,
80
+ lines,
81
+ state: this.state,
82
+ };
83
+ }
84
+ }
85
+
86
+ export async function collectNimTestTuiFrame(
87
+ input: CollectNimTestTuiFrameInput,
88
+ ): Promise<CollectNimTestTuiFrameResult> {
89
+ const stream = input.runtime.streamEvents({
90
+ tenantId: input.tenantId,
91
+ sessionId: input.sessionId,
92
+ ...(input.fromEventIdExclusive !== undefined
93
+ ? { fromEventIdExclusive: input.fromEventIdExclusive }
94
+ : {}),
95
+ fidelity: 'semantic',
96
+ });
97
+ const iterator = stream[Symbol.asyncIterator]();
98
+ const controller = new NimTestTuiController({
99
+ mode: input.mode,
100
+ runId: input.sessionId,
101
+ });
102
+ const timeoutMs = input.timeoutMs ?? 5000;
103
+ const deadline = Date.now() + timeoutMs;
104
+ let sawActiveState = false;
105
+ let projectedEventCount = 0;
106
+ let lastEventId: string | undefined;
107
+ try {
108
+ while (Date.now() < deadline) {
109
+ const remaining = deadline - Date.now();
110
+ let timer: ReturnType<typeof setTimeout> | undefined;
111
+ const next = await Promise.race([
112
+ iterator.next(),
113
+ new Promise<never>((_, reject) => {
114
+ timer = setTimeout(() => {
115
+ reject(new Error('timed out waiting for Nim test TUI idle frame'));
116
+ }, remaining);
117
+ }),
118
+ ]).finally(() => {
119
+ if (timer !== undefined) {
120
+ clearTimeout(timer);
121
+ }
122
+ });
123
+ if (next.done) {
124
+ break;
125
+ }
126
+ lastEventId = next.value.event_id;
127
+ const projected = controller.consume(next.value);
128
+ projectedEventCount += projected.length;
129
+ for (const item of projected) {
130
+ if (item.type !== 'assistant.state') {
131
+ continue;
132
+ }
133
+ if (item.state !== 'idle') {
134
+ sawActiveState = true;
135
+ continue;
136
+ }
137
+ if (sawActiveState) {
138
+ return {
139
+ frame: controller.snapshot(),
140
+ ...(lastEventId !== undefined ? { lastEventId } : {}),
141
+ projectedEventCount,
142
+ };
143
+ }
144
+ }
145
+ }
146
+ throw new Error('timed out waiting for Nim test TUI idle frame');
147
+ } finally {
148
+ await iterator.return?.();
149
+ }
150
+ }
@@ -0,0 +1 @@
1
+ export * from './projection.ts';