@itookit/dsht 0.3.0 → 0.3.3

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 (134) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +39 -23
  3. package/README.zh.md +40 -24
  4. package/dist/catalog/controller.d.ts +32 -0
  5. package/dist/catalog/controller.js +88 -0
  6. package/dist/catalog/index.d.ts +2 -0
  7. package/dist/catalog/index.js +2 -0
  8. package/dist/{cli.js → cli/index.js} +42 -29
  9. package/dist/controller/connection.d.ts +80 -0
  10. package/dist/controller/connection.js +190 -0
  11. package/dist/controller/controller.d.ts +269 -0
  12. package/dist/controller/controller.js +372 -0
  13. package/dist/controller/index.d.ts +5 -0
  14. package/dist/controller/index.js +3 -0
  15. package/dist/controller/memory-log.d.ts +35 -0
  16. package/dist/controller/memory-log.js +95 -0
  17. package/dist/cost/config.d.ts +17 -0
  18. package/dist/cost/config.js +68 -0
  19. package/dist/cost/controller.d.ts +41 -0
  20. package/dist/cost/controller.js +115 -0
  21. package/dist/cost/index.d.ts +10 -0
  22. package/dist/cost/index.js +8 -0
  23. package/dist/cost/ledger-files.d.ts +16 -0
  24. package/dist/cost/ledger-files.js +103 -0
  25. package/dist/cost/ledger.d.ts +78 -0
  26. package/dist/cost/ledger.js +146 -0
  27. package/dist/cost/pricing.d.ts +86 -0
  28. package/dist/cost/pricing.js +223 -0
  29. package/dist/cost/records.d.ts +17 -0
  30. package/dist/cost/records.js +79 -0
  31. package/dist/cost/scanner.d.ts +22 -0
  32. package/dist/cost/scanner.js +95 -0
  33. package/dist/cost/types.d.ts +85 -0
  34. package/dist/cost/types.js +7 -0
  35. package/dist/index.d.ts +3 -0
  36. package/dist/index.js +2 -0
  37. package/dist/session/connection-view.d.ts +18 -0
  38. package/dist/session/connection-view.js +1 -0
  39. package/dist/{controller.d.ts → session/controller.d.ts} +102 -136
  40. package/dist/session/controller.js +616 -0
  41. package/dist/session/export-html.d.ts +9 -0
  42. package/dist/session/export-html.js +39 -0
  43. package/dist/{export.d.ts → session/export.d.ts} +1 -1
  44. package/dist/{export.js → session/export.js} +6 -20
  45. package/dist/{history.d.ts → session/history.d.ts} +17 -0
  46. package/dist/{history.js → session/history.js} +163 -2
  47. package/dist/session/index.d.ts +17 -0
  48. package/dist/session/index.js +10 -0
  49. package/dist/session/markdown.d.ts +39 -0
  50. package/dist/session/markdown.js +255 -0
  51. package/dist/session/math.d.ts +11 -0
  52. package/dist/session/math.js +82 -0
  53. package/dist/session/navigation.d.ts +37 -0
  54. package/dist/session/navigation.js +84 -0
  55. package/dist/{references.js → session/references.js} +1 -1
  56. package/dist/{telemetry.d.ts → session/telemetry.d.ts} +1 -1
  57. package/dist/{telemetry.js → session/telemetry.js} +1 -1
  58. package/dist/{transcript.d.ts → session/transcript.d.ts} +65 -1
  59. package/dist/{transcript.js → session/transcript.js} +141 -20
  60. package/dist/session/types.d.ts +18 -0
  61. package/dist/session/types.js +2 -0
  62. package/dist/state.d.ts +41 -0
  63. package/dist/state.js +9 -0
  64. package/dist/storage/directories.d.ts +14 -0
  65. package/dist/storage/directories.js +24 -0
  66. package/dist/storage/files.d.ts +51 -0
  67. package/dist/storage/files.js +152 -0
  68. package/dist/storage/heap-snapshot.d.ts +19 -0
  69. package/dist/storage/heap-snapshot.js +29 -0
  70. package/dist/storage/index.d.ts +4 -0
  71. package/dist/storage/index.js +4 -0
  72. package/dist/transport/auth.js +65 -0
  73. package/dist/transport/host.d.ts +13 -0
  74. package/dist/transport/host.js +1 -0
  75. package/dist/ui/app.d.ts +11 -0
  76. package/dist/ui/app.js +790 -0
  77. package/dist/ui/chat/header.d.ts +11 -0
  78. package/dist/ui/chat/header.js +14 -0
  79. package/dist/ui/chat/history-view.d.ts +12 -0
  80. package/dist/ui/chat/history-view.js +17 -0
  81. package/dist/ui/chat/status.d.ts +91 -0
  82. package/dist/ui/chat/status.js +386 -0
  83. package/dist/ui/chat/viewport.d.ts +17 -0
  84. package/dist/ui/chat/viewport.js +14 -0
  85. package/dist/ui/commands/parse.d.ts +99 -0
  86. package/dist/ui/commands/parse.js +126 -0
  87. package/dist/ui/commands/registry.d.ts +33 -0
  88. package/dist/ui/commands/registry.js +73 -0
  89. package/dist/ui/copy-mode.d.ts +4 -0
  90. package/dist/ui/copy-mode.js +6 -0
  91. package/dist/{cost-view.d.ts → ui/dialogs/cost.d.ts} +1 -1
  92. package/dist/{cost-view.js → ui/dialogs/cost.js} +6 -6
  93. package/dist/ui/dialogs/index.d.ts +120 -0
  94. package/dist/ui/dialogs/index.js +113 -0
  95. package/dist/ui/dialogs/picker.d.ts +18 -0
  96. package/dist/ui/dialogs/picker.js +38 -0
  97. package/dist/ui/frozen.d.ts +8 -0
  98. package/dist/ui/frozen.js +7 -0
  99. package/dist/ui/input/references.d.ts +12 -0
  100. package/dist/ui/input/references.js +15 -0
  101. package/dist/ui/mount.d.ts +6 -0
  102. package/dist/ui/mount.js +11 -0
  103. package/dist/{theme.d.ts → ui/theme/index.d.ts} +1 -1
  104. package/package.json +19 -13
  105. package/dist/app.d.ts +0 -21
  106. package/dist/app.js +0 -805
  107. package/dist/auth.js +0 -108
  108. package/dist/controller.js +0 -961
  109. package/dist/cost.d.ts +0 -119
  110. package/dist/cost.js +0 -313
  111. package/dist/history-view.d.ts +0 -8
  112. package/dist/history-view.js +0 -12
  113. package/dist/navigation.d.ts +0 -11
  114. package/dist/navigation.js +0 -36
  115. package/dist/status.d.ts +0 -28
  116. package/dist/status.js +0 -157
  117. /package/dist/{cli.d.ts → cli/index.d.ts} +0 -0
  118. /package/dist/{memory.d.ts → session/memory.d.ts} +0 -0
  119. /package/dist/{memory.js → session/memory.js} +0 -0
  120. /package/dist/{references.d.ts → session/references.d.ts} +0 -0
  121. /package/dist/{auth.d.ts → transport/auth.d.ts} +0 -0
  122. /package/dist/{client.d.ts → transport/client.d.ts} +0 -0
  123. /package/dist/{client.js → transport/client.js} +0 -0
  124. /package/dist/{endpoint.d.ts → transport/endpoint.d.ts} +0 -0
  125. /package/dist/{endpoint.js → transport/endpoint.js} +0 -0
  126. /package/dist/{wire.d.ts → transport/wire.d.ts} +0 -0
  127. /package/dist/{wire.js → transport/wire.js} +0 -0
  128. /package/dist/{input-history.d.ts → ui/input/history.d.ts} +0 -0
  129. /package/dist/{input-history.js → ui/input/history.js} +0 -0
  130. /package/dist/{input.d.ts → ui/input/input.d.ts} +0 -0
  131. /package/dist/{input.js → ui/input/input.js} +0 -0
  132. /package/dist/{mouse.d.ts → ui/input/mouse.d.ts} +0 -0
  133. /package/dist/{mouse.js → ui/input/mouse.js} +0 -0
  134. /package/dist/{theme.js → ui/theme/index.js} +0 -0
@@ -0,0 +1,88 @@
1
+ import { array, errorText, object, string } from "../transport/wire.js";
2
+ /** Owns model-catalog and preset loads for the selected session. */
3
+ export class CatalogController {
4
+ store;
5
+ host;
6
+ presetClient;
7
+ revision = 0;
8
+ tasks = new Set();
9
+ constructor(store, host) {
10
+ this.store = store;
11
+ this.host = host;
12
+ }
13
+ /** Drop generation-scoped catalog state at the start of a connection generation. */
14
+ reset() {
15
+ this.store.update({ modelError: undefined, defaultModel: undefined, presets: undefined, presetError: undefined });
16
+ }
17
+ /** Wait for every in-flight catalog task, so shutdown leaves no pending request. */
18
+ async settle() { await Promise.all(this.tasks); }
19
+ /** Load the optional preset roster once per connection, only when a session names a preset. */
20
+ loadPresetNames() {
21
+ const client = this.host.client();
22
+ if (!client || !this.store.state.online || this.presetClient === client)
23
+ return;
24
+ this.presetClient = client;
25
+ const task = client.call('agentPresets/list', {}).then(value => {
26
+ if (client === this.host.client())
27
+ this.store.update({ presets: array(object(value).presets).map(object), presetError: undefined });
28
+ }).catch(error => {
29
+ if (client === this.host.client())
30
+ this.store.update({ presets: [], presetError: errorText(error) });
31
+ });
32
+ this.tasks.add(task);
33
+ void task.finally(() => this.tasks.delete(task));
34
+ }
35
+ /** Fetch current model routes and adapter-owned reasoning choices for the selected session.
36
+ * @returns Host catalog; provider failures remain available to the selector.
37
+ */
38
+ async modelCatalog() {
39
+ const sessionId = this.sessionId;
40
+ const selection = this.store.selection();
41
+ const value = object(await this.host.require().call('session/modelCatalog', {}));
42
+ if (selection !== this.store.selection() || sessionId !== this.store.state.sessionId)
43
+ throw new Error('Session changed while loading models');
44
+ return value;
45
+ }
46
+ /** Select the next request's model; the host also attempts to save its deployment default.
47
+ * @param provider - Host provider route ID.
48
+ * @param model - Exact model ID.
49
+ * @param reasoningEffort - Optional adapter-owned effort ID; omission uses its default.
50
+ */
51
+ async selectModel(provider, model, reasoningEffort) {
52
+ const sessionId = this.sessionId;
53
+ const selection = this.store.selection();
54
+ const selected = object(object(await this.host.require().call('session/selectModel', { request: {
55
+ sessionId, provider, model, ...(reasoningEffort === undefined ? {} : { reasoningEffort }),
56
+ } })).selected);
57
+ if (selection !== this.store.selection() || sessionId !== this.store.state.sessionId)
58
+ return;
59
+ this.store.update({ status: `Next request: ${string(selected.provider)} / ${string(selected.model)}${selected.reasoningEffort ? ` · ${string(selected.reasoningEffort)}` : ''}` });
60
+ this.refresh();
61
+ }
62
+ /** Reload the default route and provider failures without touching session state. */
63
+ refresh() {
64
+ const client = this.host.client();
65
+ if (!client)
66
+ return;
67
+ const revision = ++this.revision;
68
+ const task = client.call('session/modelCatalog', {}).then(value => {
69
+ if (client === this.host.client() && revision === this.revision)
70
+ this.store.update({ defaultModel: object(object(value).default), modelError: undefined });
71
+ }, error => {
72
+ if (client === this.host.client() && revision === this.revision)
73
+ this.store.update({ defaultModel: undefined, modelError: errorText(error) });
74
+ }).catch(error => {
75
+ if (client === this.host.client() && revision === this.revision)
76
+ this.store.update({ defaultModel: undefined, modelError: errorText(error) });
77
+ });
78
+ this.tasks.add(task);
79
+ void task.finally(() => this.tasks.delete(task));
80
+ }
81
+ /** @returns The selected session identity, or a `Select a session first` failure. */
82
+ get sessionId() {
83
+ const id = this.store.state.sessionId;
84
+ if (!id)
85
+ throw new Error('Select a session first');
86
+ return id;
87
+ }
88
+ }
@@ -0,0 +1,2 @@
1
+ /** Catalog domain: model routes, reasoning efforts and agent-preset metadata. */
2
+ export { CatalogController } from './controller.ts';
@@ -0,0 +1,2 @@
1
+ /** Catalog domain: model routes, reasoning efforts and agent-preset metadata. */
2
+ export { CatalogController } from "./controller.js";
@@ -1,21 +1,19 @@
1
1
  #!/usr/bin/env node
2
- import { jsx as _jsx } from "react/jsx-runtime";
3
2
  /** Standalone executable entry; connects to an existing host and never launches Harness. */
4
3
  import { createHash } from 'node:crypto';
5
- import { mkdir, readFile, writeFile } from 'node:fs/promises';
6
4
  import { homedir } from 'node:os';
7
5
  import { join } from 'node:path';
8
- import { CostLedger, DEFAULT_PRICES, pricesFrom } from "./cost.js";
6
+ import { CostLedger, loadPrices } from "../cost/index.js";
9
7
  import { parseArgs } from 'node:util';
10
- import { render } from 'ink';
11
- import { App } from "./app.js";
12
- import { sessionLabel } from "./navigation.js";
13
- import { CookieStore, login } from "./auth.js";
14
- import { Client } from "./client.js";
15
- import { historyLimits } from "./memory.js";
16
- import { Controller } from "./controller.js";
17
- import { endpoint } from "./endpoint.js";
18
- import { errorText, safeText, string } from "./wire.js";
8
+ import { mount } from "../ui/mount.js";
9
+ import { ensureDirectory } from "../storage/index.js";
10
+ import { sessionLabel } from "../session/navigation.js";
11
+ import { CookieStore, login } from "../transport/auth.js";
12
+ import { Client } from "../transport/client.js";
13
+ import { historyLimits } from "../session/memory.js";
14
+ import { Controller } from "../controller/controller.js";
15
+ import { endpoint } from "../transport/endpoint.js";
16
+ import { errorText, safeText, string } from "../transport/wire.js";
19
17
  const HELP = `Usage: dsht [options] [list workspaces|list sessions]
20
18
 
21
19
  With no command, choose a workspace and session interactively.
@@ -26,14 +24,19 @@ With no command, choose a workspace and session interactively.
26
24
  --auth-dir <path> Private cookie directory (or DSHT_AUTH_DIR)
27
25
  --history-records <n> Soft history record limit (default 2000)
28
26
  --history-mb <n> Soft history payload budget in MiB (default 16)
27
+ --memory-log <path> Append runtime memory samples; a failing log stops itself
28
+ --no-memory-log Disable the runtime memory log (default: enabled)
29
29
  --json Print machine-readable list output
30
30
  --help Show this help
31
31
 
32
32
  The default host is http://127.0.0.1:3080.
33
33
  First login: export DSH_TOKEN, or export DSH_URL as the URL printed by dsh web.
34
34
  Cookies are saved per server origin and reused on later starts. Tokens are never saved.
35
- /cost shows session, today and three-day CNY estimates.
35
+ /cost shows the session and today CNY estimates.
36
36
  DSHT_CONFIG_DIR overrides the prices.json directory; DSHT_STATE_DIR overrides usage storage.
37
+ The memory log defaults to <state>/memory.log; DSHT_MEMORY_LOG sets another path or 'off'.
38
+ prices.json overrides the shipped rates and is seeded on first use; every scan re-decides the
39
+ history with the table loaded then, so an edited table reaches past requests on the next scan.
37
40
  Examples:
38
41
  npx @itookit/dsht
39
42
  dsht list workspaces --json
@@ -44,6 +47,7 @@ async function main() {
44
47
  url: { type: 'string', default: process.env.DSH_URL ?? 'http://127.0.0.1:3080' },
45
48
  'history-records': { type: 'string' }, 'history-mb': { type: 'string' },
46
49
  workspace: { type: 'string' }, session: { type: 'string' }, 'auth-dir': { type: 'string' }, json: { type: 'boolean' }, help: { type: 'boolean' },
50
+ 'memory-log': { type: 'string' }, 'no-memory-log': { type: 'boolean' },
47
51
  } });
48
52
  if (values.help) {
49
53
  process.stdout.write(HELP);
@@ -80,24 +84,17 @@ async function main() {
80
84
  }
81
85
  return;
82
86
  }
83
- if (!process.stdin.isTTY || !process.stdout.isTTY)
84
- throw new Error('Interactive mode requires a terminal. Use list workspaces or list sessions for scripts.');
85
87
  const config = process.env.DSHT_CONFIG_DIR ?? join(process.env.XDG_CONFIG_HOME ?? join(homedir(), '.config'), 'dsht');
86
- await mkdir(config, { recursive: true, mode: 0o700 });
87
- const pricePath = join(config, 'prices.json');
88
- try {
89
- await writeFile(pricePath, JSON.stringify(DEFAULT_PRICES, null, 2) + '\n', { flag: 'wx', mode: 0o600 });
90
- }
91
- catch (error) {
92
- if (!(error instanceof Error && 'code' in error && error.code === 'EEXIST'))
93
- throw error;
94
- }
95
- const prices = pricesFrom(JSON.parse(await readFile(pricePath, 'utf8')));
96
- const costDirectory = join(process.env.DSHT_STATE_DIR ?? join(process.env.XDG_STATE_HOME ?? join(homedir(), '.local', 'state'), 'dsht'), 'cost', createHash('sha256').update(new URL(url).origin).digest('hex'));
97
- const costs = new CostLedger(prices, costDirectory);
88
+ await ensureDirectory(config);
89
+ const { prices, custom } = await loadPrices(config);
90
+ const stateRoot = process.env.DSHT_STATE_DIR ?? join(process.env.XDG_STATE_HOME ?? join(homedir(), '.local', 'state'), 'dsht');
91
+ const costDirectory = join(stateRoot, 'cost', createHash('sha256').update(new URL(url).origin).digest('hex'));
92
+ const costs = new CostLedger(prices, costDirectory, custom);
98
93
  await costs.load();
99
- const controller = new Controller(url, token, values.session, undefined, client => login(client, token, store), costs, limits);
100
- const app = render(_jsx(App, { controller: controller }), { exitOnCtrlC: false });
94
+ if (!process.stdin.isTTY || !process.stdout.isTTY)
95
+ throw new Error('Interactive mode requires a terminal. Use list workspaces or list sessions for scripts.');
96
+ const controller = new Controller(url, token, values.session, undefined, client => login(client, token, store), costs, limits, memoryLogPath(stateRoot, values['memory-log'], values['no-memory-log']));
97
+ const app = mount(controller);
101
98
  const terminate = () => app.unmount();
102
99
  process.once('SIGTERM', terminate);
103
100
  controller.start();
@@ -109,4 +106,20 @@ async function main() {
109
106
  await controller.shutdown();
110
107
  }
111
108
  }
109
+ /** Resolve the runtime memory log path: an explicit flag wins, then the environment, then the default.
110
+ * @param stateRoot - Application state root used for the default path.
111
+ * @param requested - `--memory-log` value, when given.
112
+ * @param disabled - `--no-memory-log` flag.
113
+ * @returns Absolute log path, or undefined when the log is disabled.
114
+ */
115
+ function memoryLogPath(stateRoot, requested, disabled) {
116
+ if (requested !== undefined && requested.trim() === '')
117
+ throw new Error('--memory-log requires a path');
118
+ if (disabled)
119
+ return undefined;
120
+ const chosen = (requested ?? process.env.DSHT_MEMORY_LOG)?.trim();
121
+ if (chosen === undefined || chosen === '')
122
+ return join(stateRoot, 'memory.log');
123
+ return chosen === 'off' ? undefined : chosen;
124
+ }
112
125
  main().catch(error => { process.stderr.write(`${errorText(error)}\n`); process.exitCode = 1; });
@@ -0,0 +1,80 @@
1
+ import { Client } from '../transport/client.ts';
2
+ import { type ObjectValue } from '../transport/wire.ts';
3
+ import type { HostAccess } from '../transport/host.ts';
4
+ import { Telemetry } from '../session/telemetry.ts';
5
+ import type { ConnectionView } from '../session/connection-view.ts';
6
+ import type { ControllerStore } from '../state.ts';
7
+ /** Connection inputs resolved by the CLI or a library consumer. */
8
+ export interface ConnectionOptions {
9
+ base: string;
10
+ token: string | undefined;
11
+ initialSession: string | undefined;
12
+ /** Factory for one generation's client; replaced in tests. */
13
+ makeClient: () => Client;
14
+ /** Authentication used by every generation, including reconnects. */
15
+ authenticate: (client: Client) => Promise<void>;
16
+ }
17
+ /** Reactions the controller facade owes to connection lifecycle and host events. */
18
+ export interface ConnectionListener {
19
+ /** A new generation starts; domains drop generation-scoped state. */
20
+ begin(): void;
21
+ /** The event stream is ready and the control baseline is applied. */
22
+ ready(): Promise<void>;
23
+ /** The generation ended and its socket is closed. */
24
+ ended(): Promise<void>;
25
+ /** Deliver a host waterfall; return true when a domain retained it for an answer. */
26
+ waterfall(frame: ObjectValue): boolean;
27
+ /** The host cancelled a waterfall a domain retained. */
28
+ cancelled(eventId: string): void;
29
+ /** The host reported one session's running state. */
30
+ status(sessionId: string, running: boolean): void;
31
+ /** The host reported one session's error. */
32
+ error(sessionId: unknown, error: unknown): void;
33
+ /** Model catalogs may have changed. */
34
+ invalidated(): void;
35
+ /** A turn finished, so billing may refresh. */
36
+ idle(): void;
37
+ }
38
+ /** Owns reconnects, subscriptions and the telemetry generation. User commands remain single-attempt operations. */
39
+ export declare class ConnectionController implements HostAccess, ConnectionView {
40
+ private readonly store;
41
+ private readonly options;
42
+ private readonly listener;
43
+ telemetry: Telemetry;
44
+ clientId: string;
45
+ private readonly runningUpdates;
46
+ private readonly observedRunningAt;
47
+ private current;
48
+ private readonly abort;
49
+ private runTask;
50
+ private generationFailed;
51
+ constructor(store: ControllerStore, options: ConnectionOptions, listener: ConnectionListener);
52
+ /** Start one retry loop, with a fresh snapshot generation after every disconnect. */
53
+ start(): void;
54
+ /** Cancel retries and HTTP, close the socket, and wait for the loop to settle. */
55
+ stop(): Promise<void>;
56
+ /** @returns The connected client, or undefined while offline. */
57
+ client(): Client | undefined;
58
+ /** @returns The connected client, or throws while offline. */
59
+ require(): Client;
60
+ /** @returns Whether the current generation applied its host baseline. */
61
+ online(): boolean;
62
+ /** @returns The client lifetime signal. */
63
+ signal(): AbortSignal;
64
+ /** @returns The projection store of the current generation. */
65
+ telemetryView(): Telemetry;
66
+ /** @returns Cached host running state, or undefined when never reported. */
67
+ runningFor(sessionId: string): boolean | undefined;
68
+ /** @returns When this client first observed the session, for the elapsed-time fallback. */
69
+ observedAt(sessionId: string): number | undefined;
70
+ /** Record an observation start for a session this client just opened. */
71
+ observe(sessionId: string): void;
72
+ /** Fail the current generation, so the controller reopens a snapshot. */
73
+ fail(error: Error): void;
74
+ /** Answer one retained host waterfall through the event-result endpoint. */
75
+ reply(frame: ObjectValue, outcome: ObjectValue): Promise<void>;
76
+ /** Run one generation, then retry with bounded jittered backoff until stopped. */
77
+ private run;
78
+ /** Apply one `api-session/status` notification to the running maps and the session view. */
79
+ private acceptStatus;
80
+ }
@@ -0,0 +1,190 @@
1
+ /** Owns the physical connection, its generations, and the host event subscriptions. */
2
+ import { setTimeout as delay } from 'node:timers/promises';
3
+ import { AuthenticationRequired } from "../transport/auth.js";
4
+ import { HttpError, RemoteError } from "../transport/client.js";
5
+ import { array, errorText, object, string } from "../transport/wire.js";
6
+ import { Telemetry } from "../session/telemetry.js";
7
+ /** Projection keys this client consumes; the host retains every other capability. */
8
+ const RETAINED_PROJECTIONS = new Set(['title', 'modelSelection', 'contextPressure', 'tokenUsage', 'sessionStats', 'agentPreset']);
9
+ /** Host events that invalidate the model catalog. */
10
+ const CATALOG_EVENTS = ['llm/adapters-updated', 'settings/document-updated', 'credentials/reference-updated'];
11
+ /** Owns reconnects, subscriptions and the telemetry generation. User commands remain single-attempt operations. */
12
+ export class ConnectionController {
13
+ store;
14
+ options;
15
+ listener;
16
+ telemetry = new Telemetry(RETAINED_PROJECTIONS);
17
+ clientId = '';
18
+ runningUpdates = new Map();
19
+ observedRunningAt = new Map();
20
+ current;
21
+ abort = new AbortController();
22
+ runTask;
23
+ generationFailed;
24
+ constructor(store, options, listener) {
25
+ this.store = store;
26
+ this.options = options;
27
+ this.listener = listener;
28
+ }
29
+ /** Start one retry loop, with a fresh snapshot generation after every disconnect. */
30
+ start() { this.runTask ??= this.run(); }
31
+ /** Cancel retries and HTTP, close the socket, and wait for the loop to settle. */
32
+ async stop() {
33
+ this.abort.abort();
34
+ this.generationFailed?.(new Error('Client stopped'));
35
+ await this.current?.close();
36
+ await this.runTask;
37
+ }
38
+ /** @returns The connected client, or undefined while offline. */
39
+ client() { return this.current; }
40
+ /** @returns The connected client, or throws while offline. */
41
+ require() {
42
+ if (!this.current || !this.store.state.online)
43
+ throw new Error('Not connected');
44
+ return this.current;
45
+ }
46
+ /** @returns Whether the current generation applied its host baseline. */
47
+ online() { return this.store.state.online; }
48
+ /** @returns The client lifetime signal. */
49
+ signal() { return this.abort.signal; }
50
+ /** @returns The projection store of the current generation. */
51
+ telemetryView() { return this.telemetry; }
52
+ /** @returns Cached host running state, or undefined when never reported. */
53
+ runningFor(sessionId) { return this.runningUpdates.get(sessionId); }
54
+ /** @returns When this client first observed the session, for the elapsed-time fallback. */
55
+ observedAt(sessionId) { return this.observedRunningAt.get(sessionId); }
56
+ /** Record an observation start for a session this client just opened. */
57
+ observe(sessionId) { if (!this.observedRunningAt.has(sessionId))
58
+ this.observedRunningAt.set(sessionId, Date.now()); }
59
+ /** Fail the current generation, so the controller reopens a snapshot. */
60
+ fail(error) { this.generationFailed?.(error); }
61
+ /** Answer one retained host waterfall through the event-result endpoint. */
62
+ async reply(frame, outcome) {
63
+ await this.require().call('$events/result', { clientId: this.clientId, eventId: string(frame.eventId), outcome });
64
+ }
65
+ /** Run one generation, then retry with bounded jittered backoff until stopped. */
66
+ async run() {
67
+ let attempt = 0;
68
+ while (!this.abort.signal.aborted) {
69
+ this.runningUpdates.clear();
70
+ this.observedRunningAt.clear();
71
+ this.telemetry = new Telemetry(RETAINED_PROJECTIONS);
72
+ this.listener.begin();
73
+ const client = this.options.makeClient();
74
+ this.current = client;
75
+ try {
76
+ await this.options.authenticate(client);
77
+ await client.connect();
78
+ let fail;
79
+ const disconnected = new Promise(resolve => { fail = resolve; });
80
+ this.generationFailed = fail;
81
+ const ready = new Promise((resolve, reject) => {
82
+ const timer = setTimeout(() => reject(new Error('Host ready timed out')), client.timeoutMs);
83
+ client.subscribe('$events', {}, {
84
+ item: value => {
85
+ const frame = object(value);
86
+ if (frame.type === 'ready') {
87
+ this.clientId = string(frame.clientId);
88
+ clearTimeout(timer);
89
+ resolve();
90
+ }
91
+ else if (frame.type === 'waterfall') {
92
+ if (this.listener.waterfall(frame))
93
+ return;
94
+ void client.call('$events/result', { clientId: this.clientId,
95
+ eventId: string(frame.eventId), outcome: { kind: 'next' } }).catch(error => fail(new Error(errorText(error))));
96
+ }
97
+ else if (frame.type === 'cancel') {
98
+ this.listener.cancelled(string(frame.eventId));
99
+ }
100
+ else if (frame.type === 'emit' && frame.event === 'api-session/status') {
101
+ this.acceptStatus(array(frame.args));
102
+ }
103
+ else if (frame.type === 'emit' && CATALOG_EVENTS.includes(String(frame.event))) {
104
+ this.listener.invalidated();
105
+ }
106
+ else if (frame.type === 'emit' && frame.event === 'api-session/error') {
107
+ const args = array(frame.args);
108
+ this.listener.error(args[0], args[1]);
109
+ }
110
+ },
111
+ end: error => { clearTimeout(timer); const reason = error ?? new Error('Event stream ended'); reject(reason); fail(reason); },
112
+ });
113
+ });
114
+ await ready;
115
+ await new Promise((resolve, reject) => {
116
+ const timer = setTimeout(() => reject(new Error('Session control baseline timed out')), client.timeoutMs);
117
+ client.subscribe('session/control', {}, {
118
+ item: value => {
119
+ try {
120
+ this.telemetry.accept(value);
121
+ this.store.update({});
122
+ clearTimeout(timer);
123
+ resolve();
124
+ }
125
+ catch (error) {
126
+ clearTimeout(timer);
127
+ reject(error);
128
+ fail(new Error(errorText(error)));
129
+ }
130
+ },
131
+ end: error => {
132
+ clearTimeout(timer);
133
+ if (error instanceof RemoteError && error.code === 'gateway/method-unavailable') {
134
+ this.store.update({ controlError: 'Live metrics unavailable on this host' });
135
+ resolve();
136
+ }
137
+ else {
138
+ const reason = error ?? new Error('Session control stream ended');
139
+ reject(reason);
140
+ fail(reason);
141
+ }
142
+ },
143
+ });
144
+ });
145
+ await this.listener.ready();
146
+ attempt = 0;
147
+ const error = await disconnected;
148
+ if (!this.abort.signal.aborted)
149
+ throw error;
150
+ }
151
+ catch (error) {
152
+ if (error instanceof AuthenticationRequired || error instanceof HttpError && [401, 403].includes(error.status)) {
153
+ this.store.update({ error: `${errorText(error)}. Set DSH_TOKEN and restart to log in.`, status: 'Login required' });
154
+ return;
155
+ }
156
+ if (!this.abort.signal.aborted)
157
+ this.store.update({ error: errorText(error), status: 'Reconnecting…' });
158
+ }
159
+ finally {
160
+ this.generationFailed = undefined;
161
+ this.store.update({ online: false, pending: [] });
162
+ await client.close();
163
+ await this.listener.ended();
164
+ }
165
+ if (!this.abort.signal.aborted) {
166
+ try {
167
+ await delay(Math.min(500 * 2 ** attempt++, 10_000) * (0.8 + Math.random() * 0.4), undefined, { signal: this.abort.signal });
168
+ }
169
+ catch (error) {
170
+ if (!this.abort.signal.aborted)
171
+ throw error;
172
+ }
173
+ }
174
+ }
175
+ }
176
+ /** Apply one `api-session/status` notification to the running maps and the session view. */
177
+ acceptStatus(args) {
178
+ const sessionId = string(args[0]);
179
+ if (typeof args[1] !== 'boolean')
180
+ throw new Error('Invalid session running state');
181
+ if (args[1] && !this.runningUpdates.get(sessionId))
182
+ this.observedRunningAt.set(sessionId, Date.now());
183
+ if (!args[1])
184
+ this.observedRunningAt.delete(sessionId);
185
+ this.runningUpdates.set(sessionId, args[1]);
186
+ this.listener.status(sessionId, args[1]);
187
+ if (!args[1])
188
+ this.listener.idle();
189
+ }
190
+ }