@gmickel/gno 2.5.1 → 2.7.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 (160) hide show
  1. package/README.md +60 -5
  2. package/assets/skill/README.md +5 -1
  3. package/assets/skill/SKILL.md +80 -4
  4. package/assets/skill/cli-reference.md +132 -2
  5. package/assets/skill/examples.md +30 -0
  6. package/assets/skill/mcp-reference.md +54 -1
  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.5.1.zip → gno-browser-clipper-v2.7.0.zip} +0 -0
  13. package/browser-extension/artifacts/gno-browser-clipper-v2.7.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 +449 -35
  17. package/spec/mcp.md +234 -4
  18. package/spec/output-schemas/ask.schema.json +1 -1
  19. package/spec/output-schemas/capture-receipt.schema.json +4 -1
  20. package/spec/output-schemas/doctor.schema.json +88 -0
  21. package/spec/output-schemas/error.schema.json +11 -2
  22. package/spec/output-schemas/get.schema.json +1 -1
  23. package/spec/output-schemas/mcp-capture-result.schema.json +4 -2
  24. package/spec/output-schemas/memory-remember.schema.json +10 -4
  25. package/spec/output-schemas/multi-get.schema.json +4 -1
  26. package/spec/output-schemas/peek.schema.json +2 -9
  27. package/spec/output-schemas/request-status.schema.json +113 -0
  28. package/spec/output-schemas/resident-status.schema.json +22 -0
  29. package/spec/output-schemas/search-result.schema.json +1 -1
  30. package/spec/output-schemas/search-results.schema.json +1 -1
  31. package/spec/output-schemas/sessions-automation-run.schema.json +46 -0
  32. package/spec/output-schemas/sessions-discovery.schema.json +38 -0
  33. package/spec/output-schemas/sessions-import-receipt.schema.json +156 -0
  34. package/spec/output-schemas/sessions-status.schema.json +432 -0
  35. package/spec/output-schemas/status.schema.json +98 -0
  36. package/src/cli/commands/ask.ts +14 -2
  37. package/src/cli/commands/capture.ts +55 -96
  38. package/src/cli/commands/daemon.ts +41 -0
  39. package/src/cli/commands/doctor.ts +54 -20
  40. package/src/cli/commands/embed.ts +41 -3
  41. package/src/cli/commands/ls.ts +3 -0
  42. package/src/cli/commands/memory.ts +12 -3
  43. package/src/cli/commands/query.ts +5 -0
  44. package/src/cli/commands/request-status.ts +59 -0
  45. package/src/cli/commands/reset.ts +39 -5
  46. package/src/cli/commands/sessions.ts +713 -0
  47. package/src/cli/commands/shared.ts +14 -1
  48. package/src/cli/commands/status.ts +63 -5
  49. package/src/cli/commands/vec.ts +54 -0
  50. package/src/cli/detach.ts +29 -1
  51. package/src/cli/errors.ts +13 -9
  52. package/src/cli/program.ts +441 -2
  53. package/src/cli/session-binding.ts +49 -0
  54. package/src/config/types.ts +8 -0
  55. package/src/core/capture-publish.ts +239 -0
  56. package/src/core/capture-sync.ts +12 -2
  57. package/src/core/host-paths.ts +31 -0
  58. package/src/core/memory-remember.ts +234 -122
  59. package/src/core/memory-types.ts +11 -0
  60. package/src/core/network-boundary-inventory.ts +8 -0
  61. package/src/core/request-receipts.ts +671 -0
  62. package/src/core/shutdown-budget.ts +6 -0
  63. package/src/core/vector-partition-status.ts +52 -0
  64. package/src/embed/backlog.ts +124 -18
  65. package/src/embed/fingerprint.ts +6 -3
  66. package/src/embed/retry.ts +66 -27
  67. package/src/embed/variant-backlog.ts +15 -10
  68. package/src/embed/variant-retry.ts +31 -22
  69. package/src/index.ts +30 -2
  70. package/src/llm/native-worker/dispatcher.ts +2 -0
  71. package/src/llm/native-worker/embedding-identity.ts +42 -0
  72. package/src/llm/native-worker/protocol.ts +1 -0
  73. package/src/llm/types.ts +3 -0
  74. package/src/mcp/context.ts +17 -0
  75. package/src/mcp/http-egress.ts +4 -0
  76. package/src/mcp/http-transport.ts +2 -0
  77. package/src/mcp/resources/index.ts +6 -5
  78. package/src/mcp/tool-descriptions-core.ts +1 -1
  79. package/src/mcp/tools/capture.ts +87 -85
  80. package/src/mcp/tools/index.ts +77 -4
  81. package/src/mcp/tools/memory-remember.ts +8 -1
  82. package/src/mcp/tools/memory-shared.ts +7 -1
  83. package/src/mcp/tools/request-status.ts +73 -0
  84. package/src/mcp/tools/sessions.ts +208 -0
  85. package/src/mcp/tools/status.ts +4 -0
  86. package/src/pipeline/hybrid.ts +37 -7
  87. package/src/pipeline/vsearch.ts +14 -2
  88. package/src/sdk/client.ts +180 -84
  89. package/src/sdk/index.ts +6 -0
  90. package/src/sdk/types.ts +54 -2
  91. package/src/serve/capture-service.ts +98 -32
  92. package/src/serve/config-sync.ts +3 -2
  93. package/src/serve/embed-scheduler.ts +133 -19
  94. package/src/serve/host-path-redaction.ts +79 -0
  95. package/src/serve/public/app.tsx +4 -1
  96. package/src/serve/public/components/CaptureModal.tsx +26 -8
  97. package/src/serve/public/components/sessions/AutomationPanel.tsx +800 -0
  98. package/src/serve/public/components/sessions/ImportReceipt.tsx +238 -0
  99. package/src/serve/public/components/sessions/SessionSearch.tsx +286 -0
  100. package/src/serve/public/components/sessions/SourcesPanel.tsx +541 -0
  101. package/src/serve/public/components/sessions/api.ts +40 -0
  102. package/src/serve/public/globals.built.css +1 -1
  103. package/src/serve/public/hooks/use-api.ts +26 -3
  104. package/src/serve/public/lib/request-intent.ts +77 -0
  105. package/src/serve/public/lib/snippet.tsx +52 -0
  106. package/src/serve/public/lib/workspace-actions.ts +12 -1
  107. package/src/serve/public/lib/workspace-tabs.ts +2 -0
  108. package/src/serve/public/pages/Dashboard.tsx +22 -9
  109. package/src/serve/public/pages/DocView.tsx +25 -6
  110. package/src/serve/public/pages/DocumentEditor.tsx +224 -104
  111. package/src/serve/public/pages/Search.tsx +1 -41
  112. package/src/serve/public/pages/Sessions.tsx +350 -0
  113. package/src/serve/resident-runtime.ts +69 -4
  114. package/src/serve/resident-status.ts +13 -1
  115. package/src/serve/routes/api.ts +476 -147
  116. package/src/serve/routes/sessions.ts +766 -0
  117. package/src/serve/security.ts +9 -0
  118. package/src/serve/server.ts +215 -10
  119. package/src/serve/session-automation.ts +146 -0
  120. package/src/serve/status-model.ts +16 -0
  121. package/src/serve/status.ts +2 -0
  122. package/src/serve/watch-reconciliation-shared.ts +3 -0
  123. package/src/serve/watch-service-events.ts +3 -2
  124. package/src/serve/watch-service-run-flush.ts +35 -2
  125. package/src/serve/watch-service.ts +5 -0
  126. package/src/sessions/archive.ts +348 -0
  127. package/src/sessions/automation-state.ts +444 -0
  128. package/src/sessions/automation-status.ts +239 -0
  129. package/src/sessions/automation.ts +1169 -0
  130. package/src/sessions/binding.ts +105 -0
  131. package/src/sessions/claude-hook.ts +240 -0
  132. package/src/sessions/config.ts +176 -0
  133. package/src/sessions/format.ts +191 -0
  134. package/src/sessions/import-child-env.ts +8 -0
  135. package/src/sessions/import-child.ts +152 -0
  136. package/src/sessions/parsers/claude-code.ts +259 -0
  137. package/src/sessions/parsers/codex.ts +303 -0
  138. package/src/sessions/parsers/hermes.ts +248 -0
  139. package/src/sessions/parsers/openclaw.ts +496 -0
  140. package/src/sessions/parsers/shared.ts +184 -0
  141. package/src/sessions/sanitize.ts +222 -0
  142. package/src/sessions/service.ts +1533 -0
  143. package/src/sessions/setup.ts +477 -0
  144. package/src/sessions/sources.ts +518 -0
  145. package/src/sessions/state.ts +118 -0
  146. package/src/sessions/types.ts +457 -0
  147. package/src/store/migrations/031-runtime-independent-vectors.ts +29 -0
  148. package/src/store/migrations/032-vector-runtime-callers.ts +17 -0
  149. package/src/store/migrations/index.ts +4 -0
  150. package/src/store/sqlite/adapter.ts +76 -16
  151. package/src/store/sqlite/scoped-index.ts +9 -0
  152. package/src/store/types.ts +11 -1
  153. package/src/store/vector/lazy.ts +46 -43
  154. package/src/store/vector/runtime-compat.ts +651 -0
  155. package/src/store/vector/sqlite-vec.ts +20 -2
  156. package/src/store/vector/status.ts +276 -35
  157. package/src/store/vector/types.ts +2 -0
  158. package/src/store/vector/variant-search.ts +71 -23
  159. package/src/store/vector/variants.ts +49 -14
  160. package/browser-extension/artifacts/gno-browser-clipper-v2.5.1.zip.sha256 +0 -1
@@ -0,0 +1,444 @@
1
+ /**
2
+ * Machine-written automation run state: one coalesced pending marker per
3
+ * profile plus the daemon heartbeat.
4
+ *
5
+ * Lives in `<archiveRoot>/.gno-sessions/automation.json` beside the import
6
+ * checkpoint, never in the owner's config. Every change is serialized by
7
+ * `automation.lock` and written atomically (private temp file, fsync,
8
+ * rename, directory fsync), so an admission is acknowledged only once it is
9
+ * durable. Transitions are pure functions over the loaded record so they can
10
+ * be driven with a fake clock.
11
+ *
12
+ * Generation contract: a trigger increments `generation`; a run records the
13
+ * generation it started with and a completed run consumes only that one, so a
14
+ * trigger arriving mid-run stays pending. Nothing here imports or parses
15
+ * sessions.
16
+ *
17
+ * @module src/sessions/automation-state
18
+ */
19
+
20
+ // node:fs/promises: fsync (FileHandle.sync), rename, chmod and stat have no Bun equivalents.
21
+ import { chmod, open, rename, stat, unlink } from "node:fs/promises";
22
+ // node:path: no Bun path utilities.
23
+ import { dirname, join } from "node:path";
24
+
25
+ import type {
26
+ SessionRunRecord,
27
+ SessionTriggerKind,
28
+ SessionsErrorCode,
29
+ } from "./types";
30
+
31
+ import { acquireSqliteWriteLock } from "../core/file-lock";
32
+ import { SESSION_STATE_DIRNAME } from "./archive";
33
+ import { SessionsError } from "./types";
34
+
35
+ export const AUTOMATION_STATE_VERSION = "1";
36
+ /** Daemon wake-up: heartbeat, schedule check and pending drain. */
37
+ export const AUTOMATION_TICK_MS = 30_000;
38
+ /** A heartbeat older than this means the daemon is gone or stuck. */
39
+ export const AUTOMATION_HEARTBEAT_STALE_MS = 3 * AUTOMATION_TICK_MS;
40
+ /** Longest a hook waits for the marker lock before reporting a failure. */
41
+ export const HOOK_ADMISSION_DEADLINE_MS = 1_000;
42
+ /** Default changed units per source per run. */
43
+ export const DEFAULT_AUTOMATION_LIMIT = 200;
44
+ /** Default automatic retries after a failed run. */
45
+ export const DEFAULT_AUTOMATION_RETRIES = 3;
46
+ /** Shortest schedule cadence; imports enumerate every unit of a source. */
47
+ export const MIN_AUTOMATION_CADENCE_MS = 60_000;
48
+ /** A recorded run older than this is treated as interrupted (pid reuse). */
49
+ export const AUTOMATION_RUN_STALE_MS = 2 * 60 * 60_000;
50
+ const RETRY_BASE_MS = 60_000;
51
+ const RETRY_MAX_MS = 30 * 60_000;
52
+
53
+ export interface ProfileRunState {
54
+ /** Latest admitted trigger generation. */
55
+ generation: number;
56
+ /** Generation consumed by the last completed run. */
57
+ consumed: number;
58
+ pendingSince: string | null;
59
+ pendingTriggers: SessionTriggerKind[];
60
+ lastTrigger: { kind: SessionTriggerKind; at: string } | null;
61
+ running: {
62
+ generation: number;
63
+ triggers: SessionTriggerKind[];
64
+ startedAt: string;
65
+ pid: number;
66
+ } | null;
67
+ lastRun: SessionRunRecord | null;
68
+ lastSuccessAt: string | null;
69
+ /** Consecutive failed attempts since the last success or owner action. */
70
+ attempts: number;
71
+ /** A failure that needs owner correction; automatic triggers cannot clear it. */
72
+ blocked?: boolean;
73
+ retryAt: string | null;
74
+ nextDueAt: string | null;
75
+ }
76
+
77
+ export interface AutomationState {
78
+ schemaVersion: typeof AUTOMATION_STATE_VERSION;
79
+ daemon: { pid: number; startedAt: string; heartbeatAt: string } | null;
80
+ profiles: Record<string, ProfileRunState>;
81
+ }
82
+
83
+ export const emptyAutomationState = (): AutomationState => ({
84
+ schemaVersion: AUTOMATION_STATE_VERSION,
85
+ daemon: null,
86
+ profiles: {},
87
+ });
88
+
89
+ export const emptyProfileRunState = (): ProfileRunState => ({
90
+ generation: 0,
91
+ consumed: 0,
92
+ pendingSince: null,
93
+ pendingTriggers: [],
94
+ lastTrigger: null,
95
+ running: null,
96
+ lastRun: null,
97
+ lastSuccessAt: null,
98
+ attempts: 0,
99
+ retryAt: null,
100
+ nextDueAt: null,
101
+ });
102
+
103
+ const stateDir = (archiveRoot: string): string =>
104
+ join(archiveRoot, SESSION_STATE_DIRNAME);
105
+
106
+ export const automationStatePath = (archiveRoot: string): string =>
107
+ join(stateDir(archiveRoot), "automation.json");
108
+
109
+ const automationLockPath = (archiveRoot: string): string =>
110
+ join(stateDir(archiveRoot), "automation.lock");
111
+
112
+ /** Read the state; a missing file is empty, a corrupt one is reported. */
113
+ export async function loadAutomationState(
114
+ archiveRoot: string
115
+ ): Promise<{ state: AutomationState; corrupt: boolean }> {
116
+ const file = Bun.file(automationStatePath(archiveRoot));
117
+ if (!(await file.exists())) {
118
+ return { state: emptyAutomationState(), corrupt: false };
119
+ }
120
+ try {
121
+ const parsed = (await file.json()) as AutomationState;
122
+ if (
123
+ parsed?.schemaVersion === AUTOMATION_STATE_VERSION &&
124
+ typeof parsed.profiles === "object" &&
125
+ parsed.profiles !== null
126
+ ) {
127
+ return { state: parsed, corrupt: false };
128
+ }
129
+ } catch {
130
+ // Reported below; the next durable write replaces the file.
131
+ }
132
+ return { state: emptyAutomationState(), corrupt: true };
133
+ }
134
+
135
+ export type StateWriter = (path: string, content: string) => Promise<void>;
136
+
137
+ /** Private (0600) atomic write that is on disk before it returns. */
138
+ async function writeDurable(path: string, content: string): Promise<void> {
139
+ const temporary = `${path}.tmp.${process.pid}.${crypto.randomUUID()}`;
140
+ try {
141
+ const handle = await open(temporary, "wx", 0o600);
142
+ try {
143
+ await handle.writeFile(content);
144
+ await handle.sync();
145
+ } finally {
146
+ await handle.close();
147
+ }
148
+ await chmod(temporary, 0o600);
149
+ await rename(temporary, path);
150
+ } catch (error) {
151
+ await unlink(temporary).catch(() => undefined);
152
+ throw error;
153
+ }
154
+ // Persist the rename itself. Directory fsync is unsupported on Windows.
155
+ const directory = await open(dirname(path), "r").catch(() => null);
156
+ await directory?.sync().catch(() => undefined);
157
+ await directory?.close();
158
+ }
159
+
160
+ /**
161
+ * Load, mutate and durably save the state under the marker lock. The
162
+ * archive's state directory must already exist: a removed destination is
163
+ * reported, never silently recreated.
164
+ */
165
+ export async function mutateAutomationState<T>(
166
+ archiveRoot: string,
167
+ mutate: (state: AutomationState) => T | Promise<T>,
168
+ options: { lockWaitMs?: number; writeState?: StateWriter } = {}
169
+ ): Promise<T> {
170
+ const directory = await stat(stateDir(archiveRoot)).catch(() => null);
171
+ if (!directory?.isDirectory()) {
172
+ throw new SessionsError(
173
+ "SESSIONS_SOURCE_UNAVAILABLE",
174
+ "The session archive destination is missing; recreate it with gno sessions init, then re-enable automation."
175
+ );
176
+ }
177
+ const lock = await acquireSqliteWriteLock(
178
+ automationLockPath(archiveRoot),
179
+ options.lockWaitMs ?? 5_000
180
+ );
181
+ if (!lock) {
182
+ throw new SessionsError(
183
+ "SESSIONS_BUSY",
184
+ "Automation state is locked by another process; retry shortly."
185
+ );
186
+ }
187
+ try {
188
+ const { state } = await loadAutomationState(archiveRoot);
189
+ const result = await mutate(state);
190
+ await (options.writeState ?? writeDurable)(
191
+ automationStatePath(archiveRoot),
192
+ `${JSON.stringify(state)}\n`
193
+ );
194
+ return result;
195
+ } finally {
196
+ await lock.release();
197
+ }
198
+ }
199
+
200
+ // ─────────────────────────────────────────────────────────────────────────────
201
+ // Pure transitions
202
+ // ─────────────────────────────────────────────────────────────────────────────
203
+
204
+ /** Own-property lookup: profile IDs such as `constructor` are valid. */
205
+ export function ownProfile(
206
+ state: AutomationState,
207
+ id: string
208
+ ): ProfileRunState | undefined {
209
+ return Object.hasOwn(state.profiles, id) ? state.profiles[id] : undefined;
210
+ }
211
+
212
+ export function profileState(
213
+ state: AutomationState,
214
+ id: string
215
+ ): ProfileRunState {
216
+ const existing = ownProfile(state, id);
217
+ if (existing) return existing;
218
+ const created = emptyProfileRunState();
219
+ state.profiles[id] = created;
220
+ return created;
221
+ }
222
+
223
+ export const isPending = (profile: ProfileRunState): boolean =>
224
+ profile.generation > profile.consumed;
225
+
226
+ /** Owner action (run now, reconfigure, enable): a fresh attempt budget. */
227
+ export function unblock(profile: ProfileRunState): void {
228
+ profile.attempts = 0;
229
+ profile.retryAt = null;
230
+ profile.blocked = false;
231
+ }
232
+
233
+ /**
234
+ * Record one trigger. Duplicate triggers coalesce into the same pending run.
235
+ * An explicit `manual` trigger resets the retry budget; automatic triggers
236
+ * keep a pending backoff and a permanent block, and only re-arm a transient
237
+ * failure whose retries are used up.
238
+ */
239
+ export function admit(
240
+ profile: ProfileRunState,
241
+ kind: SessionTriggerKind,
242
+ now: Date,
243
+ retries: number
244
+ ): number {
245
+ profile.generation += 1;
246
+ profile.pendingSince ??= now.toISOString();
247
+ if (!profile.pendingTriggers.includes(kind)) {
248
+ profile.pendingTriggers.push(kind);
249
+ }
250
+ profile.lastTrigger = { kind, at: now.toISOString() };
251
+ if (kind === "manual") unblock(profile);
252
+ else if (!profile.blocked && profile.attempts > retries) {
253
+ profile.attempts = 0;
254
+ profile.retryAt = null;
255
+ }
256
+ return profile.generation;
257
+ }
258
+
259
+ /** Disable/pause: drop admitted work that has not started. */
260
+ export function clearPending(profile: ProfileRunState): void {
261
+ profile.consumed = profile.generation;
262
+ profile.pendingSince = null;
263
+ profile.pendingTriggers = [];
264
+ profile.attempts = 0;
265
+ profile.retryAt = null;
266
+ }
267
+
268
+ export function isProcessAlive(pid: number): boolean {
269
+ try {
270
+ process.kill(pid, 0);
271
+ return true;
272
+ } catch (error) {
273
+ return (error as NodeJS.ErrnoException).code === "EPERM";
274
+ }
275
+ }
276
+
277
+ /** A run is live while its process exists and it is not implausibly old. */
278
+ export function isRunLive(
279
+ running: ProfileRunState["running"],
280
+ now: Date,
281
+ alive: (pid: number) => boolean = isProcessAlive
282
+ ): boolean {
283
+ if (!running) return false;
284
+ const age = now.getTime() - Date.parse(running.startedAt);
285
+ return alive(running.pid) && !(age > AUTOMATION_RUN_STALE_MS);
286
+ }
287
+
288
+ /**
289
+ * A run whose process is gone was interrupted: its generation was never
290
+ * consumed, so the work stays pending and is retried.
291
+ */
292
+ export function recoverInterrupted(
293
+ profile: ProfileRunState,
294
+ now: Date,
295
+ alive: (pid: number) => boolean = isProcessAlive
296
+ ): boolean {
297
+ if (!profile.running || isRunLive(profile.running, now, alive)) return false;
298
+ profile.lastRun = {
299
+ triggers: profile.running.triggers,
300
+ startedAt: profile.running.startedAt,
301
+ finishedAt: now.toISOString(),
302
+ outcome: "failed",
303
+ reason: "interrupted",
304
+ threads: { imported: 0, updated: 0, unchanged: 0 },
305
+ units: { incomplete: 0, failed: 0, deferred: 0 },
306
+ };
307
+ profile.running = null;
308
+ profile.retryAt = null;
309
+ return true;
310
+ }
311
+
312
+ /** Whether pending work may start now (no live run, backoff elapsed, budget left). */
313
+ export function canStart(
314
+ profile: ProfileRunState,
315
+ retries: number,
316
+ now: Date,
317
+ alive: (pid: number) => boolean = isProcessAlive
318
+ ): boolean {
319
+ if (!isPending(profile)) return false;
320
+ if (isRunLive(profile.running, now, alive)) return false;
321
+ if (profile.attempts > retries) return false;
322
+ return (
323
+ profile.retryAt === null || Date.parse(profile.retryAt) <= now.getTime()
324
+ );
325
+ }
326
+
327
+ export type StartedRun = NonNullable<ProfileRunState["running"]>;
328
+
329
+ export function beginRun(
330
+ profile: ProfileRunState,
331
+ now: Date,
332
+ pid: number
333
+ ): StartedRun {
334
+ const started: StartedRun = {
335
+ generation: profile.generation,
336
+ triggers: [...profile.pendingTriggers],
337
+ startedAt: now.toISOString(),
338
+ pid,
339
+ };
340
+ profile.running = { ...started, triggers: [...started.triggers] };
341
+ return started;
342
+ }
343
+
344
+ /** Whether `profile` still records exactly this run (not a removed/replaced one). */
345
+ export const ownsRun = (
346
+ profile: ProfileRunState,
347
+ started: StartedRun
348
+ ): boolean =>
349
+ profile.running?.pid === started.pid &&
350
+ profile.running.startedAt === started.startedAt &&
351
+ profile.running.generation === started.generation;
352
+
353
+ /** Failure classes: contention and transient errors back off; the rest wait for a fix. */
354
+ export type RunFailureClass = "contention" | "transient" | "permanent";
355
+
356
+ export function retryDelayMs(attempt: number): number {
357
+ return Math.min(RETRY_BASE_MS * 2 ** Math.max(0, attempt - 1), RETRY_MAX_MS);
358
+ }
359
+
360
+ /**
361
+ * Settle a run. Success consumes the started generation (a trigger that
362
+ * arrived meanwhile stays pending); deferred work keeps it pending for the
363
+ * next tick; a failure keeps it pending with backoff or, when permanent or
364
+ * out of retries, until the next trigger.
365
+ */
366
+ export function finishRun(
367
+ profile: ProfileRunState,
368
+ started: { generation: number },
369
+ run: SessionRunRecord,
370
+ options: { retries: number; failure?: RunFailureClass; now: Date }
371
+ ): void {
372
+ profile.running = null;
373
+ profile.lastRun = run;
374
+ if (run.outcome === "failed") {
375
+ if (options.failure === "permanent") profile.blocked = true;
376
+ profile.attempts =
377
+ options.failure === "permanent"
378
+ ? options.retries + 1
379
+ : profile.attempts + 1;
380
+ profile.retryAt =
381
+ profile.attempts <= options.retries
382
+ ? new Date(
383
+ options.now.getTime() + retryDelayMs(profile.attempts)
384
+ ).toISOString()
385
+ : null;
386
+ return;
387
+ }
388
+ // A partial run settles its generation but is not a successful completion.
389
+ if (run.outcome !== "partial") profile.lastSuccessAt = run.finishedAt;
390
+ unblock(profile);
391
+ if (run.units.deferred > 0) return;
392
+ profile.consumed = Math.max(profile.consumed, started.generation);
393
+ if (!isPending(profile)) {
394
+ profile.pendingSince = null;
395
+ profile.pendingTriggers = [];
396
+ }
397
+ }
398
+
399
+ /**
400
+ * Elapsed-cadence schedule. Missed intervals (sleep, restart, a stopped
401
+ * daemon) coalesce into one admission; a clock moved backwards cannot push
402
+ * the next run further than one cadence away.
403
+ */
404
+ export function scheduleTick(
405
+ profile: ProfileRunState,
406
+ cadenceMs: number,
407
+ now: Date,
408
+ retries: number
409
+ ): boolean {
410
+ const nowMs = now.getTime();
411
+ const due = profile.nextDueAt ? Date.parse(profile.nextDueAt) : Number.NaN;
412
+ if (!Number.isFinite(due) || due - nowMs > cadenceMs) {
413
+ profile.nextDueAt = new Date(nowMs + cadenceMs).toISOString();
414
+ return false;
415
+ }
416
+ if (due > nowMs) return false;
417
+ admit(profile, "schedule", now, retries);
418
+ profile.nextDueAt = new Date(nowMs + cadenceMs).toISOString();
419
+ return true;
420
+ }
421
+
422
+ /** Stable, content-free classification of a failed run's error. */
423
+ export function classifyRunError(code: SessionsErrorCode | null): {
424
+ failure: RunFailureClass;
425
+ reason: string;
426
+ } {
427
+ switch (code) {
428
+ case "SESSIONS_BUSY":
429
+ return { failure: "contention", reason: "busy" };
430
+ case "SESSIONS_UNKNOWN_SOURCE":
431
+ case "SESSIONS_UNKNOWN_PROFILE":
432
+ return { failure: "permanent", reason: "source_revoked" };
433
+ case "SESSIONS_SOURCE_UNAVAILABLE":
434
+ return { failure: "permanent", reason: "source_unavailable" };
435
+ case "SESSIONS_UNKNOWN_COLLECTION":
436
+ case "SESSIONS_NOT_CONFIGURED":
437
+ case "SESSIONS_BINDING_MISMATCH":
438
+ case "SESSIONS_INVALID_INPUT":
439
+ case "SESSIONS_UNSAFE_PATH":
440
+ return { failure: "permanent", reason: "invalid_configuration" };
441
+ default:
442
+ return { failure: "transient", reason: "runtime_error" };
443
+ }
444
+ }
@@ -0,0 +1,239 @@
1
+ /**
2
+ * Reader-facing automation status, shared by every surface. Path-free: it
3
+ * reports profile and source IDs, collections, triggers, times, outcomes and
4
+ * recovery actions, never host paths or session content.
5
+ *
6
+ * @module src/sessions/automation-status
7
+ */
8
+
9
+ import type { SessionAutomationProfile, SessionsConfig } from "./config";
10
+ import type {
11
+ SessionAutomationStatus,
12
+ SessionProfileState,
13
+ SessionProfileStatus,
14
+ } from "./types";
15
+
16
+ import { parseFindingsCadenceMs } from "../config/types";
17
+ import {
18
+ AUTOMATION_HEARTBEAT_STALE_MS,
19
+ type AutomationState,
20
+ DEFAULT_AUTOMATION_LIMIT,
21
+ DEFAULT_AUTOMATION_RETRIES,
22
+ isPending,
23
+ isProcessAlive,
24
+ isRunLive,
25
+ loadAutomationState,
26
+ MIN_AUTOMATION_CADENCE_MS,
27
+ ownProfile,
28
+ type ProfileRunState,
29
+ } from "./automation-state";
30
+ import { inspectClaudeHook } from "./claude-hook";
31
+
32
+ /** Parse a profile cadence; null when malformed or below the minimum. */
33
+ export function automationCadenceMs(
34
+ cadence: string | undefined
35
+ ): number | null {
36
+ if (!cadence) return null;
37
+ const ms = parseFindingsCadenceMs(cadence);
38
+ return ms !== null && ms >= MIN_AUTOMATION_CADENCE_MS ? ms : null;
39
+ }
40
+
41
+ /** Enabled with a valid cadence; a hand-edited invalid cadence never runs. */
42
+ export const scheduleRunnable = (profile: SessionAutomationProfile): boolean =>
43
+ profile.schedule?.enabled === true &&
44
+ automationCadenceMs(profile.schedule.cadence) !== null;
45
+
46
+ export const profileLimit = (profile: SessionAutomationProfile): number =>
47
+ profile.limit ?? DEFAULT_AUTOMATION_LIMIT;
48
+
49
+ export const profileRetries = (profile: SessionAutomationProfile): number =>
50
+ profile.retries ?? DEFAULT_AUTOMATION_RETRIES;
51
+
52
+ export function profileCollections(
53
+ sessions: SessionsConfig,
54
+ profile: SessionAutomationProfile
55
+ ): string[] {
56
+ const names = new Set<string>();
57
+ for (const source of sessions.sources) {
58
+ if (!profile.sources.includes(source.id)) continue;
59
+ names.add(source.collection);
60
+ for (const mapping of source.projects ?? []) names.add(mapping.collection);
61
+ }
62
+ return [...names].sort();
63
+ }
64
+
65
+ export function daemonState(
66
+ daemon: AutomationState["daemon"],
67
+ now: Date,
68
+ alive: (pid: number) => boolean = isProcessAlive
69
+ ): SessionAutomationStatus["daemon"]["state"] {
70
+ if (!daemon || !alive(daemon.pid)) return "not_running";
71
+ return now.getTime() - Date.parse(daemon.heartbeatAt) <=
72
+ AUTOMATION_HEARTBEAT_STALE_MS
73
+ ? "running"
74
+ : "stale";
75
+ }
76
+
77
+ const RECOVERY: Record<string, string> = {
78
+ source_revoked:
79
+ "A selected source is no longer registered: register it again or update the profile's sources with `gno sessions automation set`.",
80
+ source_unavailable:
81
+ "A selected source or the archive destination is missing or unreadable: fix it, then run `gno sessions automation run <profile>`.",
82
+ invalid_configuration:
83
+ "The archive config no longer matches this profile (collection, binding or source settings): correct it, then run `gno sessions automation run <profile>`.",
84
+ import_failed:
85
+ "Every processed unit failed: check `gno sessions status` and the receipt of `gno sessions automation run <profile>`.",
86
+ };
87
+
88
+ function recoveryFor(
89
+ profile: SessionAutomationProfile,
90
+ run: ProfileRunState | undefined,
91
+ state: SessionProfileState,
92
+ installed: boolean | null,
93
+ daemon: SessionAutomationStatus["daemon"]["state"]
94
+ ): string | null {
95
+ const id = profile.id;
96
+ if (
97
+ profile.schedule?.enabled &&
98
+ automationCadenceMs(profile.schedule.cadence) === null
99
+ ) {
100
+ return `The schedule cadence "${profile.schedule.cadence}" is invalid, so the schedule does not run: fix it with \`gno sessions automation set ${id} --source … --cadence 30m\` (<n>s|m|h|d, 1m to 30d).`;
101
+ }
102
+ if (profile.hook?.enabled && installed === false) {
103
+ return `The Claude Code hook entry is missing from its settings file: run \`gno sessions automation enable ${id} --hook claude-code\` to reinstall it.`;
104
+ }
105
+ if (profile.hook?.enabled && installed === null) {
106
+ return `The Claude Code settings file could not be read: fix it, then run \`gno sessions automation enable ${id} --hook claude-code\`.`;
107
+ }
108
+ if (state === "failed" || state === "retrying") {
109
+ const reason = run?.lastRun?.reason ?? "";
110
+ const known = RECOVERY[reason];
111
+ if (known) return known.replace("<profile>", id);
112
+ if (state === "failed") {
113
+ return `Automatic retries are exhausted: check \`gno sessions status\`, then run \`gno sessions automation run ${id}\`.`;
114
+ }
115
+ }
116
+ // A live run needs no action; suggesting "run now" would only hit busy.
117
+ if (state === "running") return null;
118
+ const scheduled = scheduleRunnable(profile);
119
+ if (
120
+ daemon !== "running" &&
121
+ (scheduled || state === "pending" || state === "retrying")
122
+ ) {
123
+ return `not running: no daemon. Schedules and admitted hook work run only while \`gno daemon\` runs on this archive's config and index; or run \`gno sessions automation run ${id}\` now.`;
124
+ }
125
+ if (state === "partial") {
126
+ return "The last run left incomplete or deferred units; the next run retries them.";
127
+ }
128
+ return null;
129
+ }
130
+
131
+ function deriveState(
132
+ profile: SessionAutomationProfile,
133
+ run: ProfileRunState | undefined,
134
+ now: Date,
135
+ alive: (pid: number) => boolean
136
+ ): SessionProfileState {
137
+ const retries = profile.retries ?? DEFAULT_AUTOMATION_RETRIES;
138
+ if (run && isRunLive(run.running, now, alive)) return "running";
139
+ if (run && isPending(run)) {
140
+ if (run.attempts > retries) return "failed";
141
+ return run.retryAt ? "retrying" : "pending";
142
+ }
143
+ const enabled = profile.hook?.enabled || scheduleRunnable(profile);
144
+ if (!enabled) return "off";
145
+ if (run?.lastRun?.outcome === "failed") return "failed";
146
+ if (run?.lastRun?.outcome === "partial") return "partial";
147
+ return "idle";
148
+ }
149
+
150
+ /** Automation status for one archive; `warnings` joins the sessions status. */
151
+ export async function readAutomationStatus(input: {
152
+ sessions: SessionsConfig;
153
+ configPath: string;
154
+ indexName: string;
155
+ now?: Date;
156
+ alive?: (pid: number) => boolean;
157
+ }): Promise<{ status: SessionAutomationStatus; warnings: string[] }> {
158
+ const now = input.now ?? new Date();
159
+ const alive = input.alive ?? isProcessAlive;
160
+ const profiles = input.sessions.automation ?? [];
161
+ const { state, corrupt } = await loadAutomationState(
162
+ input.sessions.archiveRoot
163
+ );
164
+ const warnings = corrupt
165
+ ? [
166
+ "automation run state was unreadable; pending admissions recorded in it are lost and the next trigger starts fresh",
167
+ ]
168
+ : [];
169
+ const daemon = daemonState(state.daemon, now, alive);
170
+ const result: SessionProfileStatus[] = [];
171
+ for (const profile of profiles) {
172
+ const run = ownProfile(state, profile.id);
173
+ const installed = profile.hook
174
+ ? await inspectClaudeHook(profile.hook.settings, {
175
+ configPath: input.configPath,
176
+ indexName: input.indexName,
177
+ profileId: profile.id,
178
+ })
179
+ : null;
180
+ const profileState = deriveState(profile, run, now, alive);
181
+ const cadenceValid = automationCadenceMs(profile.schedule?.cadence);
182
+ if (profile.schedule && cadenceValid === null) {
183
+ warnings.push(
184
+ `automation profile ${profile.id}: cadence "${profile.schedule.cadence}" is invalid (use <n>s|m|h|d, 1m to 30d); the schedule is reported off and does not run`
185
+ );
186
+ }
187
+ const pending = run && isPending(run) && run.pendingSince;
188
+ result.push({
189
+ id: profile.id,
190
+ sources: [...profile.sources],
191
+ collections: profileCollections(input.sessions, profile),
192
+ state: profileState,
193
+ hook: profile.hook
194
+ ? {
195
+ harness: profile.hook.harness,
196
+ enabled: profile.hook.enabled,
197
+ installed,
198
+ }
199
+ : null,
200
+ schedule: profile.schedule
201
+ ? {
202
+ // Effective state: an invalid cadence (warned above) never runs.
203
+ enabled: scheduleRunnable(profile),
204
+ cadence: profile.schedule.cadence,
205
+ // A due time is only real while a daemon is ticking.
206
+ nextDueAt:
207
+ scheduleRunnable(profile) && daemon === "running"
208
+ ? (run?.nextDueAt ?? null)
209
+ : null,
210
+ }
211
+ : null,
212
+ limit: profileLimit(profile),
213
+ retries: profileRetries(profile),
214
+ pending: pending
215
+ ? { since: pending, triggers: [...(run?.pendingTriggers ?? [])] }
216
+ : null,
217
+ running:
218
+ run?.running && isRunLive(run.running, now, alive)
219
+ ? {
220
+ startedAt: run.running.startedAt,
221
+ triggers: [...run.running.triggers],
222
+ }
223
+ : null,
224
+ lastTrigger: run?.lastTrigger ?? null,
225
+ lastRun: run?.lastRun ?? null,
226
+ lastSuccessAt: run?.lastSuccessAt ?? null,
227
+ retryAt: run?.retryAt ?? null,
228
+ recovery: recoveryFor(profile, run, profileState, installed, daemon),
229
+ });
230
+ }
231
+ return {
232
+ status: {
233
+ daemon: { state: daemon, heartbeatAt: state.daemon?.heartbeatAt ?? null },
234
+ timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
235
+ profiles: result,
236
+ },
237
+ warnings,
238
+ };
239
+ }