@gmickel/gno 2.5.1 → 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 +60 -5
- package/assets/skill/README.md +5 -1
- package/assets/skill/SKILL.md +75 -1
- package/assets/skill/cli-reference.md +123 -0
- package/assets/skill/examples.md +30 -0
- package/assets/skill/mcp-reference.md +52 -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.5.1.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 +347 -24
- package/spec/mcp.md +198 -2
- package/spec/output-schemas/capture-receipt.schema.json +3 -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/cli/commands/ask.ts +14 -2
- package/src/cli/commands/capture.ts +55 -96
- 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/program.ts +388 -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/memory-remember.ts +233 -122
- package/src/core/memory-types.ts +11 -0
- package/src/core/network-boundary-inventory.ts +8 -0
- package/src/core/request-receipts.ts +671 -0
- package/src/index.ts +9 -0
- package/src/mcp/context.ts +8 -0
- package/src/mcp/http-egress.ts +4 -0
- package/src/mcp/http-transport.ts +2 -0
- package/src/mcp/tools/capture.ts +87 -83
- package/src/mcp/tools/index.ts +66 -0
- package/src/mcp/tools/memory-remember.ts +7 -0
- package/src/mcp/tools/memory-shared.ts +7 -1
- package/src/mcp/tools/request-status.ts +73 -0
- package/src/mcp/tools/sessions.ts +208 -0
- package/src/sdk/client.ts +180 -84
- package/src/sdk/index.ts +6 -0
- package/src/sdk/types.ts +54 -2
- package/src/serve/capture-service.ts +98 -32
- package/src/serve/config-sync.ts +3 -2
- package/src/serve/public/app.tsx +4 -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 +2 -0
- package/src/serve/public/pages/Dashboard.tsx +10 -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 +205 -1
- package/src/serve/session-automation.ts +146 -0
- 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 +54 -15
- package/src/store/sqlite/scoped-index.ts +9 -0
- package/browser-extension/artifacts/gno-browser-clipper-v2.5.1.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
|
+
}
|