@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.
- package/README.md +69 -6
- package/assets/skill/README.md +5 -1
- package/assets/skill/SKILL.md +98 -1
- package/assets/skill/cli-reference.md +140 -0
- package/assets/skill/examples.md +45 -0
- package/assets/skill/mcp-reference.md +62 -0
- package/assets/skill/recipes/capture-and-file.md +6 -0
- package/assets/skill/recipes/memory-file-decision.md +6 -0
- package/assets/skill/recipes/memory-supersede-fact.md +5 -0
- package/assets/skill/recipes/session-evidence-lookup.md +98 -0
- package/assets/spa-production.json.gz +0 -0
- package/browser-extension/artifacts/{gno-browser-clipper-v2.4.0.zip → gno-browser-clipper-v2.6.0.zip} +0 -0
- package/browser-extension/artifacts/gno-browser-clipper-v2.6.0.zip.sha256 +1 -0
- package/browser-extension/dist/manifest.json +1 -1
- package/package.json +2 -1
- package/spec/cli.md +358 -24
- package/spec/compiled-context.md +68 -0
- package/spec/mcp.md +223 -2
- package/spec/output-schemas/capture-receipt.schema.json +3 -0
- package/spec/output-schemas/compiled-context-check.schema.json +44 -0
- package/spec/output-schemas/compiled-context-file.schema.json +165 -0
- package/spec/output-schemas/compiled-context-preview.schema.json +142 -0
- package/spec/output-schemas/mcp-capture-result.schema.json +3 -0
- package/spec/output-schemas/memory-remember.schema.json +8 -2
- package/spec/output-schemas/request-status.schema.json +113 -0
- package/spec/output-schemas/sessions-automation-run.schema.json +46 -0
- package/spec/output-schemas/sessions-discovery.schema.json +38 -0
- package/spec/output-schemas/sessions-import-receipt.schema.json +156 -0
- package/spec/output-schemas/sessions-status.schema.json +432 -0
- package/src/app/compiled-context-files.ts +361 -0
- package/src/app/compiled-context.ts +240 -0
- package/src/app/context-surface.ts +7 -2
- package/src/cli/commands/ask.ts +14 -2
- package/src/cli/commands/audit.ts +4 -0
- package/src/cli/commands/capture.ts +55 -96
- package/src/cli/commands/context-compiled.ts +136 -0
- package/src/cli/commands/daemon.ts +41 -0
- package/src/cli/commands/ls.ts +3 -0
- package/src/cli/commands/memory.ts +12 -3
- package/src/cli/commands/request-status.ts +59 -0
- package/src/cli/commands/reset.ts +39 -5
- package/src/cli/commands/sessions.ts +713 -0
- package/src/cli/commands/shared.ts +14 -1
- package/src/cli/errors.ts +10 -3
- package/src/cli/program.ts +458 -1
- package/src/cli/session-binding.ts +49 -0
- package/src/config/types.ts +8 -0
- package/src/core/capture-publish.ts +239 -0
- package/src/core/capture-sync.ts +3 -0
- package/src/core/compiled-context.ts +254 -0
- package/src/core/context-budget.ts +2 -10
- package/src/core/file-lock.ts +22 -5
- package/src/core/folder-setup-planning.ts +2 -1
- package/src/core/memory-remember.ts +233 -122
- package/src/core/memory-types.ts +11 -0
- package/src/core/network-boundary-inventory.ts +16 -0
- package/src/core/request-receipts.ts +671 -0
- package/src/core/setup-receipt.ts +27 -20
- package/src/core/typed-metadata.ts +4 -0
- package/src/core/validation.ts +9 -2
- package/src/core/windows-private-path.ts +96 -0
- package/src/index.ts +11 -2
- package/src/ingestion/compiled-context.ts +15 -0
- package/src/ingestion/sync.ts +40 -8
- package/src/ingestion/walker.ts +5 -4
- package/src/llm/nodeLlamaCpp/simulator-install.ts +6 -2
- package/src/mcp/context.ts +8 -0
- package/src/mcp/http-egress.ts +15 -3
- package/src/mcp/http-transport.ts +2 -0
- package/src/mcp/retrieval-warnings.ts +24 -0
- package/src/mcp/tools/ask.ts +14 -1
- package/src/mcp/tools/capture.ts +87 -83
- package/src/mcp/tools/context.ts +46 -2
- package/src/mcp/tools/index.ts +119 -7
- package/src/mcp/tools/memory-remember.ts +7 -0
- package/src/mcp/tools/memory-shared.ts +7 -1
- package/src/mcp/tools/query.ts +3 -1
- package/src/mcp/tools/request-status.ts +73 -0
- package/src/mcp/tools/search.ts +3 -1
- package/src/mcp/tools/sessions.ts +208 -0
- package/src/mcp/tools/vsearch.ts +3 -1
- package/src/sdk/client.ts +252 -89
- package/src/sdk/index.ts +13 -0
- package/src/sdk/types.ts +82 -3
- package/src/serve/capture-service.ts +98 -32
- package/src/serve/compiled-context.ts +84 -0
- package/src/serve/config-sync.ts +3 -2
- package/src/serve/public/app.tsx +15 -1
- package/src/serve/public/components/CaptureModal.tsx +26 -8
- package/src/serve/public/components/sessions/AutomationPanel.tsx +800 -0
- package/src/serve/public/components/sessions/ImportReceipt.tsx +238 -0
- package/src/serve/public/components/sessions/SessionSearch.tsx +286 -0
- package/src/serve/public/components/sessions/SourcesPanel.tsx +541 -0
- package/src/serve/public/components/sessions/api.ts +40 -0
- package/src/serve/public/components/sessions/snippet.tsx +53 -0
- package/src/serve/public/globals.built.css +1 -1
- package/src/serve/public/hooks/use-api.ts +10 -2
- package/src/serve/public/lib/request-intent.ts +69 -0
- package/src/serve/public/lib/workspace-actions.ts +12 -1
- package/src/serve/public/lib/workspace-tabs.ts +4 -0
- package/src/serve/public/pages/CompiledContext.tsx +364 -0
- package/src/serve/public/pages/Dashboard.tsx +17 -0
- package/src/serve/public/pages/DocView.tsx +15 -1
- package/src/serve/public/pages/DocumentEditor.tsx +139 -96
- package/src/serve/public/pages/Sessions.tsx +350 -0
- package/src/serve/resident-runtime.ts +43 -3
- package/src/serve/routes/api.ts +476 -147
- package/src/serve/routes/sessions.ts +766 -0
- package/src/serve/security.ts +9 -0
- package/src/serve/server.ts +248 -3
- package/src/serve/session-automation.ts +146 -0
- package/src/serve/spa-production-build.ts +6 -5
- package/src/sessions/archive.ts +348 -0
- package/src/sessions/automation-state.ts +444 -0
- package/src/sessions/automation-status.ts +239 -0
- package/src/sessions/automation.ts +1169 -0
- package/src/sessions/binding.ts +105 -0
- package/src/sessions/claude-hook.ts +240 -0
- package/src/sessions/config.ts +176 -0
- package/src/sessions/format.ts +191 -0
- package/src/sessions/import-child-env.ts +8 -0
- package/src/sessions/import-child.ts +152 -0
- package/src/sessions/parsers/claude-code.ts +259 -0
- package/src/sessions/parsers/codex.ts +303 -0
- package/src/sessions/parsers/hermes.ts +248 -0
- package/src/sessions/parsers/openclaw.ts +496 -0
- package/src/sessions/parsers/shared.ts +184 -0
- package/src/sessions/sanitize.ts +222 -0
- package/src/sessions/service.ts +1533 -0
- package/src/sessions/setup.ts +477 -0
- package/src/sessions/sources.ts +518 -0
- package/src/sessions/state.ts +118 -0
- package/src/sessions/types.ts +457 -0
- package/src/store/sqlite/adapter.ts +68 -16
- package/src/store/sqlite/scoped-index.ts +9 -0
- package/browser-extension/artifacts/gno-browser-clipper-v2.4.0.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
|
+
}
|