@gmickel/gno 2.4.0 → 2.6.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 (136) hide show
  1. package/README.md +69 -6
  2. package/assets/skill/README.md +5 -1
  3. package/assets/skill/SKILL.md +98 -1
  4. package/assets/skill/cli-reference.md +140 -0
  5. package/assets/skill/examples.md +45 -0
  6. package/assets/skill/mcp-reference.md +62 -0
  7. package/assets/skill/recipes/capture-and-file.md +6 -0
  8. package/assets/skill/recipes/memory-file-decision.md +6 -0
  9. package/assets/skill/recipes/memory-supersede-fact.md +5 -0
  10. package/assets/skill/recipes/session-evidence-lookup.md +98 -0
  11. package/assets/spa-production.json.gz +0 -0
  12. package/browser-extension/artifacts/{gno-browser-clipper-v2.4.0.zip → gno-browser-clipper-v2.6.0.zip} +0 -0
  13. package/browser-extension/artifacts/gno-browser-clipper-v2.6.0.zip.sha256 +1 -0
  14. package/browser-extension/dist/manifest.json +1 -1
  15. package/package.json +2 -1
  16. package/spec/cli.md +358 -24
  17. package/spec/compiled-context.md +68 -0
  18. package/spec/mcp.md +223 -2
  19. package/spec/output-schemas/capture-receipt.schema.json +3 -0
  20. package/spec/output-schemas/compiled-context-check.schema.json +44 -0
  21. package/spec/output-schemas/compiled-context-file.schema.json +165 -0
  22. package/spec/output-schemas/compiled-context-preview.schema.json +142 -0
  23. package/spec/output-schemas/mcp-capture-result.schema.json +3 -0
  24. package/spec/output-schemas/memory-remember.schema.json +8 -2
  25. package/spec/output-schemas/request-status.schema.json +113 -0
  26. package/spec/output-schemas/sessions-automation-run.schema.json +46 -0
  27. package/spec/output-schemas/sessions-discovery.schema.json +38 -0
  28. package/spec/output-schemas/sessions-import-receipt.schema.json +156 -0
  29. package/spec/output-schemas/sessions-status.schema.json +432 -0
  30. package/src/app/compiled-context-files.ts +361 -0
  31. package/src/app/compiled-context.ts +240 -0
  32. package/src/app/context-surface.ts +7 -2
  33. package/src/cli/commands/ask.ts +14 -2
  34. package/src/cli/commands/audit.ts +4 -0
  35. package/src/cli/commands/capture.ts +55 -96
  36. package/src/cli/commands/context-compiled.ts +136 -0
  37. package/src/cli/commands/daemon.ts +41 -0
  38. package/src/cli/commands/ls.ts +3 -0
  39. package/src/cli/commands/memory.ts +12 -3
  40. package/src/cli/commands/request-status.ts +59 -0
  41. package/src/cli/commands/reset.ts +39 -5
  42. package/src/cli/commands/sessions.ts +713 -0
  43. package/src/cli/commands/shared.ts +14 -1
  44. package/src/cli/errors.ts +10 -3
  45. package/src/cli/program.ts +458 -1
  46. package/src/cli/session-binding.ts +49 -0
  47. package/src/config/types.ts +8 -0
  48. package/src/core/capture-publish.ts +239 -0
  49. package/src/core/capture-sync.ts +3 -0
  50. package/src/core/compiled-context.ts +254 -0
  51. package/src/core/context-budget.ts +2 -10
  52. package/src/core/file-lock.ts +22 -5
  53. package/src/core/folder-setup-planning.ts +2 -1
  54. package/src/core/memory-remember.ts +233 -122
  55. package/src/core/memory-types.ts +11 -0
  56. package/src/core/network-boundary-inventory.ts +16 -0
  57. package/src/core/request-receipts.ts +671 -0
  58. package/src/core/setup-receipt.ts +27 -20
  59. package/src/core/typed-metadata.ts +4 -0
  60. package/src/core/validation.ts +9 -2
  61. package/src/core/windows-private-path.ts +96 -0
  62. package/src/index.ts +11 -2
  63. package/src/ingestion/compiled-context.ts +15 -0
  64. package/src/ingestion/sync.ts +40 -8
  65. package/src/ingestion/walker.ts +5 -4
  66. package/src/llm/nodeLlamaCpp/simulator-install.ts +6 -2
  67. package/src/mcp/context.ts +8 -0
  68. package/src/mcp/http-egress.ts +15 -3
  69. package/src/mcp/http-transport.ts +2 -0
  70. package/src/mcp/retrieval-warnings.ts +24 -0
  71. package/src/mcp/tools/ask.ts +14 -1
  72. package/src/mcp/tools/capture.ts +87 -83
  73. package/src/mcp/tools/context.ts +46 -2
  74. package/src/mcp/tools/index.ts +119 -7
  75. package/src/mcp/tools/memory-remember.ts +7 -0
  76. package/src/mcp/tools/memory-shared.ts +7 -1
  77. package/src/mcp/tools/query.ts +3 -1
  78. package/src/mcp/tools/request-status.ts +73 -0
  79. package/src/mcp/tools/search.ts +3 -1
  80. package/src/mcp/tools/sessions.ts +208 -0
  81. package/src/mcp/tools/vsearch.ts +3 -1
  82. package/src/sdk/client.ts +252 -89
  83. package/src/sdk/index.ts +13 -0
  84. package/src/sdk/types.ts +82 -3
  85. package/src/serve/capture-service.ts +98 -32
  86. package/src/serve/compiled-context.ts +84 -0
  87. package/src/serve/config-sync.ts +3 -2
  88. package/src/serve/public/app.tsx +15 -1
  89. package/src/serve/public/components/CaptureModal.tsx +26 -8
  90. package/src/serve/public/components/sessions/AutomationPanel.tsx +800 -0
  91. package/src/serve/public/components/sessions/ImportReceipt.tsx +238 -0
  92. package/src/serve/public/components/sessions/SessionSearch.tsx +286 -0
  93. package/src/serve/public/components/sessions/SourcesPanel.tsx +541 -0
  94. package/src/serve/public/components/sessions/api.ts +40 -0
  95. package/src/serve/public/components/sessions/snippet.tsx +53 -0
  96. package/src/serve/public/globals.built.css +1 -1
  97. package/src/serve/public/hooks/use-api.ts +10 -2
  98. package/src/serve/public/lib/request-intent.ts +69 -0
  99. package/src/serve/public/lib/workspace-actions.ts +12 -1
  100. package/src/serve/public/lib/workspace-tabs.ts +4 -0
  101. package/src/serve/public/pages/CompiledContext.tsx +364 -0
  102. package/src/serve/public/pages/Dashboard.tsx +17 -0
  103. package/src/serve/public/pages/DocView.tsx +15 -1
  104. package/src/serve/public/pages/DocumentEditor.tsx +139 -96
  105. package/src/serve/public/pages/Sessions.tsx +350 -0
  106. package/src/serve/resident-runtime.ts +43 -3
  107. package/src/serve/routes/api.ts +476 -147
  108. package/src/serve/routes/sessions.ts +766 -0
  109. package/src/serve/security.ts +9 -0
  110. package/src/serve/server.ts +248 -3
  111. package/src/serve/session-automation.ts +146 -0
  112. package/src/serve/spa-production-build.ts +6 -5
  113. package/src/sessions/archive.ts +348 -0
  114. package/src/sessions/automation-state.ts +444 -0
  115. package/src/sessions/automation-status.ts +239 -0
  116. package/src/sessions/automation.ts +1169 -0
  117. package/src/sessions/binding.ts +105 -0
  118. package/src/sessions/claude-hook.ts +240 -0
  119. package/src/sessions/config.ts +176 -0
  120. package/src/sessions/format.ts +191 -0
  121. package/src/sessions/import-child-env.ts +8 -0
  122. package/src/sessions/import-child.ts +152 -0
  123. package/src/sessions/parsers/claude-code.ts +259 -0
  124. package/src/sessions/parsers/codex.ts +303 -0
  125. package/src/sessions/parsers/hermes.ts +248 -0
  126. package/src/sessions/parsers/openclaw.ts +496 -0
  127. package/src/sessions/parsers/shared.ts +184 -0
  128. package/src/sessions/sanitize.ts +222 -0
  129. package/src/sessions/service.ts +1533 -0
  130. package/src/sessions/setup.ts +477 -0
  131. package/src/sessions/sources.ts +518 -0
  132. package/src/sessions/state.ts +118 -0
  133. package/src/sessions/types.ts +457 -0
  134. package/src/store/sqlite/adapter.ts +68 -16
  135. package/src/store/sqlite/scoped-index.ts +9 -0
  136. package/browser-extension/artifacts/gno-browser-clipper-v2.4.0.zip.sha256 +0 -1
@@ -0,0 +1,1169 @@
1
+ /**
2
+ * Opt-in session automation: owner profile management, host-hook admission,
3
+ * and the one run path shared by daemon drains and explicit run-now.
4
+ *
5
+ * Hooks and schedule ticks only mark a profile pending (see
6
+ * automation-state); the import itself is always the manual importer
7
+ * (SessionsService.import) over the profile's registered sources, so
8
+ * parsing, redaction, routing and checkpoints have one implementation.
9
+ * Nothing here enables itself: every trigger is switched on by an explicit
10
+ * owner call, and every run rechecks the owner config first.
11
+ *
12
+ * @module src/sessions/automation
13
+ */
14
+
15
+ // node:fs/promises mkdir/stat: directory structure ops without Bun equivalents.
16
+ import { mkdir, stat } from "node:fs/promises";
17
+ // node:path: no Bun path utilities.
18
+ import { isAbsolute, join, resolve } from "node:path";
19
+
20
+ import type { Config } from "../config/types";
21
+ import type { SqliteAdapter } from "../store/sqlite/adapter";
22
+ import type { SessionAutomationProfile, SessionsConfig } from "./config";
23
+ import type { SessionsServiceDeps } from "./service";
24
+
25
+ import { getIndexDbPath } from "../app/constants";
26
+ import { loadConfig } from "../config";
27
+ import { applyConfigFileChange } from "../core/config-mutation";
28
+ import { acquireWriteLock } from "../core/file-lock";
29
+ import { SESSION_STATE_DIRNAME } from "./archive";
30
+ import {
31
+ admit,
32
+ type AutomationState,
33
+ beginRun,
34
+ canStart,
35
+ classifyRunError,
36
+ clearPending,
37
+ finishRun,
38
+ HOOK_ADMISSION_DEADLINE_MS,
39
+ isPending,
40
+ isProcessAlive,
41
+ isRunLive,
42
+ loadAutomationState,
43
+ mutateAutomationState,
44
+ ownProfile,
45
+ ownsRun,
46
+ profileState,
47
+ recoverInterrupted,
48
+ type RunFailureClass,
49
+ scheduleTick,
50
+ type StartedRun,
51
+ type StateWriter,
52
+ unblock,
53
+ } from "./automation-state";
54
+ import {
55
+ automationCadenceMs,
56
+ daemonState,
57
+ profileCollections,
58
+ profileLimit,
59
+ profileRetries,
60
+ scheduleRunnable,
61
+ } from "./automation-status";
62
+ import { canonicalConfigPath, assertSessionBinding } from "./binding";
63
+ import {
64
+ buildClaudeHookCommand,
65
+ defaultClaudeSettingsPath,
66
+ type HookIdentity,
67
+ inspectClaudeHook,
68
+ installClaudeHook,
69
+ removeClaudeHook,
70
+ } from "./claude-hook";
71
+ import {
72
+ SESSION_HOOK_HARNESSES,
73
+ SessionAutomationProfileSchema,
74
+ type SessionHookHarness,
75
+ } from "./config";
76
+ import { importInChildProcess } from "./import-child";
77
+ import { SessionsService } from "./service";
78
+ import { canonicalPath } from "./sources";
79
+ import { importLockPath } from "./state";
80
+ import {
81
+ type SessionAutomationRunResult,
82
+ type SessionImportReceipt,
83
+ type SessionRunRecord,
84
+ SessionsError,
85
+ type SessionTriggerKind,
86
+ } from "./types";
87
+
88
+ export interface AutomationContext {
89
+ /** Archive config path (the config the hook and daemon are bound to). */
90
+ configPath: string;
91
+ indexName: string;
92
+ env?: NodeJS.ProcessEnv;
93
+ now?: () => Date;
94
+ }
95
+
96
+ export interface SessionAutomationPreview {
97
+ schemaVersion: "1";
98
+ profileId: string;
99
+ configPath: string;
100
+ index: string;
101
+ archiveRoot: string;
102
+ sources: Array<{
103
+ id: string;
104
+ harness: string | null;
105
+ path: string | null;
106
+ available: boolean;
107
+ collection: string | null;
108
+ projects: Array<{ prefix: string; collection: string }>;
109
+ }>;
110
+ collections: string[];
111
+ hook: {
112
+ harness: SessionHookHarness;
113
+ enabled: boolean;
114
+ settings: string;
115
+ command: string;
116
+ installed: boolean | null;
117
+ };
118
+ schedule: { enabled: boolean; cadence: string | null; minimum: "1m" };
119
+ limit: number;
120
+ retries: number;
121
+ daemon: { state: "running" | "not_running" | "stale"; command: string };
122
+ notes: string[];
123
+ }
124
+
125
+ export interface SessionAutomationChange {
126
+ schemaVersion: "1";
127
+ profileId: string;
128
+ hook: { enabled: boolean; entriesRemoved: number } | null;
129
+ schedule: { enabled: boolean } | null;
130
+ /** Admitted work that was cleared because its trigger was switched off. */
131
+ pendingCleared: boolean;
132
+ /** A run already in progress finishes (bounded by the limit); nothing new starts. */
133
+ running: { startedAt: string } | null;
134
+ removed: boolean;
135
+ warnings: string[];
136
+ }
137
+
138
+ const nowOf = (ctx: AutomationContext): Date => ctx.now?.() ?? new Date();
139
+
140
+ /**
141
+ * SQLite writer contention (in this process or reported by an import child)
142
+ * is a busy archive, retried with backoff, not a runtime error.
143
+ */
144
+ function isIndexLockContention(error: unknown): boolean {
145
+ if (typeof error !== "object" || error === null) return false;
146
+ const { code, message, cause } = error as {
147
+ code?: unknown;
148
+ message?: unknown;
149
+ cause?: unknown;
150
+ };
151
+ if (code === "SQLITE_BUSY" || code === "SQLITE_LOCKED") return true;
152
+ if (
153
+ typeof message === "string" &&
154
+ /database is locked|SQLITE_BUSY/i.test(message)
155
+ ) {
156
+ return true;
157
+ }
158
+ return cause !== undefined && cause !== error && isIndexLockContention(cause);
159
+ }
160
+
161
+ async function loadArchive(
162
+ ctx: AutomationContext
163
+ ): Promise<{ config: Config; sessions: SessionsConfig }> {
164
+ const loaded = await loadConfig(ctx.configPath);
165
+ if (!loaded.ok) {
166
+ throw new SessionsError(
167
+ "SESSIONS_NOT_CONFIGURED",
168
+ "The session archive config could not be loaded."
169
+ );
170
+ }
171
+ if (!loaded.value.sessions) {
172
+ throw new SessionsError(
173
+ "SESSIONS_NOT_CONFIGURED",
174
+ "No session archive is configured for this config."
175
+ );
176
+ }
177
+ await assertSessionBinding({
178
+ config: loaded.value,
179
+ configPath: ctx.configPath,
180
+ indexName: ctx.indexName,
181
+ dbPath: getIndexDbPath(ctx.indexName),
182
+ });
183
+ return { config: loaded.value, sessions: loaded.value.sessions };
184
+ }
185
+
186
+ const unknownProfile = (id: string): SessionsError =>
187
+ new SessionsError(
188
+ "SESSIONS_UNKNOWN_PROFILE",
189
+ `Unknown automation profile "${id}".`
190
+ );
191
+
192
+ function findProfile(
193
+ sessions: SessionsConfig,
194
+ id: string
195
+ ): SessionAutomationProfile {
196
+ const profile = sessions.automation?.find((item) => item.id === id);
197
+ if (!profile) {
198
+ throw new SessionsError(
199
+ "SESSIONS_UNKNOWN_PROFILE",
200
+ `Unknown automation profile "${id}". Configured: ${(sessions.automation ?? []).map((item) => item.id).join(", ") || "(none)"}.`
201
+ );
202
+ }
203
+ return profile;
204
+ }
205
+
206
+ async function hookIdentity(
207
+ ctx: AutomationContext,
208
+ profileId: string
209
+ ): Promise<HookIdentity> {
210
+ return {
211
+ configPath: await canonicalConfigPath(ctx.configPath),
212
+ indexName: ctx.indexName,
213
+ profileId,
214
+ };
215
+ }
216
+
217
+ function validCadence(cadence: string): string {
218
+ if (automationCadenceMs(cadence) === null) {
219
+ throw new SessionsError(
220
+ "SESSIONS_INVALID_INPUT",
221
+ `Cadence "${cadence}" is invalid: use <n>s|m|h|d between 1m and 30d (elapsed time, e.g. 30m or 6h).`
222
+ );
223
+ }
224
+ return cadence;
225
+ }
226
+
227
+ /** Create the state directory under an existing archive root. */
228
+ async function ensureStateDir(archiveRoot: string): Promise<void> {
229
+ const root = await stat(archiveRoot).catch(() => null);
230
+ if (!root?.isDirectory()) {
231
+ throw new SessionsError(
232
+ "SESSIONS_SOURCE_UNAVAILABLE",
233
+ "The session archive destination is missing; recreate it with gno sessions init."
234
+ );
235
+ }
236
+ await mkdir(join(archiveRoot, SESSION_STATE_DIRNAME), { recursive: true });
237
+ }
238
+
239
+ async function stateDirExists(archiveRoot: string): Promise<boolean> {
240
+ const dir = await stat(join(archiveRoot, SESSION_STATE_DIRNAME)).catch(
241
+ () => null
242
+ );
243
+ return dir?.isDirectory() === true;
244
+ }
245
+
246
+ type ProfileEdit = (
247
+ profile: SessionAutomationProfile | undefined,
248
+ sessions: SessionsConfig
249
+ ) => SessionAutomationProfile | null;
250
+
251
+ /** Apply one profile change to the owner config under the config lock. */
252
+ async function editProfile(
253
+ ctx: AutomationContext,
254
+ id: string,
255
+ edit: ProfileEdit
256
+ ): Promise<SessionsConfig> {
257
+ const result = await applyConfigFileChange(
258
+ { configPath: ctx.configPath },
259
+ (config) => {
260
+ const sessions = config.sessions;
261
+ if (!sessions) {
262
+ return {
263
+ ok: false,
264
+ code: "SESSIONS_NOT_CONFIGURED",
265
+ error: "No session archive is configured for this config.",
266
+ };
267
+ }
268
+ const profiles = sessions.automation ?? [];
269
+ let next: SessionAutomationProfile | null;
270
+ try {
271
+ next = edit(
272
+ profiles.find((item) => item.id === id),
273
+ sessions
274
+ );
275
+ } catch (error) {
276
+ if (!(error instanceof SessionsError)) throw error;
277
+ return { ok: false, code: error.code, error: error.message };
278
+ }
279
+ const others = profiles.filter((item) => item.id !== id);
280
+ const automation = next
281
+ ? profiles.some((item) => item.id === id)
282
+ ? profiles.map((item) => (item.id === id ? next : item))
283
+ : [...profiles, next]
284
+ : others;
285
+ if (automation.length > 16) {
286
+ return {
287
+ ok: false,
288
+ code: "SESSIONS_INVALID_INPUT",
289
+ error: "At most 16 automation profiles per archive.",
290
+ };
291
+ }
292
+ const { automation: _drop, ...rest } = sessions;
293
+ return {
294
+ ok: true,
295
+ config: {
296
+ ...config,
297
+ sessions: automation.length > 0 ? { ...rest, automation } : rest,
298
+ },
299
+ };
300
+ }
301
+ );
302
+ if (!result.ok) {
303
+ const code = (
304
+ result.code.startsWith("SESSIONS_")
305
+ ? result.code
306
+ : "SESSIONS_INVALID_INPUT"
307
+ ) as SessionsError["code"];
308
+ throw new SessionsError(code, result.error);
309
+ }
310
+ return result.config.sessions as SessionsConfig;
311
+ }
312
+
313
+ // ─────────────────────────────────────────────────────────────────────────────
314
+ // Owner management (local surfaces only)
315
+ // ─────────────────────────────────────────────────────────────────────────────
316
+
317
+ export interface SetProfileInput {
318
+ id: string;
319
+ sources: string[];
320
+ cadence?: string;
321
+ limit?: number;
322
+ retries?: number;
323
+ }
324
+
325
+ /**
326
+ * Create or reconfigure a profile. Triggers keep their current state; a new
327
+ * profile starts with every trigger off.
328
+ */
329
+ export async function setAutomationProfile(
330
+ ctx: AutomationContext,
331
+ input: SetProfileInput
332
+ ): Promise<SessionAutomationPreview> {
333
+ if (input.cadence !== undefined) validCadence(input.cadence);
334
+ // Binding first: a mismatched pair must not change the archive config.
335
+ const { sessions: current } = await loadArchive(ctx);
336
+ await editProfile(ctx, input.id, (existing, sessions) => {
337
+ for (const sourceId of input.sources) {
338
+ if (!sessions.sources.some((source) => source.id === sourceId)) {
339
+ throw new SessionsError(
340
+ "SESSIONS_UNKNOWN_SOURCE",
341
+ `Unknown session source "${sourceId}". Register it first with gno sessions source add.`
342
+ );
343
+ }
344
+ }
345
+ const candidate = {
346
+ ...existing,
347
+ id: input.id,
348
+ sources: input.sources,
349
+ ...(input.cadence !== undefined
350
+ ? {
351
+ schedule: {
352
+ enabled: existing?.schedule?.enabled ?? false,
353
+ cadence: input.cadence,
354
+ },
355
+ }
356
+ : {}),
357
+ ...(input.limit !== undefined ? { limit: input.limit } : {}),
358
+ ...(input.retries !== undefined ? { retries: input.retries } : {}),
359
+ };
360
+ const parsed = SessionAutomationProfileSchema.safeParse(candidate);
361
+ if (!parsed.success) {
362
+ throw new SessionsError(
363
+ "SESSIONS_INVALID_INPUT",
364
+ `Invalid automation profile: ${parsed.error.issues.map((issue) => `${issue.path.join(".") || "profile"}: ${issue.message}`).join("; ")}`
365
+ );
366
+ }
367
+ return parsed.data;
368
+ });
369
+ await settleState(ctx, current, input.id, unblock);
370
+ return previewAutomationProfile(ctx, input.id);
371
+ }
372
+
373
+ /** What a profile would run: sources, destinations, triggers, prerequisites. */
374
+ export async function previewAutomationProfile(
375
+ ctx: AutomationContext,
376
+ id: string,
377
+ options: { settings?: string } = {}
378
+ ): Promise<SessionAutomationPreview> {
379
+ const { sessions } = await loadArchive(ctx);
380
+ const profile = findProfile(sessions, id);
381
+ const identity = await hookIdentity(ctx, id);
382
+ const settings =
383
+ options.settings ??
384
+ profile.hook?.settings ??
385
+ defaultClaudeSettingsPath(ctx.env);
386
+ const { state } = await loadAutomationState(sessions.archiveRoot);
387
+ const daemon = daemonState(state.daemon, nowOf(ctx));
388
+ const sources = await Promise.all(
389
+ profile.sources.map(async (sourceId) => {
390
+ const source = sessions.sources.find((item) => item.id === sourceId);
391
+ return {
392
+ id: sourceId,
393
+ harness: source?.harness ?? null,
394
+ path: source?.path ?? null,
395
+ available: source ? (await canonicalPath(source.path)) !== null : false,
396
+ collection: source?.collection ?? null,
397
+ projects: source?.projects ?? [],
398
+ };
399
+ })
400
+ );
401
+ const configPath = identity.configPath;
402
+ const daemonCommand = `gno --config ${configPath} --index ${ctx.indexName} daemon`;
403
+ const notes: string[] = [];
404
+ if (profile.schedule?.enabled && !scheduleRunnable(profile)) {
405
+ notes.push(
406
+ `warning: cadence "${profile.schedule.cadence}" is invalid (use <n>s|m|h|d, 1m to 30d); the schedule is off and does not run until you fix it with sessions automation set ${id} --source … --cadence 30m.`
407
+ );
408
+ }
409
+ if (!profile.hook?.enabled && !profile.schedule?.enabled) {
410
+ notes.push(
411
+ `Nothing runs automatically until you enable a trigger: gno --config ${configPath} --index ${ctx.indexName} sessions automation enable ${id} --hook claude-code and/or --schedule --cadence <n>s|m|h|d.`
412
+ );
413
+ }
414
+ notes.push(
415
+ `Hooks only mark this profile pending; imports run in \`${daemonCommand}\` (currently ${daemon === "running" ? "running" : "not running: no daemon"}) or with \`sessions automation run ${id}\`. GNO never installs or starts a service.`,
416
+ "Schedules use elapsed time and tick only while that daemon runs; `gno serve` never runs them.",
417
+ "Imports use each source's registered collection and project mappings; hooks cannot add sources or change destinations."
418
+ );
419
+ if (sources.some((source) => source.harness === null)) {
420
+ notes.push(
421
+ "A selected source is no longer registered; runs fail until the profile is corrected."
422
+ );
423
+ }
424
+ return {
425
+ schemaVersion: "1",
426
+ profileId: id,
427
+ configPath,
428
+ index: ctx.indexName,
429
+ archiveRoot: sessions.archiveRoot,
430
+ sources,
431
+ collections: profileCollections(sessions, profile),
432
+ hook: {
433
+ harness: "claude-code",
434
+ enabled: profile.hook?.enabled === true,
435
+ settings,
436
+ command: buildClaudeHookCommand(identity),
437
+ installed: await inspectClaudeHook(settings, identity),
438
+ },
439
+ schedule: {
440
+ // Effective state, as in status: an invalid cadence never runs.
441
+ enabled: scheduleRunnable(profile),
442
+ cadence: profile.schedule?.cadence ?? null,
443
+ minimum: "1m",
444
+ },
445
+ limit: profileLimit(profile),
446
+ retries: profileRetries(profile),
447
+ daemon: { state: daemon, command: daemonCommand },
448
+ notes,
449
+ };
450
+ }
451
+
452
+ export interface EnableInput {
453
+ hook?: { harness: string; settings?: string };
454
+ schedule?: { cadence?: string };
455
+ }
456
+
457
+ /** Explicitly switch on a hook and/or the daemon schedule for one profile. */
458
+ export async function enableAutomation(
459
+ ctx: AutomationContext,
460
+ id: string,
461
+ input: EnableInput
462
+ ): Promise<SessionAutomationPreview> {
463
+ if (!input.hook && !input.schedule) {
464
+ throw new SessionsError(
465
+ "SESSIONS_INVALID_INPUT",
466
+ "Choose what to enable: --hook claude-code and/or --schedule --cadence <n>s|m|h|d."
467
+ );
468
+ }
469
+ const { sessions } = await loadArchive(ctx);
470
+ const profile = findProfile(sessions, id);
471
+ let settings: string | undefined;
472
+ if (input.hook) {
473
+ const harness = input.hook.harness;
474
+ if (!(SESSION_HOOK_HARNESSES as readonly string[]).includes(harness)) {
475
+ throw new SessionsError(
476
+ "SESSIONS_UNSUPPORTED_INTEGRATION",
477
+ `No verified hook for "${harness}". Supported: ${SESSION_HOOK_HARNESSES.join(", ")}. Other harnesses import manually or on a daemon schedule.`
478
+ );
479
+ }
480
+ const harnesses = profile.sources.map(
481
+ (sourceId) =>
482
+ sessions.sources.find((source) => source.id === sourceId)?.harness
483
+ );
484
+ if (!harnesses.includes("claude-code")) {
485
+ throw new SessionsError(
486
+ "SESSIONS_INVALID_INPUT",
487
+ "The Claude Code hook needs a registered claude-code source in the profile."
488
+ );
489
+ }
490
+ settings =
491
+ input.hook.settings ??
492
+ profile.hook?.settings ??
493
+ defaultClaudeSettingsPath(ctx.env);
494
+ if (!isAbsolute(settings)) {
495
+ throw new SessionsError(
496
+ "SESSIONS_INVALID_INPUT",
497
+ "The Claude Code settings path must be absolute."
498
+ );
499
+ }
500
+ settings = resolve(settings);
501
+ // Only the documented default location may be created; any other file
502
+ // must already exist as a settings file the owner names explicitly.
503
+ const isDefault = settings === resolve(defaultClaudeSettingsPath(ctx.env));
504
+ if (input.hook.settings !== undefined && !isDefault) {
505
+ const file = await stat(settings).catch(() => null);
506
+ if (!file?.isFile()) {
507
+ throw new SessionsError(
508
+ "SESSIONS_INVALID_INPUT",
509
+ "--settings must name an existing Claude Code settings file; only the default settings.json is created when missing."
510
+ );
511
+ }
512
+ }
513
+ }
514
+ let cadence: string | undefined;
515
+ if (input.schedule) {
516
+ const requested = input.schedule.cadence ?? profile.schedule?.cadence;
517
+ if (!requested) {
518
+ throw new SessionsError(
519
+ "SESSIONS_INVALID_INPUT",
520
+ "Scheduling needs an explicit elapsed cadence: --cadence <n>s|m|h|d (at least 1m)."
521
+ );
522
+ }
523
+ cadence = validCadence(requested);
524
+ }
525
+ await ensureStateDir(sessions.archiveRoot);
526
+ const cadenceMs = cadence ? automationCadenceMs(cadence) : null;
527
+ const identity = await hookIdentity(ctx, id);
528
+ // Install, record and state change under the marker lock that remove also
529
+ // holds, so a concurrent remove never leaves an owned entry behind.
530
+ await mutateAutomationState(sessions.archiveRoot, async (state) => {
531
+ const current = findProfile((await loadArchive(ctx)).sessions, id);
532
+ if (settings) {
533
+ const previous = current.hook?.settings;
534
+ if (previous && resolve(previous) !== settings) {
535
+ // Moving to another settings file: remove the old owned entry first
536
+ // (fails closed), so no untracked integration keeps firing.
537
+ await removeClaudeHook(previous, identity);
538
+ }
539
+ // Install before recording: a settings file that cannot be edited
540
+ // leaves the profile unchanged.
541
+ await installClaudeHook(settings, identity);
542
+ }
543
+ await editProfile(ctx, id, (existing) => {
544
+ if (!existing) throw unknownProfile(id);
545
+ return {
546
+ ...existing,
547
+ ...(settings
548
+ ? {
549
+ hook: {
550
+ harness: "claude-code" as const,
551
+ enabled: true,
552
+ settings,
553
+ },
554
+ }
555
+ : {}),
556
+ ...(cadence ? { schedule: { enabled: true, cadence } } : {}),
557
+ };
558
+ });
559
+ // Enabling is an owner action: it clears a block awaiting correction.
560
+ const existing = ownProfile(state, id);
561
+ if (existing) unblock(existing);
562
+ // Like the findings pass: the first scheduled run is one cadence away.
563
+ if (cadenceMs !== null) {
564
+ const run = profileState(state, id);
565
+ run.nextDueAt = new Date(nowOf(ctx).getTime() + cadenceMs).toISOString();
566
+ }
567
+ });
568
+ return previewAutomationProfile(ctx, id);
569
+ }
570
+
571
+ /**
572
+ * Drop admitted work of switched-off trigger kinds; manual run requests and
573
+ * kinds still enabled stay pending.
574
+ */
575
+ function dropTriggers(
576
+ run: ReturnType<typeof profileState>,
577
+ kinds: SessionTriggerKind[]
578
+ ): boolean {
579
+ if (!isPending(run)) return false;
580
+ const remaining = run.pendingTriggers.filter((kind) => !kinds.includes(kind));
581
+ if (remaining.length > 0) {
582
+ run.pendingTriggers = remaining;
583
+ return false;
584
+ }
585
+ clearPending(run);
586
+ return true;
587
+ }
588
+
589
+ /** Pause: switch triggers off, uninstall the owned hook entry, clear pending. */
590
+ export async function disableAutomation(
591
+ ctx: AutomationContext,
592
+ id: string,
593
+ which: { hook?: boolean; schedule?: boolean } = {}
594
+ ): Promise<SessionAutomationChange> {
595
+ const both = which.hook === undefined && which.schedule === undefined;
596
+ const hook = both || which.hook === true;
597
+ const schedule = both || which.schedule === true;
598
+ const { sessions } = await loadArchive(ctx);
599
+ const profile = findProfile(sessions, id);
600
+ // Config first: a hook that fires from now on sees the trigger disabled.
601
+ await editProfile(ctx, id, (existing) => {
602
+ if (!existing) throw unknownProfile(id);
603
+ return {
604
+ ...existing,
605
+ ...(hook && existing.hook
606
+ ? { hook: { ...existing.hook, enabled: false } }
607
+ : {}),
608
+ ...(schedule && existing.schedule
609
+ ? { schedule: { ...existing.schedule, enabled: false } }
610
+ : {}),
611
+ };
612
+ });
613
+ const warnings: string[] = [];
614
+ let entriesRemoved = 0;
615
+ if (hook && profile.hook) {
616
+ try {
617
+ entriesRemoved = (
618
+ await removeClaudeHook(
619
+ profile.hook.settings,
620
+ await hookIdentity(ctx, id)
621
+ )
622
+ ).removed;
623
+ } catch (error) {
624
+ warnings.push(
625
+ `The hook is disabled, but its entry could not be removed from the Claude Code settings: ${(error as Error).message}`
626
+ );
627
+ }
628
+ }
629
+ const settled = await settleState(ctx, sessions, id, (run) => {
630
+ if (schedule) run.nextDueAt = null;
631
+ const kinds: SessionTriggerKind[] = [
632
+ ...(hook ? (["hook"] as const) : []),
633
+ ...(schedule ? (["schedule"] as const) : []),
634
+ ];
635
+ return dropTriggers(run, kinds);
636
+ });
637
+ return {
638
+ schemaVersion: "1",
639
+ profileId: id,
640
+ hook: hook && profile.hook ? { enabled: false, entriesRemoved } : null,
641
+ schedule: schedule && profile.schedule ? { enabled: false } : null,
642
+ pendingCleared: settled.value ?? false,
643
+ running: settled.running,
644
+ removed: false,
645
+ warnings,
646
+ };
647
+ }
648
+
649
+ async function settleState<T>(
650
+ ctx: AutomationContext,
651
+ sessions: SessionsConfig,
652
+ id: string,
653
+ change: (run: ReturnType<typeof profileState>) => T
654
+ ): Promise<{ value: T | null; running: { startedAt: string } | null }> {
655
+ if (!(await stateDirExists(sessions.archiveRoot))) {
656
+ return { value: null, running: null };
657
+ }
658
+ return mutateAutomationState(sessions.archiveRoot, (state) => {
659
+ const run = ownProfile(state, id);
660
+ if (!run) return { value: null, running: null };
661
+ const value = change(run);
662
+ return {
663
+ value,
664
+ running: isRunLive(run.running, nowOf(ctx))
665
+ ? { startedAt: run.running!.startedAt }
666
+ : null,
667
+ };
668
+ });
669
+ }
670
+
671
+ /** Uninstall owned integrations and delete the profile. Archives stay. */
672
+ export async function removeAutomationProfile(
673
+ ctx: AutomationContext,
674
+ id: string
675
+ ): Promise<SessionAutomationChange> {
676
+ const { sessions } = await loadArchive(ctx);
677
+ const profile = findProfile(sessions, id);
678
+ const identity = await hookIdentity(ctx, id);
679
+ // Runs start under the marker lock after rechecking the profile, so doing
680
+ // the whole removal under that lock means a run either started first (and
681
+ // removal refuses, keeping it visible) or never starts.
682
+ const remove = async (
683
+ state: AutomationState | null
684
+ ): Promise<{ entriesRemoved: number; pendingCleared: boolean }> => {
685
+ const run = state ? ownProfile(state, id) : undefined;
686
+ if (isRunLive(run?.running ?? null, nowOf(ctx))) {
687
+ throw new SessionsError(
688
+ "SESSIONS_BUSY",
689
+ `Automation profile "${id}" is running. Pause it with disable (nothing new starts), then remove it once status shows the run finished.`
690
+ );
691
+ }
692
+ // Fails closed: an unreadable settings file keeps the profile, so the
693
+ // owned entry never outlives the profile that can remove it.
694
+ const entriesRemoved = profile.hook
695
+ ? (await removeClaudeHook(profile.hook.settings, identity)).removed
696
+ : 0;
697
+ await editProfile(ctx, id, () => null);
698
+ if (state) delete state.profiles[id];
699
+ return { entriesRemoved, pendingCleared: run ? isPending(run) : false };
700
+ };
701
+ // With the archive gone no run can start (runs need its state directory),
702
+ // so only then is the lock-free path safe.
703
+ const archivePresent = await ensureStateDir(sessions.archiveRoot).then(
704
+ () => true,
705
+ () => false
706
+ );
707
+ const removed = archivePresent
708
+ ? await mutateAutomationState(sessions.archiveRoot, remove)
709
+ : await remove(null);
710
+ return {
711
+ schemaVersion: "1",
712
+ profileId: id,
713
+ hook: profile.hook
714
+ ? { enabled: false, entriesRemoved: removed.entriesRemoved }
715
+ : null,
716
+ schedule: profile.schedule ? { enabled: false } : null,
717
+ pendingCleared: removed.pendingCleared,
718
+ running: null,
719
+ removed: true,
720
+ warnings: [],
721
+ };
722
+ }
723
+
724
+ // ─────────────────────────────────────────────────────────────────────────────
725
+ // Hook admission (foreground, tiny)
726
+ // ─────────────────────────────────────────────────────────────────────────────
727
+
728
+ export type HookAdmission =
729
+ | {
730
+ outcome: "accepted";
731
+ profileId: string;
732
+ daemon: "running" | "not_running" | "stale";
733
+ }
734
+ | { outcome: "skipped"; profileId: string; reason: string };
735
+
736
+ /**
737
+ * Durably mark a profile pending for one host hook event. No parsing, no
738
+ * import, no network: it validates the event, rechecks the owner config under
739
+ * the marker lock, and returns once the marker is on disk. Errors mean the
740
+ * event was not accepted.
741
+ */
742
+ export async function admitHookTrigger(
743
+ ctx: AutomationContext,
744
+ input: {
745
+ harness: SessionHookHarness;
746
+ profileId: string;
747
+ payload: unknown;
748
+ lockWaitMs?: number;
749
+ writeState?: StateWriter;
750
+ }
751
+ ): Promise<HookAdmission> {
752
+ const payload = input.payload;
753
+ if (
754
+ payload !== null &&
755
+ (typeof payload !== "object" ||
756
+ Array.isArray(payload) ||
757
+ ("hook_event_name" in payload &&
758
+ (payload as { hook_event_name: unknown }).hook_event_name !==
759
+ "SessionEnd"))
760
+ ) {
761
+ return {
762
+ outcome: "skipped",
763
+ profileId: input.profileId,
764
+ reason: "unexpected_event",
765
+ };
766
+ }
767
+ const { sessions } = await loadArchive(ctx);
768
+ const configured = sessions.automation?.find(
769
+ (item) => item.id === input.profileId
770
+ );
771
+ if (!configured) {
772
+ return {
773
+ outcome: "skipped",
774
+ profileId: input.profileId,
775
+ reason: "unknown_profile",
776
+ };
777
+ }
778
+ const enabled = configured.hook;
779
+ if (!enabled?.enabled || enabled.harness !== input.harness) {
780
+ // Nothing to admit: the state file is not touched.
781
+ return {
782
+ outcome: "skipped",
783
+ profileId: input.profileId,
784
+ reason: "hook_disabled",
785
+ };
786
+ }
787
+ return mutateAutomationState(
788
+ sessions.archiveRoot,
789
+ async (state): Promise<HookAdmission> => {
790
+ // Reread under the lock so a concurrent disable is always honoured.
791
+ const fresh = await loadArchive(ctx);
792
+ const profile = fresh.sessions.automation?.find(
793
+ (item) => item.id === input.profileId
794
+ );
795
+ if (!profile) {
796
+ return {
797
+ outcome: "skipped",
798
+ profileId: input.profileId,
799
+ reason: "unknown_profile",
800
+ };
801
+ }
802
+ if (!profile.hook?.enabled || profile.hook.harness !== input.harness) {
803
+ return {
804
+ outcome: "skipped",
805
+ profileId: input.profileId,
806
+ reason: "hook_disabled",
807
+ };
808
+ }
809
+ const now = nowOf(ctx);
810
+ admit(
811
+ profileState(state, profile.id),
812
+ "hook",
813
+ now,
814
+ profileRetries(profile)
815
+ );
816
+ return {
817
+ outcome: "accepted",
818
+ profileId: profile.id,
819
+ daemon: daemonState(state.daemon, now),
820
+ };
821
+ },
822
+ {
823
+ lockWaitMs: input.lockWaitMs ?? HOOK_ADMISSION_DEADLINE_MS,
824
+ writeState: input.writeState,
825
+ }
826
+ );
827
+ }
828
+
829
+ // ─────────────────────────────────────────────────────────────────────────────
830
+ // Runs (daemon drain and explicit run-now)
831
+ // ─────────────────────────────────────────────────────────────────────────────
832
+
833
+ export interface AutomationRunDeps extends AutomationContext {
834
+ store: SqliteAdapter;
835
+ syncService?: SessionsServiceDeps["syncService"];
836
+ pid?: number;
837
+ alive?: (pid: number) => boolean;
838
+ /** Daemon drains take the shared write lease around the imports. */
839
+ acquireLease?: () => Promise<
840
+ { ok: true; release: () => Promise<void> } | { ok: false }
841
+ >;
842
+ /**
843
+ * Servers (`serve`, the daemon's HTTP/MCP listener, stdio MCP) import in a
844
+ * child process so a long import never blocks their event loop; the CLI
845
+ * and SDK import in their own process.
846
+ */
847
+ inChildProcess?: boolean;
848
+ }
849
+
850
+ function summarizeRun(
851
+ receipts: SessionImportReceipt[],
852
+ triggers: SessionTriggerKind[],
853
+ startedAt: Date,
854
+ finishedAt: Date,
855
+ failed: { reason: string } | null
856
+ ): SessionRunRecord {
857
+ const sum = (pick: (receipt: SessionImportReceipt) => number): number =>
858
+ receipts.reduce((total, receipt) => total + pick(receipt), 0);
859
+ const record: SessionRunRecord = {
860
+ triggers,
861
+ startedAt: startedAt.toISOString(),
862
+ finishedAt: finishedAt.toISOString(),
863
+ outcome: "complete",
864
+ reason: null,
865
+ threads: {
866
+ imported: sum((r) => r.counts.imported),
867
+ updated: sum((r) => r.counts.updated),
868
+ unchanged: sum((r) => r.counts.unchanged),
869
+ },
870
+ units: {
871
+ incomplete: sum((r) => r.counts.incomplete),
872
+ failed: sum((r) => r.counts.failed + r.counts.unsupported),
873
+ deferred: sum((r) => r.deferredUnits),
874
+ },
875
+ };
876
+ if (failed) return { ...record, outcome: "failed", reason: failed.reason };
877
+ if (receipts.some((receipt) => receipt.status === "failed")) {
878
+ return { ...record, outcome: "failed", reason: "import_failed" };
879
+ }
880
+ if (receipts.some((receipt) => receipt.status === "partial")) {
881
+ let reason = "unit_failures";
882
+ if (receipts.some((receipt) => receipt.lexical.status === "failed")) {
883
+ reason = "index_sync_failed";
884
+ } else if (record.units.deferred > 0) reason = "deferred";
885
+ else if (record.units.incomplete > 0) reason = "incomplete_units";
886
+ return { ...record, outcome: "partial", reason };
887
+ }
888
+ if (receipts.every((receipt) => receipt.status === "nothing_to_do")) {
889
+ return { ...record, outcome: "up_to_date" };
890
+ }
891
+ return record;
892
+ }
893
+
894
+ type Begun =
895
+ | {
896
+ started: StartedRun;
897
+ profile: SessionAutomationProfile;
898
+ config: Config;
899
+ }
900
+ | { started: null; reason: string; pending: boolean };
901
+
902
+ /**
903
+ * Run one profile through the manual importer. `trigger: "manual"` admits an
904
+ * explicit run request first; `null` drains work already pending. Returns
905
+ * `ran: false` when nothing may start (busy, backoff, trigger switched off).
906
+ */
907
+ export async function runAutomationProfile(
908
+ deps: AutomationRunDeps,
909
+ profileId: string,
910
+ options: { trigger: "manual" | null }
911
+ ): Promise<SessionAutomationRunResult> {
912
+ const alive = deps.alive ?? isProcessAlive;
913
+ const pid = deps.pid ?? process.pid;
914
+ const { sessions } = await loadArchive(deps);
915
+ findProfile(sessions, profileId);
916
+ await ensureStateDir(sessions.archiveRoot);
917
+
918
+ const begun = await mutateAutomationState(
919
+ sessions.archiveRoot,
920
+ async (state): Promise<Begun> => {
921
+ // Authority is rechecked from the owner config under the marker lock.
922
+ const fresh = await loadArchive(deps);
923
+ const profile = fresh.sessions.automation?.find(
924
+ (item) => item.id === profileId
925
+ );
926
+ if (!profile) {
927
+ return { started: null, reason: "profile_removed", pending: false };
928
+ }
929
+ const now = nowOf(deps);
930
+ const run = profileState(state, profileId);
931
+ recoverInterrupted(run, now, alive);
932
+ if (options.trigger) {
933
+ admit(run, options.trigger, now, profileRetries(profile));
934
+ }
935
+ const allowed = run.pendingTriggers.filter(
936
+ (kind) =>
937
+ kind === "manual" ||
938
+ (kind === "hook" && profile.hook?.enabled === true) ||
939
+ (kind === "schedule" && scheduleRunnable(profile))
940
+ );
941
+ if (isPending(run) && allowed.length === 0) {
942
+ clearPending(run);
943
+ return { started: null, reason: "trigger_disabled", pending: false };
944
+ }
945
+ run.pendingTriggers = allowed;
946
+ const retries = profileRetries(profile);
947
+ if (!canStart(run, retries, now, alive)) {
948
+ let reason = "nothing_pending";
949
+ if (isRunLive(run.running, now, alive)) reason = "busy";
950
+ else if (isPending(run)) {
951
+ reason = run.attempts > retries ? "retries_exhausted" : "backoff";
952
+ }
953
+ return { started: null, reason, pending: isPending(run) };
954
+ }
955
+ return {
956
+ started: beginRun(run, now, pid),
957
+ profile,
958
+ config: fresh.config,
959
+ };
960
+ }
961
+ );
962
+ if (begun.started === null) {
963
+ return {
964
+ schemaVersion: "1",
965
+ profileId,
966
+ ran: false,
967
+ outcome: "not_started",
968
+ reason: begun.reason,
969
+ pending: begun.pending,
970
+ receipts: [],
971
+ };
972
+ }
973
+
974
+ const { profile, config, started } = begun;
975
+ const startedAt = nowOf(deps);
976
+ const receipts: SessionImportReceipt[] = [];
977
+ let failure: { failure: RunFailureClass; reason: string } | null = null;
978
+ const lease = deps.acquireLease ? await deps.acquireLease() : null;
979
+ if (lease && !lease.ok) {
980
+ failure = { failure: "contention", reason: "busy" };
981
+ } else {
982
+ try {
983
+ // Another import (a CLI import or a server's import child) holds the
984
+ // archive and writes the index in long transactions: report busy
985
+ // before touching the index rather than waiting behind it.
986
+ const probe = await acquireWriteLock(
987
+ importLockPath(sessions.archiveRoot),
988
+ 0
989
+ );
990
+ if (!probe) {
991
+ throw new SessionsError(
992
+ "SESSIONS_BUSY",
993
+ "Another session import is running for this archive."
994
+ );
995
+ }
996
+ await probe.release();
997
+ // Project the current archive collections (added, changed or removed)
998
+ // into the index before importing.
999
+ const synced = await deps.store.syncCollections(config.collections);
1000
+ if (!synced.ok) {
1001
+ throw isIndexLockContention(synced.error)
1002
+ ? new SessionsError("SESSIONS_BUSY", "The archive index is busy.")
1003
+ : new SessionsError(
1004
+ "SESSIONS_RUNTIME_FAILURE",
1005
+ "Archive collections could not be synced into the index."
1006
+ );
1007
+ }
1008
+ const service = new SessionsService({
1009
+ config,
1010
+ configPath: deps.configPath,
1011
+ indexName: deps.indexName,
1012
+ store: deps.store,
1013
+ syncService: deps.syncService,
1014
+ now: deps.now,
1015
+ });
1016
+ const importSource = (sourceId: string) =>
1017
+ deps.inChildProcess
1018
+ ? importInChildProcess({
1019
+ config,
1020
+ configPath: deps.configPath,
1021
+ indexName: deps.indexName,
1022
+ sourceId,
1023
+ dryRun: false,
1024
+ limit: profileLimit(profile),
1025
+ })
1026
+ : service.import(
1027
+ { sourceId, limit: profileLimit(profile) },
1028
+ { allowPaths: false }
1029
+ );
1030
+ for (const sourceId of profile.sources) {
1031
+ if (
1032
+ !config.sessions?.sources.some((source) => source.id === sourceId)
1033
+ ) {
1034
+ throw new SessionsError(
1035
+ "SESSIONS_UNKNOWN_SOURCE",
1036
+ "A profile source is no longer registered."
1037
+ );
1038
+ }
1039
+ receipts.push(await importSource(sourceId));
1040
+ }
1041
+ } catch (error) {
1042
+ failure = classifyRunError(
1043
+ error instanceof SessionsError
1044
+ ? error.code
1045
+ : isIndexLockContention(error)
1046
+ ? "SESSIONS_BUSY"
1047
+ : null
1048
+ );
1049
+ } finally {
1050
+ if (lease?.ok) await lease.release().catch(() => undefined);
1051
+ }
1052
+ }
1053
+ const record = summarizeRun(
1054
+ receipts,
1055
+ started.triggers,
1056
+ startedAt,
1057
+ nowOf(deps),
1058
+ failure
1059
+ );
1060
+ const failureClass: RunFailureClass | undefined =
1061
+ record.outcome === "failed" ? (failure?.failure ?? "permanent") : undefined;
1062
+ const pending = await mutateAutomationState(sessions.archiveRoot, (state) => {
1063
+ const run = ownProfile(state, profileId);
1064
+ // Removed (or removed and recreated) mid-run: this run no longer owns
1065
+ // the record, so it settles nothing and consumes nothing.
1066
+ if (!run || !ownsRun(run, started)) return false;
1067
+ finishRun(run, started, record, {
1068
+ retries: profileRetries(profile),
1069
+ failure: failureClass,
1070
+ now: nowOf(deps),
1071
+ });
1072
+ return isPending(run);
1073
+ });
1074
+ return {
1075
+ schemaVersion: "1",
1076
+ profileId,
1077
+ ran: true,
1078
+ outcome: record.outcome,
1079
+ reason: record.reason,
1080
+ pending,
1081
+ receipts,
1082
+ };
1083
+ }
1084
+
1085
+ /**
1086
+ * One daemon tick: heartbeat, coalesced schedule admissions, recovery of
1087
+ * interrupted runs, then a drain of every profile that may start. Archives
1088
+ * without automation profiles are left untouched.
1089
+ */
1090
+ /**
1091
+ * Daemon heartbeat, written on its own timer so a long import or the
1092
+ * daemon's initial sync never makes a live daemon look absent. Archives
1093
+ * without automation profiles are left untouched.
1094
+ */
1095
+ export async function heartbeatAutomation(
1096
+ deps: AutomationContext & { pid?: number; daemonStartedAt: Date }
1097
+ ): Promise<void> {
1098
+ const { sessions } = await loadArchive(deps);
1099
+ if (
1100
+ (sessions.automation ?? []).length === 0 ||
1101
+ !(await stateDirExists(sessions.archiveRoot))
1102
+ ) {
1103
+ return;
1104
+ }
1105
+ await mutateAutomationState(
1106
+ sessions.archiveRoot,
1107
+ (state) => {
1108
+ state.daemon = {
1109
+ pid: deps.pid ?? process.pid,
1110
+ startedAt: deps.daemonStartedAt.toISOString(),
1111
+ heartbeatAt: nowOf(deps).toISOString(),
1112
+ };
1113
+ },
1114
+ { lockWaitMs: HOOK_ADMISSION_DEADLINE_MS }
1115
+ );
1116
+ }
1117
+
1118
+ export async function tickAutomation(
1119
+ deps: AutomationRunDeps & { daemonStartedAt: Date }
1120
+ ): Promise<SessionAutomationRunResult[]> {
1121
+ const { sessions } = await loadArchive(deps);
1122
+ const profiles = sessions.automation ?? [];
1123
+ if (profiles.length === 0 || !(await stateDirExists(sessions.archiveRoot))) {
1124
+ return [];
1125
+ }
1126
+ const pid = deps.pid ?? process.pid;
1127
+ const alive = deps.alive ?? isProcessAlive;
1128
+ const daemonStart = deps.daemonStartedAt.getTime();
1129
+ const eligible = await mutateAutomationState(
1130
+ sessions.archiveRoot,
1131
+ (state) => {
1132
+ const now = nowOf(deps);
1133
+ state.daemon = {
1134
+ pid,
1135
+ startedAt: deps.daemonStartedAt.toISOString(),
1136
+ heartbeatAt: now.toISOString(),
1137
+ };
1138
+ const ids: string[] = [];
1139
+ for (const profile of profiles) {
1140
+ const cadenceMs = scheduleRunnable(profile)
1141
+ ? automationCadenceMs(profile.schedule?.cadence)
1142
+ : null;
1143
+ let run = ownProfile(state, profile.id);
1144
+ if (cadenceMs !== null) {
1145
+ run = profileState(state, profile.id);
1146
+ scheduleTick(run, cadenceMs, now, profileRetries(profile));
1147
+ } else if (run) run.nextDueAt = null;
1148
+ if (!run) continue;
1149
+ // A record from this daemon's own pid that predates it is a leftover
1150
+ // of an earlier process that happened to get the same pid.
1151
+ const ownLeftover =
1152
+ run.running?.pid === pid &&
1153
+ Date.parse(run.running.startedAt) < daemonStart;
1154
+ recoverInterrupted(run, now, (value) =>
1155
+ ownLeftover ? false : alive(value)
1156
+ );
1157
+ if (canStart(run, profileRetries(profile), now, alive)) {
1158
+ ids.push(profile.id);
1159
+ }
1160
+ }
1161
+ return ids;
1162
+ }
1163
+ );
1164
+ const results: SessionAutomationRunResult[] = [];
1165
+ for (const id of eligible) {
1166
+ results.push(await runAutomationProfile(deps, id, { trigger: null }));
1167
+ }
1168
+ return results;
1169
+ }