@dinoxx/dinox-cli 2.13.1 → 2.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (112) hide show
  1. package/dist/ai/ragxClient.d.ts +15 -0
  2. package/dist/ai/ragxClient.js +71 -0
  3. package/dist/cli.js +31 -4
  4. package/dist/commands/auth/index.d.ts +2 -1
  5. package/dist/commands/auth/index.js +18 -1
  6. package/dist/commands/batch.d.ts +35 -0
  7. package/dist/commands/batch.js +202 -0
  8. package/dist/commands/boxes/index.js +21 -21
  9. package/dist/commands/doctor.d.ts +4 -1
  10. package/dist/commands/doctor.js +20 -1
  11. package/dist/commands/graph/index.js +2 -1
  12. package/dist/commands/mcp.d.ts +6 -0
  13. package/dist/commands/mcp.js +47 -0
  14. package/dist/commands/notes/actions.js +18 -2
  15. package/dist/commands/notes/contentPatch.js +2 -1
  16. package/dist/commands/notes/descriptors.js +111 -5
  17. package/dist/commands/notes/detailContent.d.ts +19 -0
  18. package/dist/commands/notes/detailContent.js +62 -0
  19. package/dist/commands/notes/export.js +7 -2
  20. package/dist/commands/notes/repo/search.d.ts +2 -1
  21. package/dist/commands/notes/repo/search.js +1 -1
  22. package/dist/commands/notes/repo/semantic.d.ts +14 -0
  23. package/dist/commands/notes/repo/semantic.js +31 -0
  24. package/dist/commands/notes/searchResponse.d.ts +3 -0
  25. package/dist/commands/notes/searchResponse.js +1 -0
  26. package/dist/commands/notes/semantic.d.ts +116 -0
  27. package/dist/commands/notes/semantic.js +332 -0
  28. package/dist/commands/prompt/index.js +7 -5
  29. package/dist/commands/runtime.d.ts +8 -0
  30. package/dist/commands/runtime.js +49 -1
  31. package/dist/commands/savedViews/index.js +12 -3
  32. package/dist/commands/schema/globalOptions.d.ts +7 -0
  33. package/dist/commands/schema/globalOptions.js +7 -0
  34. package/dist/commands/schema/renderSkills.js +6 -2
  35. package/dist/commands/schema/spec.js +20 -1
  36. package/dist/commands/storage/index.js +7 -5
  37. package/dist/commands/storage/repo/operations.js +3 -1
  38. package/dist/commands/sync.d.ts +2 -1
  39. package/dist/commands/sync.js +27 -8
  40. package/dist/commands/tags/index.js +21 -21
  41. package/dist/commands/todo/index.js +22 -2
  42. package/dist/commands/todo/repo.d.ts +5 -0
  43. package/dist/commands/todo/repo.js +7 -2
  44. package/dist/commands/writeReceipt.d.ts +13 -0
  45. package/dist/commands/writeReceipt.js +12 -0
  46. package/dist/config/serviceEndpoints.d.ts +2 -0
  47. package/dist/config/serviceEndpoints.js +2 -0
  48. package/dist/daemon/client.d.ts +2 -0
  49. package/dist/daemon/client.js +53 -3
  50. package/dist/daemon/connectivity.d.ts +20 -0
  51. package/dist/daemon/connectivity.js +42 -0
  52. package/dist/daemon/ensure.js +48 -12
  53. package/dist/daemon/forward.d.ts +37 -0
  54. package/dist/daemon/forward.js +155 -0
  55. package/dist/daemon/fresh.d.ts +4 -0
  56. package/dist/daemon/fresh.js +10 -0
  57. package/dist/daemon/index.d.ts +11 -6
  58. package/dist/daemon/index.js +90 -25
  59. package/dist/daemon/launch.d.ts +13 -1
  60. package/dist/daemon/launch.js +31 -2
  61. package/dist/daemon/lifecycle.d.ts +3 -1
  62. package/dist/daemon/lifecycle.js +27 -3
  63. package/dist/daemon/pid.d.ts +8 -0
  64. package/dist/daemon/pid.js +20 -0
  65. package/dist/daemon/route.js +2 -1
  66. package/dist/daemon/server.d.ts +45 -1
  67. package/dist/daemon/server.js +192 -4
  68. package/dist/daemon/supervise.d.ts +6 -6
  69. package/dist/daemon/supervise.js +12 -10
  70. package/dist/daemon/supervisor.d.ts +21 -5
  71. package/dist/daemon/supervisor.js +125 -6
  72. package/dist/daemon/types.d.ts +22 -0
  73. package/dist/dinox.js +43 -64
  74. package/dist/gateway/deviceHost.js +2 -1
  75. package/dist/gateway/mcpCommandTools.d.ts +24 -5
  76. package/dist/gateway/mcpCommandTools.js +70 -29
  77. package/dist/gateway/mcpEnv.d.ts +2 -0
  78. package/dist/gateway/mcpEnv.js +2 -0
  79. package/dist/gateway/mcpGroups.d.ts +3 -0
  80. package/dist/gateway/mcpGroups.js +4 -0
  81. package/dist/gateway/mcpServer.d.ts +10 -1
  82. package/dist/gateway/mcpServer.js +25 -5
  83. package/dist/powersync/runtime.d.ts +2 -0
  84. package/dist/powersync/runtime.js +3 -1
  85. package/dist/powersync/syncPass.js +13 -1
  86. package/dist/powersync/tokenIndex.d.ts +2 -0
  87. package/dist/powersync/tokenIndex.js +1 -0
  88. package/dist/requests/execute.d.ts +11 -0
  89. package/dist/requests/execute.js +26 -0
  90. package/dist/requests/store.d.ts +10 -0
  91. package/dist/requests/store.js +190 -0
  92. package/dist/runCli.d.ts +2 -0
  93. package/dist/runCli.js +64 -0
  94. package/dist/skills/commandRefs.js +2 -10
  95. package/dist/utils/argValue.js +2 -1
  96. package/dist/utils/cursor.d.ts +9 -0
  97. package/dist/utils/cursor.js +61 -0
  98. package/dist/utils/defaultFormat.d.ts +8 -0
  99. package/dist/utils/defaultFormat.js +38 -0
  100. package/dist/utils/errorCodes.d.ts +24 -0
  101. package/dist/utils/errorCodes.js +6 -0
  102. package/dist/utils/output.d.ts +4 -0
  103. package/dist/utils/output.js +72 -10
  104. package/dist/utils/progress.d.ts +25 -0
  105. package/dist/utils/progress.js +59 -0
  106. package/dist/utils/requestContext.d.ts +32 -0
  107. package/dist/utils/requestContext.js +13 -0
  108. package/dist/utils/select.d.ts +20 -0
  109. package/dist/utils/select.js +92 -0
  110. package/dist/utils/usageError.d.ts +37 -0
  111. package/dist/utils/usageError.js +127 -0
  112. package/package.json +2 -1
@@ -5,6 +5,7 @@ import { join } from 'node:path';
5
5
  import { chmodSync, existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync, } from 'node:fs';
6
6
  import { getDataDir } from '../config/paths.js';
7
7
  import { acquireExclusiveSqliteLease } from '../utils/sqliteLease.js';
8
+ import { SUPERVISOR_STOP_MARKER } from './supervisor.js';
8
9
  const DAEMON_START_LOCK_TIMEOUT_MS = 10_000;
9
10
  const LEGACY_DAEMON_UPTIME_TOLERANCE_MS = 60_000;
10
11
  const execFileAsync = promisify(execFile);
@@ -190,3 +191,22 @@ export async function acquireDaemonStartLock(timeoutMs = DAEMON_START_LOCK_TIMEO
190
191
  createBusyError: () => new Error('Timed out waiting for daemon start lock'),
191
192
  });
192
193
  }
194
+ /** Present while `dino daemon stop` wants the Windows supervisor to stay down. */
195
+ export function getSupervisorStopMarkerPath() {
196
+ return join(getDaemonDir(), SUPERVISOR_STOP_MARKER);
197
+ }
198
+ /**
199
+ * Held by a command that needs the PowerSync database in-process, from before
200
+ * it stops the daemon until it has closed the database. A daemon the service
201
+ * manager respawns in between waits for it (see waitForDaemonExclusiveIntent).
202
+ */
203
+ export async function acquireDaemonExclusiveIntentLock(timeoutMs = 30_000) {
204
+ ensureDaemonDir();
205
+ return acquireExclusiveSqliteLease({
206
+ lockPath: join(getDaemonDir(), 'daemon.exclusive.lock'),
207
+ timeoutMs,
208
+ maxWaitMs: timeoutMs,
209
+ pollIntervalMs: 50,
210
+ createBusyError: () => Object.assign(new Error('Timed out waiting for daemon exclusive intent lock'), { code: 'DAEMON_EXCLUSIVE_BUSY' }),
211
+ });
212
+ }
@@ -1,6 +1,7 @@
1
1
  import { sendDaemonRequest } from './client.js';
2
2
  import { ensureDaemonRunning } from './ensure.js';
3
3
  import { DinoxError } from '../utils/errors.js';
4
+ import { localCommandExecution, requestContext } from '../utils/requestContext.js';
4
5
  /**
5
6
  * Default online reads use the long-lived daemon as the canonical DB owner. The
6
7
  * process-local path is only for explicit modes that require opening the cache here:
@@ -17,7 +18,7 @@ export function shouldUseDaemon(globals) {
17
18
  * receives a thin sender bound to the daemon; `localRun` opens its own db/sync path.
18
19
  */
19
20
  export async function routeRead(globals, daemonMethod, daemonParams, localRun) {
20
- if (!shouldUseDaemon(globals)) {
21
+ if (requestContext.getStore()?.daemon || localCommandExecution.getStore() || !shouldUseDaemon(globals)) {
21
22
  return localRun();
22
23
  }
23
24
  try {
@@ -1,5 +1,14 @@
1
+ import type { AbstractPowerSyncDatabase } from '@powersync/common';
1
2
  import type { DaemonRequest, DaemonResponse } from './types.js';
3
+ import { type ConnectivityChange } from './connectivity.js';
4
+ import type { ResolvedDinoxConfig } from '../config/types.js';
5
+ import { connectPowerSync } from '../powersync/runtime.js';
6
+ import { acquireDaemonExclusiveIntentLock } from './pid.js';
7
+ /** EX_TEMPFAIL: the service manager restarts the daemon. */
8
+ export declare const DAEMON_RESTART_EXIT_CODE = 75;
2
9
  export declare class DaemonServer {
10
+ private readonly options;
11
+ private config;
3
12
  private db;
4
13
  private server;
5
14
  private startTime;
@@ -12,11 +21,46 @@ export declare class DaemonServer {
12
21
  private ownsArtifacts;
13
22
  private releaseOwner;
14
23
  private stopPromise;
24
+ private healthTimer;
25
+ private connectivityTimer;
26
+ private reconnect;
27
+ private reconnecting;
28
+ private syncUnconfirmedSince;
15
29
  private gateways;
30
+ constructor(options?: {
31
+ runtime?: {
32
+ db: AbstractPowerSyncDatabase;
33
+ config: ResolvedDinoxConfig;
34
+ };
35
+ acquireIntentLock?: typeof acquireDaemonExclusiveIntentLock;
36
+ connect?: typeof connectPowerSync;
37
+ onStopRequested?: (reason: string, details?: Record<string, unknown>) => void | Promise<void>;
38
+ });
16
39
  initialize(): Promise<void>;
17
- handleRequest(req: DaemonRequest): Promise<DaemonResponse>;
40
+ handleRequest(req: DaemonRequest, onProgress?: (text: string) => void): Promise<DaemonResponse>;
18
41
  private isCacheStale;
19
42
  start(port: number): Promise<void>;
20
43
  stop(): Promise<void>;
44
+ /**
45
+ * PowerSync reconnects on its own; a connection that stays failed for this
46
+ * long usually needs a fresh process (for example a rotated credential), so
47
+ * exit for the service manager to restart it.
48
+ */
49
+ private startHealthCheck;
50
+ private startConnectivityWatch;
51
+ /**
52
+ * After a wake from sleep or a network change the stream may be a dead
53
+ * connection that still reports "connected". Reconnect, and treat the cache
54
+ * as unconfirmed until a checkpoint completes on the new stream.
55
+ */
56
+ handleConnectivityChange(change: ConnectivityChange): Promise<void>;
57
+ /** The reconnect time while no checkpoint has completed since; null once confirmed. */
58
+ currentSyncUnconfirmedSince(): string | null;
21
59
  private stopInternal;
22
60
  }
61
+ /**
62
+ * Blocks until no exclusive command holds the intent lock. A respawned
63
+ * daemon must not take the database from the command that just stopped it;
64
+ * the lease is released by the OS if that command dies.
65
+ */
66
+ export declare function waitForDaemonExclusiveIntent(acquire?: typeof acquireDaemonExclusiveIntentLock): Promise<void>;
@@ -2,6 +2,8 @@ import crypto from 'node:crypto';
2
2
  import { createServer } from 'http';
3
3
  import { chmodSync, existsSync } from 'node:fs';
4
4
  import path from 'node:path';
5
+ import { CONNECTIVITY_CHECK_INTERVAL_MS, createConnectivityWatcher } from './connectivity.js';
6
+ import { commandExecutionTarget } from './forward.js';
5
7
  import { DAEMON_SERVICE_ENV } from './supervisor.js';
6
8
  import { writeDaemonLogLine } from './log.js';
7
9
  import { loadConfig } from '../config/store.js';
@@ -12,14 +14,21 @@ import { executeNoteSearch } from '../commands/notes/repo.js';
12
14
  import { getNoteDetail } from '../commands/notes/repo/read.js';
13
15
  import { getBacklinks, getGraphStats, getOutlinks, getRelatedNotes } from '../commands/graph/repo.js';
14
16
  import { executeTodoSearch } from '../commands/todo/repo.js';
15
- import { ensureDaemonDir, getDaemonSocketPath, isProcessRunning, readPid, removePid, savePid, } from './pid.js';
17
+ import { acquireDaemonExclusiveIntentLock, ensureDaemonDir, getDaemonSocketPath, isProcessRunning, readPid, removePid, savePid, } from './pid.js';
16
18
  import { getPackageVersion } from '../utils/version.js';
17
19
  import { syncNoteTokenIndex } from '../powersync/tokenIndex.js';
18
20
  import { buildStructuredErrorPayload, DinoxError } from '../utils/errors.js';
21
+ import { requestContext } from '../utils/requestContext.js';
22
+ import { runWithRequestId } from '../requests/store.js';
19
23
  import { getDaemonInfo } from './client.js';
20
24
  import { GatewayService } from '../gateway/service.js';
21
25
  import { readConversation } from '../gateway/conversationContext.js';
22
26
  const MAX_DAEMON_REQUEST_BYTES = 1 * 1024 * 1024;
27
+ const DAEMON_HEALTH_CHECK_INTERVAL_MS = 60_000;
28
+ const DAEMON_STUCK_SYNC_RESTART_MS = 15 * 60_000;
29
+ const DAEMON_INTENT_WAIT_CHUNK_MS = 30_000;
30
+ /** EX_TEMPFAIL: the service manager restarts the daemon. */
31
+ export const DAEMON_RESTART_EXIT_CODE = 75;
23
32
  function daemonSupervisor() {
24
33
  const value = process.env[DAEMON_SERVICE_ENV];
25
34
  return value === 'launchd' || value === 'systemd' || value === 'schtasks' ? value : null;
@@ -65,7 +74,12 @@ function validateLocalRequest(req) {
65
74
  }
66
75
  return { ok: true };
67
76
  }
77
+ function isStreamedCommand(request) {
78
+ return request?.method === 'command.execute' && request.params?.stream === true;
79
+ }
68
80
  export class DaemonServer {
81
+ options;
82
+ config = null;
69
83
  db = null;
70
84
  server = null;
71
85
  startTime = Date.now();
@@ -78,14 +92,30 @@ export class DaemonServer {
78
92
  ownsArtifacts = false;
79
93
  releaseOwner = null;
80
94
  stopPromise = null;
95
+ healthTimer = null;
96
+ connectivityTimer = null;
97
+ reconnect = null;
98
+ reconnecting = false;
99
+ syncUnconfirmedSince = null;
81
100
  gateways = null;
101
+ constructor(options = {}) {
102
+ this.options = options;
103
+ if (options.runtime) {
104
+ this.db = options.runtime.db;
105
+ this.config = options.runtime.config;
106
+ }
107
+ }
82
108
  async initialize() {
109
+ // Before reading config: an exclusive command (e.g. `auth login`) may be
110
+ // changing the saved identity while this respawned daemon waits.
111
+ await waitForDaemonExclusiveIntent(this.options.acquireIntentLock);
83
112
  const baseConfig = resolveConfig(await loadConfig());
84
113
  const { config } = await hydrateResolvedAuthIdentity(baseConfig, { timeoutMs: baseConfig.sync.timeoutMs });
114
+ this.config = config;
85
115
  this.userId = config.auth.userId ?? null;
86
116
  this.tokenFingerprint = config.auth.tokenFingerprint ?? null;
87
117
  this.runtimeConfigFingerprint = fingerprintDaemonRuntimeConfig(config);
88
- const { db, releaseOwner } = await connectPowerSync({
118
+ const { db, releaseOwner, reconnect } = await (this.options.connect ?? connectPowerSync)({
89
119
  config,
90
120
  offline: false,
91
121
  timeoutMs: config.sync.timeoutMs,
@@ -94,6 +124,13 @@ export class DaemonServer {
94
124
  });
95
125
  this.db = db;
96
126
  this.releaseOwner = releaseOwner;
127
+ this.reconnect = reconnect ?? null;
128
+ if (process.env[DAEMON_SERVICE_ENV]) {
129
+ this.startHealthCheck(db);
130
+ }
131
+ if (this.reconnect) {
132
+ this.startConnectivityWatch();
133
+ }
97
134
  if (config.auth.userId && config.auth.authorization) {
98
135
  // Agent gateways share the daemon's PowerSync connection; a gateway
99
136
  // failure must never take the daemon down.
@@ -110,13 +147,51 @@ export class DaemonServer {
110
147
  // PowerSync keeps the local cache in sync continuously while connected,
111
148
  // so no background polling loop is needed here.
112
149
  }
113
- async handleRequest(req) {
150
+ async handleRequest(req, onProgress) {
114
151
  try {
115
152
  if (!this.db) {
116
153
  throw new Error('Database not initialized');
117
154
  }
118
155
  const { method, params } = req;
119
156
  switch (method) {
157
+ case 'command.execute': {
158
+ const input = params;
159
+ const valid = Boolean(input)
160
+ && Array.isArray(input.argv)
161
+ && input.argv.every((arg) => typeof arg === 'string')
162
+ && typeof input.cwd === 'string'
163
+ && path.isAbsolute(input.cwd)
164
+ && typeof input.clientVersion === 'string'
165
+ && commandExecutionTarget(input.argv) === 'daemon';
166
+ if (!valid) {
167
+ throw new DinoxError('Command cannot run inside the daemon.', { code: 'DAEMON_COMMAND_NOT_FORWARDABLE' });
168
+ }
169
+ if (!this.config)
170
+ throw new Error('Daemon config not initialized');
171
+ const context = {
172
+ stdout: [],
173
+ stderr: [],
174
+ format: null,
175
+ noticeProvider: null,
176
+ cwd: input.cwd,
177
+ ...(onProgress ? { onProgress } : {}),
178
+ daemon: {
179
+ db: this.db,
180
+ config: this.config,
181
+ syncUnconfirmedSince: () => this.currentSyncUnconfirmedSince(),
182
+ },
183
+ };
184
+ const execute = async () => {
185
+ // Load the command tree only when the request is not a replay.
186
+ const { runCli } = await import('../runCli.js');
187
+ const exitCode = await requestContext.run(context, () => runCli(input.argv));
188
+ return { stdout: context.stdout.join(''), stderr: context.stderr.join(''), exitCode };
189
+ };
190
+ const result = input.requestId === undefined
191
+ ? await execute()
192
+ : await runWithRequestId(input.requestId, input.argv, execute);
193
+ return { id: req.id, ok: true, result };
194
+ }
120
195
  case 'ping':
121
196
  return {
122
197
  id: req.id,
@@ -149,7 +224,9 @@ export class DaemonServer {
149
224
  });
150
225
  }
151
226
  const timer = setTimeout(() => {
152
- void this.stop().catch(() => undefined);
227
+ void Promise.resolve().then(() => this.options.onStopRequested
228
+ ? this.options.onStopRequested('shutdown-request', { requester: shutdownParams.requester })
229
+ : this.stop()).catch(() => undefined);
153
230
  }, 10);
154
231
  timer.unref?.();
155
232
  return { id: req.id, ok: true, result: { stopping: true, instanceId: this.instanceId } };
@@ -243,12 +320,24 @@ export class DaemonServer {
243
320
  }
244
321
  try {
245
322
  const daemonReq = await readJsonBody(req);
323
+ if (isStreamedCommand(daemonReq)) {
324
+ res.writeHead(200, { 'Content-Type': 'application/x-ndjson' });
325
+ const daemonRes = await this.handleRequest(daemonReq, (progress) => {
326
+ res.write(`${JSON.stringify({ progress })}\n`);
327
+ });
328
+ res.end(JSON.stringify(daemonRes));
329
+ return;
330
+ }
246
331
  const daemonRes = await this.handleRequest(daemonReq);
247
332
  res.writeHead(200, { 'Content-Type': 'application/json' });
248
333
  res.end(JSON.stringify(daemonRes));
249
334
  }
250
335
  catch (error) {
251
336
  const message = error instanceof Error ? error.message : String(error);
337
+ if (res.headersSent) {
338
+ res.end(JSON.stringify({ ok: false, error: message }));
339
+ return;
340
+ }
252
341
  const statusCode = message.includes('too large') ? 413 : 400;
253
342
  res.writeHead(statusCode, { 'Content-Type': 'application/json' });
254
343
  res.end(JSON.stringify({ ok: false, error: message }));
@@ -318,7 +407,83 @@ export class DaemonServer {
318
407
  }
319
408
  }
320
409
  }
410
+ /**
411
+ * PowerSync reconnects on its own; a connection that stays failed for this
412
+ * long usually needs a fresh process (for example a rotated credential), so
413
+ * exit for the service manager to restart it.
414
+ */
415
+ startHealthCheck(db) {
416
+ let stuckSince = null;
417
+ this.healthTimer = setInterval(() => {
418
+ const status = getStatusSnapshot(db);
419
+ const failing = !status.connected
420
+ && Boolean(status.dataFlowStatus.downloadError || status.dataFlowStatus.uploadError);
421
+ if (!failing) {
422
+ stuckSince = null;
423
+ return;
424
+ }
425
+ stuckSince ??= Date.now();
426
+ if (Date.now() - stuckSince < DAEMON_STUCK_SYNC_RESTART_MS)
427
+ return;
428
+ if (this.healthTimer)
429
+ clearInterval(this.healthTimer);
430
+ this.healthTimer = null;
431
+ void Promise.resolve(this.options.onStopRequested?.('powersync-stuck', { exitCode: DAEMON_RESTART_EXIT_CODE }))
432
+ .catch(() => undefined);
433
+ }, DAEMON_HEALTH_CHECK_INTERVAL_MS);
434
+ this.healthTimer.unref();
435
+ }
436
+ startConnectivityWatch() {
437
+ const watcher = createConnectivityWatcher({
438
+ onChange: (change) => void this.handleConnectivityChange(change),
439
+ });
440
+ this.connectivityTimer = setInterval(() => watcher.tick(), CONNECTIVITY_CHECK_INTERVAL_MS);
441
+ this.connectivityTimer.unref();
442
+ }
443
+ /**
444
+ * After a wake from sleep or a network change the stream may be a dead
445
+ * connection that still reports "connected". Reconnect, and treat the cache
446
+ * as unconfirmed until a checkpoint completes on the new stream.
447
+ */
448
+ async handleConnectivityChange(change) {
449
+ if (!this.reconnect || this.reconnecting)
450
+ return;
451
+ this.reconnecting = true;
452
+ this.syncUnconfirmedSince = new Date().toISOString();
453
+ writeDaemonLogLine('dinox-daemon', 'reconnecting sync stream', { reason: change });
454
+ try {
455
+ await this.reconnect();
456
+ }
457
+ catch (error) {
458
+ writeDaemonLogLine('dinox-daemon', 'reconnect failed', {
459
+ reason: change,
460
+ error: error instanceof Error ? error.message : String(error),
461
+ });
462
+ }
463
+ finally {
464
+ this.reconnecting = false;
465
+ }
466
+ }
467
+ /** The reconnect time while no checkpoint has completed since; null once confirmed. */
468
+ currentSyncUnconfirmedSince() {
469
+ const since = this.syncUnconfirmedSince;
470
+ if (!since || !this.db)
471
+ return since;
472
+ const status = getStatusSnapshot(this.db);
473
+ // PowerSync reports checkpoint times with whole-second precision.
474
+ const sinceMs = Math.floor(Date.parse(since) / 1_000) * 1_000;
475
+ if (!this.reconnecting && status.connected && status.lastSyncedAt && Date.parse(status.lastSyncedAt) >= sinceMs) {
476
+ this.syncUnconfirmedSince = null;
477
+ }
478
+ return this.syncUnconfirmedSince;
479
+ }
321
480
  async stopInternal() {
481
+ if (this.healthTimer)
482
+ clearInterval(this.healthTimer);
483
+ this.healthTimer = null;
484
+ if (this.connectivityTimer)
485
+ clearInterval(this.connectivityTimer);
486
+ this.connectivityTimer = null;
322
487
  const gateways = this.gateways;
323
488
  this.gateways = null;
324
489
  await gateways?.stop().catch(() => undefined);
@@ -353,3 +518,26 @@ export class DaemonServer {
353
518
  }
354
519
  }
355
520
  }
521
+ /**
522
+ * Blocks until no exclusive command holds the intent lock. A respawned
523
+ * daemon must not take the database from the command that just stopped it;
524
+ * the lease is released by the OS if that command dies.
525
+ */
526
+ export async function waitForDaemonExclusiveIntent(acquire = acquireDaemonExclusiveIntentLock) {
527
+ let logged = false;
528
+ for (;;) {
529
+ try {
530
+ const release = await acquire(DAEMON_INTENT_WAIT_CHUNK_MS);
531
+ await release();
532
+ return;
533
+ }
534
+ catch (error) {
535
+ if (error.code !== 'DAEMON_EXCLUSIVE_BUSY')
536
+ throw error;
537
+ if (!logged) {
538
+ writeDaemonLogLine('dinox-daemon', 'waiting for exclusive Dinox command to finish');
539
+ logged = true;
540
+ }
541
+ }
542
+ }
543
+ }
@@ -10,6 +10,7 @@ export type SupervisedExit = {
10
10
  };
11
11
  export type SuperviseDependencies = {
12
12
  startChild: () => ChildProcess;
13
+ isStopRequested: () => boolean;
13
14
  sleep: (ms: number) => Promise<void>;
14
15
  now: () => number;
15
16
  log: (message: string, details?: Record<string, unknown>) => void;
@@ -17,12 +18,11 @@ export type SuperviseDependencies = {
17
18
  onStopRequest: (handler: () => void) => void;
18
19
  };
19
20
  /**
20
- * Keeps one daemon running, the way launchd's KeepAlive.SuccessfulExit=false
21
- * and systemd's Restart=on-failure do: restarts it after a crash with an
22
- * exponential backoff, and returns once it exits cleanly (exit code 0 from
23
- * `dino daemon stop`, an identity restart, or a stop for exclusive DB access)
24
- * or when asked to stop. Used where the service manager cannot restart the
25
- * daemon itself (Windows Task Scheduler).
21
+ * Keeps one daemon running the way launchd's KeepAlive and systemd's
22
+ * Restart=always do: restarts it after every exit, with an exponential
23
+ * backoff for repeated crashes, until `dino daemon stop` leaves the stop
24
+ * marker or the supervisor is signalled. Used where the service manager
25
+ * cannot restart the daemon itself (Windows Task Scheduler).
26
26
  */
27
27
  export declare function superviseDaemon(deps: SuperviseDependencies): Promise<number>;
28
28
  /** Runs the supervisor for this CLI's daemon until it exits cleanly or is signalled. */
@@ -1,5 +1,7 @@
1
1
  import { spawn } from 'node:child_process';
2
+ import { existsSync } from 'node:fs';
2
3
  import { appendDaemonLogLine, closeDaemonLogFd, openDaemonLogForChild } from './log.js';
4
+ import { getSupervisorStopMarkerPath } from './pid.js';
3
5
  /** First restart delay after a crash; doubles per consecutive crash. */
4
6
  export const SUPERVISE_INITIAL_BACKOFF_MS = 1_000;
5
7
  export const SUPERVISE_MAX_BACKOFF_MS = 60_000;
@@ -20,12 +22,11 @@ function waitForExit(child) {
20
22
  });
21
23
  }
22
24
  /**
23
- * Keeps one daemon running, the way launchd's KeepAlive.SuccessfulExit=false
24
- * and systemd's Restart=on-failure do: restarts it after a crash with an
25
- * exponential backoff, and returns once it exits cleanly (exit code 0 from
26
- * `dino daemon stop`, an identity restart, or a stop for exclusive DB access)
27
- * or when asked to stop. Used where the service manager cannot restart the
28
- * daemon itself (Windows Task Scheduler).
25
+ * Keeps one daemon running the way launchd's KeepAlive and systemd's
26
+ * Restart=always do: restarts it after every exit, with an exponential
27
+ * backoff for repeated crashes, until `dino daemon stop` leaves the stop
28
+ * marker or the supervisor is signalled. Used where the service manager
29
+ * cannot restart the daemon itself (Windows Task Scheduler).
29
30
  */
30
31
  export async function superviseDaemon(deps) {
31
32
  let stopping = false;
@@ -37,7 +38,7 @@ export async function superviseDaemon(deps) {
37
38
  wakeSleep?.();
38
39
  });
39
40
  let backoffMs = SUPERVISE_INITIAL_BACKOFF_MS;
40
- while (!stopping) {
41
+ while (!stopping && !deps.isStopRequested()) {
41
42
  const startedAt = deps.now();
42
43
  const child = deps.startChild();
43
44
  current = child;
@@ -46,13 +47,13 @@ export async function superviseDaemon(deps) {
46
47
  current = null;
47
48
  if (stopping)
48
49
  break;
49
- if (exit.code === 0) {
50
- deps.log('daemon exited cleanly; supervisor stopping');
50
+ if (deps.isStopRequested()) {
51
+ deps.log('stop marker found; supervisor stopping');
51
52
  return 0;
52
53
  }
53
54
  if (deps.now() - startedAt >= SUPERVISE_HEALTHY_RUN_MS)
54
55
  backoffMs = SUPERVISE_INITIAL_BACKOFF_MS;
55
- deps.log('daemon crashed; restarting', { code: exit.code, signal: exit.signal, delayMs: backoffMs });
56
+ deps.log('daemon exited; restarting', { code: exit.code, signal: exit.signal, delayMs: backoffMs });
56
57
  await Promise.race([
57
58
  deps.sleep(backoffMs),
58
59
  new Promise((resolve) => {
@@ -73,6 +74,7 @@ export async function superviseCurrentDaemon(port) {
73
74
  // Launched hidden at logon there is no console, so write to the log file directly.
74
75
  const log = (message, details) => appendDaemonLogLine('dinox-supervisor', message, details);
75
76
  return superviseDaemon({
77
+ isStopRequested: () => existsSync(getSupervisorStopMarkerPath()),
76
78
  startChild: () => {
77
79
  const logFd = openDaemonLogForChild();
78
80
  try {
@@ -5,11 +5,11 @@
5
5
  * every gateway goes offline. Under launchd / systemd the daemon belongs to
6
6
  * the login session instead, starts at login, and is restarted after a crash.
7
7
  *
8
- * Restart policy is "on failure" only: a clean exit (`dino daemon stop`, a
9
- * version/account restart, exclusive DB access for `doctor`) leaves it
10
- * stopped until the CLI starts it again through the manager. launchd and
11
- * systemd restart the daemon themselves; on Windows, Task Scheduler only
12
- * starts `dino daemon supervise`, which restarts it (see supervise.ts).
8
+ * Restart policy is "always": launchd KeepAlive, systemd Restart=always, and
9
+ * on Windows `dino daemon supervise` (Task Scheduler only starts it; see
10
+ * supervise.ts) bring the daemon back after any exit, including a clean one
11
+ * for exclusive DB access or a crash. Only `dino daemon stop` / `uninstall`,
12
+ * which go through stop() / uninstall() here, leave it down.
13
13
  */
14
14
  export type DaemonServiceKind = 'launchd' | 'systemd' | 'schtasks';
15
15
  export type DaemonServiceSpec = {
@@ -33,25 +33,41 @@ export type DaemonService = {
33
33
  isInstalled(): boolean;
34
34
  /** True when the installed definition is exactly what this CLI would write. */
35
35
  isCurrent(spec: DaemonServiceSpec): boolean;
36
+ /** The program and arguments the installed definition runs, or null when there is none. */
37
+ installedCommand(): string[] | null;
36
38
  /** Writes the definition and (re)loads it, which starts the daemon. */
37
39
  install(spec: DaemonServiceSpec): Promise<void>;
38
40
  /** Starts the installed daemon when it is not running. */
39
41
  start(): Promise<void>;
42
+ /** Stops the daemon and keeps it stopped until start() (or the next login). */
43
+ stop(): Promise<void>;
44
+ /** Restarts it now, skipping restart throttling. Windows: the caller then stops it through the socket. */
45
+ restart(): Promise<void>;
40
46
  /** Stops the daemon and removes the definition; false when nothing was installed. */
41
47
  uninstall(): Promise<boolean>;
42
48
  };
43
49
  /** Set in the service definition so the daemon can report who supervises it. */
44
50
  export declare const DAEMON_SERVICE_ENV = "DINOX_DAEMON_SUPERVISOR";
51
+ /** Windows: file in the daemon directory telling the supervisor not to restart. */
52
+ export declare const SUPERVISOR_STOP_MARKER = "supervisor.stop";
45
53
  export declare const runServiceCommand: ServiceCommandRunner;
46
54
  /**
47
55
  * One daemon per data directory (it owns that directory's socket), so a
48
56
  * non-default `DINOX_DATA_DIR` gets its own service.
49
57
  */
50
58
  export declare function daemonServiceLabel(env?: NodeJS.ProcessEnv): string;
59
+ /** ProgramArguments of a plist written by renderLaunchdPlist. */
60
+ export declare function parseLaunchdProgramArguments(plist: string): string[] | null;
51
61
  export declare function renderLaunchdPlist(label: string, spec: DaemonServiceSpec): string;
62
+ /** The words of an ExecStart line written by renderSystemdUnit. */
63
+ export declare function parseSystemdExecStart(unit: string): string[] | null;
52
64
  export declare function renderSystemdUnit(spec: DaemonServiceSpec): string;
53
65
  /** Quotes one argument the way CommandLineToArgvW (and so Node) parses it back. */
54
66
  export declare function quoteWindowsArg(value: string): string;
67
+ /** Splits a command line the way the Windows C runtime does (the inverse of quoteWindowsArg). */
68
+ export declare function splitWindowsCommandLine(commandLine: string): string[];
69
+ /** The command line of a launcher written by renderWindowsLauncher. */
70
+ export declare function parseWindowsLauncherCommand(launcher: string): string[] | null;
55
71
  /**
56
72
  * Task Scheduler runs console programs in a visible window, so the task runs
57
73
  * this script with wscript, which sets the daemon environment and starts the