@monotykamary/localterm-server 2.27.1 → 2.27.3
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/dist/cdp/detect-chromium.d.ts +2 -2
- package/dist/cdp/detect-chromium.d.ts.map +1 -1
- package/dist/cdp/detect-chromium.js +6 -6
- package/dist/cdp/detect-chromium.js.map +1 -1
- package/dist/git-diff.d.ts +1 -1
- package/dist/git-diff.d.ts.map +1 -1
- package/dist/git-diff.js +59 -46
- package/dist/git-diff.js.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +587 -562
- package/dist/index.js.map +1 -1
- package/dist/security.d.ts.map +1 -1
- package/dist/security.js +4 -2
- package/dist/security.js.map +1 -1
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -101,369 +101,9 @@ const safeSend = (ws, payload) => {
|
|
|
101
101
|
/* socket closed between readyState check and send */
|
|
102
102
|
}
|
|
103
103
|
};
|
|
104
|
-
|
|
105
|
-
const port = options.port ?? DEFAULT_PORT;
|
|
106
|
-
const host = options.host ?? DEFAULT_HOST;
|
|
107
|
-
const staticRoot = typeof options.staticRoot === "string" ? path.resolve(options.staticRoot) : null;
|
|
108
|
-
const isLoopbackBind = isLoopbackHost(host);
|
|
109
|
-
if (!isLoopbackBind) {
|
|
110
|
-
console.warn(`⚠ non-loopback bind (${host}): any client on the private network can open an unauthenticated shell`);
|
|
111
|
-
}
|
|
112
|
-
// The session manager owns every live PTY for the daemon's lifetime. A PTY
|
|
113
|
-
// persists across client detach (closing a tab detaches instead of killing
|
|
114
|
-
// it) so the session picker can re-attach to it; it dies on shell exit, an
|
|
115
|
-
// explicit kill from the picker, or the dormant-idle sweep. Multiple clients
|
|
116
|
-
// may attach to one PTY and fan out output/resize to all of them. The hooks
|
|
117
|
-
// close over managers defined further below; they only fire at runtime
|
|
118
|
-
// (attach/detach/output/exit), so referencing the later consts here is safe.
|
|
119
|
-
const stateDirectory = options.stateDirectory ?? path.join(os.homedir(), ".localterm");
|
|
120
|
-
const shimsDir = path.join(stateDirectory, SECRETS_SHIMS_DIRNAME);
|
|
121
|
-
const registry = new SessionManager({
|
|
122
|
-
shimsDir,
|
|
123
|
-
sendControl: safeSend,
|
|
124
|
-
hooks: {
|
|
125
|
-
onOutputActivity: () => caffeinateManager.noteOutputActivity(),
|
|
126
|
-
onSessionActivity: () => caffeinateManager.pokeAuto(),
|
|
127
|
-
onSessionEvent: (event, cwd) => sessionEventManager.onSessionEvent(event, cwd),
|
|
128
|
-
onAutomationExit: (automationId, runId, exitCode) => {
|
|
129
|
-
automationStore.updateRun(automationId, runId, {
|
|
130
|
-
status: exitCode === 0 ? "completed" : "failed",
|
|
131
|
-
exitCode,
|
|
132
|
-
finishedAt: Date.now(),
|
|
133
|
-
});
|
|
134
|
-
broadcastAutomations();
|
|
135
|
-
closeRunTabIfRequested(automationId, runId);
|
|
136
|
-
folderWatchManager.notifyRunFinished(automationId);
|
|
137
|
-
sessionEventManager.notifyRunFinished(automationId);
|
|
138
|
-
},
|
|
139
|
-
onClientExit: (ws, exitCode) => {
|
|
140
|
-
const targetId = wsToTargetId.get(ws);
|
|
141
|
-
if (targetId && (exitCode === null || exitCode === 0))
|
|
142
|
-
void cdpClient?.closeTab(targetId);
|
|
143
|
-
},
|
|
144
|
-
},
|
|
145
|
-
});
|
|
146
|
-
const app = new Hono();
|
|
147
|
-
app.use("*", createNetworkPolicyMiddleware(host, () => publicOrigin));
|
|
148
|
-
const { injectWebSocket, upgradeWebSocket, wss } = createNodeWebSocket({ app });
|
|
149
|
-
wss.options.maxPayload = 256 * 1024;
|
|
150
|
-
const automationStore = new AutomationStore(path.join(stateDirectory, "automations.json"));
|
|
151
|
-
const automationRunTracker = new AutomationRunTracker();
|
|
152
|
-
const automationScheduler = new AutomationScheduler(automationStore);
|
|
153
|
-
// Folder-watch triggers: one fs.watch per watch automation's cwd (no polling).
|
|
154
|
-
// isRunInFlight gates overlap (a launched/running latest run blocks a new
|
|
155
|
-
// launch); getAutomation re-reads live state when the debounce fires.
|
|
156
|
-
const folderWatchManager = new FolderWatchManager({
|
|
157
|
-
debounceMs: AUTOMATION_WATCH_DEBOUNCE_MS,
|
|
158
|
-
postRunGraceMs: AUTOMATION_WATCH_POST_RUN_GRACE_MS,
|
|
159
|
-
isRunInFlight: (automationId) => {
|
|
160
|
-
const status = automationStore.get(automationId)?.runs[0]?.status;
|
|
161
|
-
return status === "launched" || status === "running";
|
|
162
|
-
},
|
|
163
|
-
getAutomation: (automationId) => automationStore.get(automationId),
|
|
164
|
-
});
|
|
165
|
-
const syncFolderWatchers = () => folderWatchManager.sync(automationStore.list());
|
|
166
|
-
const sessionEventManager = new SessionEventManager({
|
|
167
|
-
debounceMs: AUTOMATION_EVENT_DEBOUNCE_MS,
|
|
168
|
-
postRunGraceMs: AUTOMATION_WATCH_POST_RUN_GRACE_MS,
|
|
169
|
-
isRunInFlight: (automationId) => {
|
|
170
|
-
const status = automationStore.get(automationId)?.runs[0]?.status;
|
|
171
|
-
return status === "launched" || status === "running";
|
|
172
|
-
},
|
|
173
|
-
getAutomation: (automationId) => automationStore.get(automationId),
|
|
174
|
-
});
|
|
175
|
-
const syncSessionEventListeners = () => sessionEventManager.sync(automationStore.list());
|
|
176
|
-
// Webhook triggers: a POST to /api/webhooks/:id arms a trailing debounce per
|
|
177
|
-
// automation (coalesces duplicate delivery) with an in-flight overlap guard.
|
|
178
|
-
// Stateless vs the watch/event managers — nothing to arm, so no sync().
|
|
179
|
-
const webhookTriggerManager = new WebhookTriggerManager({
|
|
180
|
-
debounceMs: AUTOMATION_WEBHOOK_DEBOUNCE_MS,
|
|
181
|
-
isRunInFlight: (automationId) => {
|
|
182
|
-
const status = automationStore.get(automationId)?.runs[0]?.status;
|
|
183
|
-
return status === "launched" || status === "running";
|
|
184
|
-
},
|
|
185
|
-
getAutomation: (automationId) => automationStore.get(automationId),
|
|
186
|
-
});
|
|
187
|
-
const heartbeatStore = new HeartbeatStore(path.join(stateDirectory, "daemon-heartbeat.json"));
|
|
188
|
-
const caffeinateController = options.caffeinateController ?? new CaffeinateController();
|
|
189
|
-
const caffeinatePreferencesStore = new CaffeinatePreferencesStore(path.join(stateDirectory, "caffeinate.json"));
|
|
190
|
-
const worktreeConfigStore = new WorktreeConfigStore(stateDirectory);
|
|
191
|
-
// Per-process secret injection: a backend (macOS Keychain on darwin) holds
|
|
192
|
-
// secret values; a secret is an identity + the env var it exports
|
|
193
|
-
// (~/.localterm/secrets.json, names + env var only — never values), and a
|
|
194
|
-
// process is a binary name plus the secret names it should receive
|
|
195
|
-
// (~/.localterm/processes.json) — the same multi-select model automations use
|
|
196
|
-
// for requestedSecrets. The daemon generates a PATH shim per process in
|
|
197
|
-
// ~/.localterm/shims; localterm's shell hook prepends the shims dir so the
|
|
198
|
-
// shims shadow the real binaries and inject the secret(s) at exec time.
|
|
199
|
-
// The one-time migrator rewrites a pre-flip secrets.json (with `programs`) to
|
|
200
|
-
// the new shape + a processes.json before the stores load either file.
|
|
201
|
-
migrateSecretsToProcesses(stateDirectory);
|
|
202
|
-
const secretBackend = options.secretBackend ?? createDefaultSecretBackend();
|
|
203
|
-
const secretStore = new SecretStore({
|
|
204
|
-
filePath: path.join(stateDirectory, SECRETS_FILENAME),
|
|
205
|
-
shimsDir,
|
|
206
|
-
});
|
|
207
|
-
const processStore = new ProcessStore(path.join(stateDirectory, PROCESSES_FILENAME));
|
|
208
|
-
const syncSecretShims = () => regenerateShims(processStore.list(), secretStore.envVarByName(), shimsDir, secretBackend);
|
|
209
|
-
syncSecretShims();
|
|
210
|
-
const caffeinateManager = new CaffeinateManager({
|
|
211
|
-
controller: caffeinateController,
|
|
212
|
-
store: caffeinatePreferencesStore,
|
|
213
|
-
listSessionPids: () => registry.pids(),
|
|
214
|
-
snapshotProcesses: options.caffeinateSnapshotProcesses,
|
|
215
|
-
batteryProbe: options.caffeinateBatteryProbe,
|
|
216
|
-
hasRecentOutput: (pids, withinMs) => registry.hasRecentOutput(pids, withinMs),
|
|
217
|
-
});
|
|
218
|
-
// Open dev ports: the daemon reads the process tree (ps) and the listening
|
|
219
|
-
// socket table (lsof) on demand while the ports modal is open. Both are
|
|
220
|
-
// injectable so tests can drive the list deterministically without a real
|
|
221
|
-
// listener; the tree snapshot defaults to the same `ps` read keep-awake's
|
|
222
|
-
// automatic mode uses (one shared subprocess per poll).
|
|
223
|
-
const portsSnapshotProcesses = options.portsSnapshotProcesses ?? defaultCaffeinateSnapshotProcesses;
|
|
224
|
-
const portsSnapshotListeners = options.portsSnapshotListeners ?? defaultSnapshotListeners;
|
|
225
|
-
const clientSockets = new Set();
|
|
226
|
-
// CDP target paired with each WS via the {type:"identify"} handshake, so the
|
|
227
|
-
// manager's onClientExit hook can drive closeTab on a clean shell exit for
|
|
228
|
-
// that specific socket. Per-WS (a CDP target belongs to one page); cleared
|
|
229
|
-
// on detach.
|
|
230
|
-
const wsToTargetId = new Map();
|
|
231
|
-
const cdpBackgroundTabsDisabled = process.env.LOCALTERM_DISABLE_CDP_TABS === "1";
|
|
232
|
-
// One persistent CDP socket for the daemon's lifetime — opened once at start
|
|
233
|
-
// (below), so the user clears the browser's remote-debugging prompt a single
|
|
234
|
-
// time rather than on every run. Skipped when a caller injects its own
|
|
235
|
-
// `tabController` (it owns tab control) or when disabled via env.
|
|
236
|
-
const cdpClient = options.tabController || cdpBackgroundTabsDisabled
|
|
237
|
-
? null
|
|
238
|
-
: new CdpClient({
|
|
239
|
-
detect: options.cdpDetect,
|
|
240
|
-
// Only page-type targets on the daemon's own origin get an ambient token
|
|
241
|
-
// injected — unrelated tabs the user has open in their debugged browser
|
|
242
|
-
// stay untouched. `actualPort` is bound by the http server's listen
|
|
243
|
-
// callback below; the filter is only invoked at targetCreated event
|
|
244
|
-
// time (after CdpClient.connect, which runs after listen), so it reads
|
|
245
|
-
// the resolved port rather than the pre-bind placeholder. `publicOrigin`
|
|
246
|
-
// is read live for the same reason — set post-bind via setPublicUrl.
|
|
247
|
-
tabUrlFilter: (candidateUrl) => isLocaltermTabUrl(candidateUrl, actualPort, host, publicOrigin, localOrigin),
|
|
248
|
-
});
|
|
249
|
-
const tabController = options.tabController ?? {
|
|
250
|
-
open: async (url) => {
|
|
251
|
-
// Best case: if a debug-enabled Chromium browser is running, open the run
|
|
252
|
-
// tab via CDP with `background: true` so it lands *behind* the active tab
|
|
253
|
-
// (a true background tab, no focus steal) and stays closeable. This is how
|
|
254
|
-
// browser-harness-js does it, over a connection we keep alive across runs.
|
|
255
|
-
if (cdpClient) {
|
|
256
|
-
const handle = await cdpClient.openBackgroundTab(url);
|
|
257
|
-
if (handle)
|
|
258
|
-
return handle;
|
|
259
|
-
}
|
|
260
|
-
// Fallback: the OS opener. `background: true` is macOS `open -g`, which at
|
|
261
|
-
// least keeps the browser app from coming to the foreground; ignored
|
|
262
|
-
// elsewhere. Used whenever CDP isn't available (non-Chromium default
|
|
263
|
-
// browser, remote debugging off, or LOCALTERM_DISABLE_CDP_TABS=1). Not
|
|
264
|
-
// closeable, so `closeOnFinish` is silently a no-op on this path.
|
|
265
|
-
await open(url, { background: true });
|
|
266
|
-
return null;
|
|
267
|
-
},
|
|
268
|
-
close: async (handle) => {
|
|
269
|
-
if (cdpClient)
|
|
270
|
-
await cdpClient.closeTab(handle);
|
|
271
|
-
},
|
|
272
|
-
};
|
|
273
|
-
// Maps a run id -> the tab handle that ran it, so we can close the tab when
|
|
274
|
-
// the command finishes (only set when the opener returned a closeable handle).
|
|
275
|
-
const runTabHandles = new Map();
|
|
276
|
-
// Announced REMOTE surface origin for mobile/remote tabs + the `--open`
|
|
277
|
-
// browser + the network-policy host allowlist (tailnet / portless / null =
|
|
278
|
-
// loopback). A `let` rather than a const so the CLI can swap it in after
|
|
279
|
-
// `listen` resolves the bound port and surface; `tryLaunch` and the CDP
|
|
280
|
-
// `tabUrlFilter` read it live, so a post-bind `setPublicUrl` takes effect
|
|
281
|
-
// for runs and token injection without re-wiring either closure.
|
|
282
|
-
let publicOrigin = options.publicUrl ?? null;
|
|
283
|
-
const setPublicUrl = (url) => {
|
|
284
|
-
publicOrigin = url;
|
|
285
|
-
};
|
|
286
|
-
// Announced LOCAL surface origin automation-run tabs open at — a daemon-local
|
|
287
|
-
// origin (portless / loopback) that doesn't ride the tailnet, so a flapping
|
|
288
|
-
// `tailscale serve` can't fail the run-tab load and the automation. Read live
|
|
289
|
-
// by `tryLaunch` and the CDP `tabUrlFilter` for the same reason as
|
|
290
|
-
// `publicOrigin`; falls back to `publicOrigin` (then the loopback default)
|
|
291
|
-
// when unset so a caller that only set `publicUrl` keeps the prior behavior.
|
|
292
|
-
let localOrigin = options.localUrl ?? null;
|
|
293
|
-
const setLocalUrl = (url) => {
|
|
294
|
-
localOrigin = url;
|
|
295
|
-
};
|
|
296
|
-
// Project the newest run as the legacy `lastRun` for back-compat clients.
|
|
297
|
-
const deriveLastRun = (automation) => {
|
|
298
|
-
const latest = automation.runs[0];
|
|
299
|
-
if (!latest)
|
|
300
|
-
return null;
|
|
301
|
-
return {
|
|
302
|
-
runId: latest.runId,
|
|
303
|
-
at: latest.finishedAt ?? latest.startedAt ?? latest.scheduledFor,
|
|
304
|
-
status: latest.status,
|
|
305
|
-
exitCode: latest.exitCode,
|
|
306
|
-
};
|
|
307
|
-
};
|
|
308
|
-
const toAutomationWithNextRun = (automation, from) => ({
|
|
309
|
-
...automation,
|
|
310
|
-
nextRunAt: computeNextAutomationRunAt(automation, from),
|
|
311
|
-
cron: automation.trigger.kind === "schedule" ? compileSchedule(automation.trigger.schedule) : null,
|
|
312
|
-
lastRun: deriveLastRun(automation),
|
|
313
|
-
});
|
|
314
|
-
const listAutomationsWithNextRun = () => {
|
|
315
|
-
const from = new Date();
|
|
316
|
-
return automationStore.list().map((automation) => toAutomationWithNextRun(automation, from));
|
|
317
|
-
};
|
|
318
|
-
const broadcastAutomations = () => {
|
|
319
|
-
const payload = {
|
|
320
|
-
type: "automations",
|
|
321
|
-
automations: listAutomationsWithNextRun(),
|
|
322
|
-
};
|
|
323
|
-
for (const clientSocket of clientSockets) {
|
|
324
|
-
safeSend(clientSocket, payload);
|
|
325
|
-
}
|
|
326
|
-
};
|
|
327
|
-
const caffeinateStatePayload = () => ({
|
|
328
|
-
type: "caffeinate",
|
|
329
|
-
supported: caffeinateManager.supported,
|
|
330
|
-
active: caffeinateManager.active,
|
|
331
|
-
mode: caffeinateManager.mode,
|
|
332
|
-
activityGate: caffeinateManager.activityGate,
|
|
333
|
-
batteryThreshold: caffeinateManager.batteryThreshold,
|
|
334
|
-
defaultCommands: [...caffeinateManager.defaultCommands],
|
|
335
|
-
commands: caffeinateManager.commands,
|
|
336
|
-
activeTrigger: caffeinateManager.activeTrigger,
|
|
337
|
-
});
|
|
338
|
-
// The daemon owns the single keep-awake process, so its state is broadcast to
|
|
339
|
-
// every tab — exactly like automations — and the coffee controls stay in sync.
|
|
340
|
-
const broadcastCaffeinate = () => {
|
|
341
|
-
const payload = caffeinateStatePayload();
|
|
342
|
-
for (const clientSocket of clientSockets) {
|
|
343
|
-
safeSend(clientSocket, payload);
|
|
344
|
-
}
|
|
345
|
-
};
|
|
346
|
-
caffeinateManager.on("change", broadcastCaffeinate);
|
|
347
|
-
// Open a browser tab for a run and record it in history. Scheduled and watch
|
|
348
|
-
// launches count toward the limit (and can finish the automation); manual
|
|
349
|
-
// launches never count and are allowed even on a finished/disabled automation.
|
|
350
|
-
const tryLaunch = (automation, trigger) => {
|
|
351
|
-
if (trigger !== "manual") {
|
|
352
|
-
const current = automationStore.get(automation.id);
|
|
353
|
-
if (!current || !current.enabled || current.lifecycle === "finished")
|
|
354
|
-
return null;
|
|
355
|
-
}
|
|
356
|
-
const run = automationRunTracker.create(automation);
|
|
357
|
-
const counts = trigger !== "manual";
|
|
358
|
-
automationStore.appendRun(automation.id, {
|
|
359
|
-
runId: run.runId,
|
|
360
|
-
scheduledFor: trigger === "schedule"
|
|
361
|
-
? Math.floor(run.createdAt / MS_PER_MINUTE) * MS_PER_MINUTE
|
|
362
|
-
: run.createdAt,
|
|
363
|
-
startedAt: run.createdAt,
|
|
364
|
-
finishedAt: null,
|
|
365
|
-
status: "launched",
|
|
366
|
-
exitCode: null,
|
|
367
|
-
trigger,
|
|
368
|
-
countsTowardLimit: counts,
|
|
369
|
-
});
|
|
370
|
-
if (counts)
|
|
371
|
-
automationStore.incrementRunCount(automation.id);
|
|
372
|
-
broadcastAutomations();
|
|
373
|
-
// A watch automation that just reached its limit is now "finished"; stop
|
|
374
|
-
// watching its folder promptly instead of waiting for the next mutation.
|
|
375
|
-
syncFolderWatchers();
|
|
376
|
-
syncSessionEventListeners();
|
|
377
|
-
// Open the run tab at the announced LOCAL surface origin when the CLI
|
|
378
|
-
// resolved one (portless / loopback) — run tabs open in the daemon's own
|
|
379
|
-
// debugged browser, where a flapping `tailscale serve` (laptop wake, DERP
|
|
380
|
-
// relay, cert renewal) would fail the tab load and the automation, so they
|
|
381
|
-
// never ride the tailnet even when `publicOrigin` is the tailnet URL. Fall
|
|
382
|
-
// back to `publicOrigin` (then the loopback form) so a caller that only set
|
|
383
|
-
// `publicUrl` keeps the prior single-surface behavior. A bare origin (no
|
|
384
|
-
// path) is the contract, so the `new URL` base rewrites any stray path and
|
|
385
|
-
// searchParams encodes the id.
|
|
386
|
-
const runUrl = new URL(localOrigin ?? publicOrigin ?? `http://${FRIENDLY_HOSTNAME}:${actualPort}`);
|
|
387
|
-
runUrl.searchParams.set(AUTOMATION_RUN_QUERY_PARAM, run.runId);
|
|
388
|
-
// Resolve requested secrets before opening the run tab so the env is set on
|
|
389
|
-
// the pending run by the time the WS claims it. The claim happens only after
|
|
390
|
-
// the browser loads this tab, which is gated on the resolution below, so
|
|
391
|
-
// `onOpen` always sees the resolved env. The launch stays synchronous up to
|
|
392
|
-
// here — the pending run + "launched" history are already recorded, so the
|
|
393
|
-
// `isRunInFlight` overlap guard holds across the await. A secret-resolution
|
|
394
|
-
// error is logged but does not block the tab (the run still starts, just
|
|
395
|
-
// without the failed secret).
|
|
396
|
-
void (async () => {
|
|
397
|
-
try {
|
|
398
|
-
const secretEnv = await buildAutomationSecretEnv(automation.requestedSecrets, secretStore, secretBackend);
|
|
399
|
-
automationRunTracker.setEnv(run.runId, secretEnv);
|
|
400
|
-
}
|
|
401
|
-
catch (error) {
|
|
402
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
403
|
-
console.warn(`failed to resolve secrets for automation "${automation.name}": ${message}`);
|
|
404
|
-
}
|
|
405
|
-
try {
|
|
406
|
-
const handle = await tabController.open(runUrl.href);
|
|
407
|
-
// Remember the tab so `automation-exit` can close it if closeOnFinish.
|
|
408
|
-
if (handle)
|
|
409
|
-
runTabHandles.set(run.runId, handle);
|
|
410
|
-
}
|
|
411
|
-
catch (error) {
|
|
412
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
413
|
-
console.warn(`failed to open a browser tab for automation "${automation.name}": ${message}`);
|
|
414
|
-
}
|
|
415
|
-
})();
|
|
416
|
-
return run;
|
|
417
|
-
};
|
|
418
|
-
// Close a finished run's tab when the automation opted into closeOnFinish.
|
|
419
|
-
const closeRunTabIfRequested = (automationId, runId) => {
|
|
420
|
-
const handle = runTabHandles.get(runId);
|
|
421
|
-
runTabHandles.delete(runId);
|
|
422
|
-
if (!handle)
|
|
423
|
-
return;
|
|
424
|
-
const automation = automationStore.get(automationId);
|
|
425
|
-
if (!automation?.closeOnFinish)
|
|
426
|
-
return;
|
|
427
|
-
void tabController.close(handle).catch((error) => {
|
|
428
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
429
|
-
console.warn(`failed to close automation tab (run ${runId}): ${message}`);
|
|
430
|
-
});
|
|
431
|
-
};
|
|
432
|
-
// On boot, settle the state the dead process left behind: any run still
|
|
433
|
-
// "launched"/"running" can never resume (the run tracker is in-memory), so it
|
|
434
|
-
// becomes "missed"; and if the daemon was down across scheduled times, record
|
|
435
|
-
// those as "skipped" so the user can see what didn't run while the machine was
|
|
436
|
-
// off. Skipped runs never launch and never count toward a limit. No clients
|
|
437
|
-
// exist yet, so nothing is broadcast.
|
|
438
|
-
const reconcileOnStartup = (now) => {
|
|
439
|
-
const lastAliveAt = heartbeatStore.read();
|
|
440
|
-
const hadOutage = lastAliveAt !== null && now - lastAliveAt >= AUTOMATION_RECONCILE_MIN_DOWNTIME_MS;
|
|
441
|
-
for (const automation of automationStore.list()) {
|
|
442
|
-
for (const run of automation.runs) {
|
|
443
|
-
if (run.status === "launched" || run.status === "running") {
|
|
444
|
-
automationStore.updateRun(automation.id, run.runId, {
|
|
445
|
-
status: "missed",
|
|
446
|
-
finishedAt: now,
|
|
447
|
-
});
|
|
448
|
-
}
|
|
449
|
-
}
|
|
450
|
-
if (!hadOutage || !automation.enabled || automation.lifecycle === "finished")
|
|
451
|
-
continue;
|
|
452
|
-
for (const occurrence of enumerateMissedOccurrences(automation, lastAliveAt, now)) {
|
|
453
|
-
automationStore.appendRun(automation.id, {
|
|
454
|
-
runId: randomUUID(),
|
|
455
|
-
scheduledFor: occurrence,
|
|
456
|
-
startedAt: null,
|
|
457
|
-
finishedAt: occurrence,
|
|
458
|
-
status: "skipped",
|
|
459
|
-
exitCode: null,
|
|
460
|
-
trigger: "schedule",
|
|
461
|
-
countsTowardLimit: false,
|
|
462
|
-
});
|
|
463
|
-
}
|
|
464
|
-
}
|
|
465
|
-
};
|
|
104
|
+
const buildApiRoutes = (ctx) => {
|
|
466
105
|
const api = new Hono();
|
|
106
|
+
const { registry, cdpClient, secretBackend, secretStore, shimsDir, processStore, syncSecretShims, automationStore, broadcastAutomations, syncFolderWatchers, syncSessionEventListeners, webhookTriggerManager, worktreeConfigStore, portsSnapshotProcesses, portsSnapshotListeners, toAutomationWithNextRun, listAutomationsWithNextRun, tryLaunch, } = ctx;
|
|
467
107
|
api.get("/health", (context) => context.json({
|
|
468
108
|
ok: true,
|
|
469
109
|
sessions: registry.size(),
|
|
@@ -881,220 +521,605 @@ export const createServer = async (options = {}) => {
|
|
|
881
521
|
return context.json({ error: "invalid_body" }, HTTP_STATUS_BAD_REQUEST);
|
|
882
522
|
return context.json(await worktreeConfigStore.update(cwd, parsed.data));
|
|
883
523
|
});
|
|
884
|
-
// Repo-root `.worktreeinclude` file: a gitignore-syntax allowlist of gitignored
|
|
885
|
-
// files copied from the main worktree into each fresh worktree. Exposed so the
|
|
886
|
-
// UI can show the current contents (or an empty editor when none exists) and
|
|
887
|
-
// let the user create or update it.
|
|
888
|
-
api.get("/git/worktrees/include-file", async (context) => {
|
|
889
|
-
const cwd = resolveCwdQuery(context.req.query("cwd"));
|
|
890
|
-
if (!cwd)
|
|
891
|
-
return context.json({ error: "invalid_cwd" }, HTTP_STATUS_BAD_REQUEST);
|
|
892
|
-
const file = await readWorktreeIncludeFile(cwd);
|
|
893
|
-
if (!file)
|
|
894
|
-
return context.json({ error: "not_a_git_repo" }, HTTP_STATUS_BAD_REQUEST);
|
|
895
|
-
return context.json(file);
|
|
524
|
+
// Repo-root `.worktreeinclude` file: a gitignore-syntax allowlist of gitignored
|
|
525
|
+
// files copied from the main worktree into each fresh worktree. Exposed so the
|
|
526
|
+
// UI can show the current contents (or an empty editor when none exists) and
|
|
527
|
+
// let the user create or update it.
|
|
528
|
+
api.get("/git/worktrees/include-file", async (context) => {
|
|
529
|
+
const cwd = resolveCwdQuery(context.req.query("cwd"));
|
|
530
|
+
if (!cwd)
|
|
531
|
+
return context.json({ error: "invalid_cwd" }, HTTP_STATUS_BAD_REQUEST);
|
|
532
|
+
const file = await readWorktreeIncludeFile(cwd);
|
|
533
|
+
if (!file)
|
|
534
|
+
return context.json({ error: "not_a_git_repo" }, HTTP_STATUS_BAD_REQUEST);
|
|
535
|
+
return context.json(file);
|
|
536
|
+
});
|
|
537
|
+
api.put("/git/worktrees/include-file", async (context) => {
|
|
538
|
+
const cwd = resolveCwdQuery(context.req.query("cwd"));
|
|
539
|
+
if (!cwd)
|
|
540
|
+
return context.json({ error: "invalid_cwd" }, HTTP_STATUS_BAD_REQUEST);
|
|
541
|
+
const parsed = worktreeIncludeFileInputSchema.safeParse(await readJsonBody(context));
|
|
542
|
+
if (!parsed.success)
|
|
543
|
+
return context.json({ error: "invalid_body" }, HTTP_STATUS_BAD_REQUEST);
|
|
544
|
+
const file = await writeWorktreeIncludeFile(cwd, parsed.data.content);
|
|
545
|
+
if (!file)
|
|
546
|
+
return context.json({ error: "not_a_git_repo" }, HTTP_STATUS_BAD_REQUEST);
|
|
547
|
+
return context.json(file);
|
|
548
|
+
});
|
|
549
|
+
// Remove stale, clean, auto-created worktrees older than the sweep threshold.
|
|
550
|
+
// Never removes the current/main worktree or any the user made manually; a
|
|
551
|
+
// dirty worktree is left untouched. Returns the paths removed. The branch
|
|
552
|
+
// refs survive `git worktree remove`, so swept work is recoverable.
|
|
553
|
+
api.post("/git/worktrees/sweep", async (context) => {
|
|
554
|
+
const cwd = resolveCwdQuery(context.req.query("cwd"));
|
|
555
|
+
if (!cwd)
|
|
556
|
+
return context.json({ error: "invalid_cwd" }, HTTP_STATUS_BAD_REQUEST);
|
|
557
|
+
try {
|
|
558
|
+
return context.json(await sweepStaleWorktrees(cwd, Date.now(), (worktreePath) => registry.sessionsInPath(worktreePath).length > 0));
|
|
559
|
+
}
|
|
560
|
+
catch (error) {
|
|
561
|
+
return context.json({ error: "git_failed", message: worktreeErrorMessage(error) }, HTTP_STATUS_BAD_REQUEST);
|
|
562
|
+
}
|
|
563
|
+
});
|
|
564
|
+
// Launch an external command (an "Open in…" entry) detached in a worktree via
|
|
565
|
+
// the user's login shell so rc-sourced PATH entries resolve. Fire-and-forget:
|
|
566
|
+
// the daemon never waits on the launched process, and its output is discarded
|
|
567
|
+
// (these are GUI launches like `code .`, `fork .`). The daemon already hands
|
|
568
|
+
// out unrestricted shells, so running a user-configured command is not an
|
|
569
|
+
// escalation.
|
|
570
|
+
api.post("/launch", async (context) => {
|
|
571
|
+
const parsed = launchInputSchema.safeParse(await readJsonBody(context));
|
|
572
|
+
if (!parsed.success)
|
|
573
|
+
return context.json({ error: "invalid_body" }, HTTP_STATUS_BAD_REQUEST);
|
|
574
|
+
if (!resolveCwdQuery(parsed.data.cwd)) {
|
|
575
|
+
return context.json({ error: "invalid_cwd" }, HTTP_STATUS_BAD_REQUEST);
|
|
576
|
+
}
|
|
577
|
+
try {
|
|
578
|
+
const child = spawn(getDefaultShell(), ["-l", "-c", parsed.data.command], {
|
|
579
|
+
cwd: parsed.data.cwd,
|
|
580
|
+
detached: true,
|
|
581
|
+
stdio: "ignore",
|
|
582
|
+
env: { ...process.env, PATH: shellPathForUserShell() },
|
|
583
|
+
});
|
|
584
|
+
child.unref();
|
|
585
|
+
}
|
|
586
|
+
catch (error) {
|
|
587
|
+
return context.json({ error: "launch_failed", message: worktreeErrorMessage(error) }, HTTP_STATUS_BAD_REQUEST);
|
|
588
|
+
}
|
|
589
|
+
return context.json({ ok: true });
|
|
590
|
+
});
|
|
591
|
+
api.delete("/git/worktrees", async (context) => {
|
|
592
|
+
const cwd = resolveCwdQuery(context.req.query("cwd"));
|
|
593
|
+
if (!cwd)
|
|
594
|
+
return context.json({ error: "invalid_cwd" }, HTTP_STATUS_BAD_REQUEST);
|
|
595
|
+
const targetPath = resolveWorktreePath(cwd, context.req.query("path"));
|
|
596
|
+
if (!targetPath)
|
|
597
|
+
return context.json({ error: "invalid_path" }, HTTP_STATUS_BAD_REQUEST);
|
|
598
|
+
// A live shell sitting in the worktree (attached, dormant in the
|
|
599
|
+
// no-clients grace window, or running an automation) blocks removal —
|
|
600
|
+
// `git worktree remove` would pull the directory out from under the PTY.
|
|
601
|
+
const sessionsOnWorktree = registry.sessionsInPath(targetPath);
|
|
602
|
+
if (sessionsOnWorktree.length > 0) {
|
|
603
|
+
const count = sessionsOnWorktree.length;
|
|
604
|
+
return context.json({
|
|
605
|
+
error: "active_pty",
|
|
606
|
+
message: `${count} shell${count === 1 ? "" : "s"} still open in this worktree — close ${count === 1 ? "it" : "them"} first`,
|
|
607
|
+
}, HTTP_STATUS_CONFLICT);
|
|
608
|
+
}
|
|
609
|
+
try {
|
|
610
|
+
await removeGitWorktree(cwd, targetPath);
|
|
611
|
+
return context.json({ ok: true });
|
|
612
|
+
}
|
|
613
|
+
catch (error) {
|
|
614
|
+
return context.json({ error: "git_failed", message: worktreeErrorMessage(error) }, HTTP_STATUS_BAD_REQUEST);
|
|
615
|
+
}
|
|
616
|
+
});
|
|
617
|
+
// A trigger is valid iff a schedule trigger compiles to ≥1 parseable cron;
|
|
618
|
+
// watch and event triggers are always valid (their cwd is validated
|
|
619
|
+
// separately).
|
|
620
|
+
const isValidTriggerInput = (trigger) => {
|
|
621
|
+
const normalized = normalizeTriggerInput(trigger);
|
|
622
|
+
if (normalized.kind === "watch" || normalized.kind === "event" || normalized.kind === "webhook")
|
|
623
|
+
return true;
|
|
624
|
+
const crons = compileScheduleAll(normalized.schedule);
|
|
625
|
+
return crons.length > 0 && crons.every((cron) => parseCronExpression(cron) !== null);
|
|
626
|
+
};
|
|
627
|
+
api.get("/automations", (context) => context.json({ automations: listAutomationsWithNextRun() }));
|
|
628
|
+
api.post("/automations", async (context) => {
|
|
629
|
+
const parsed = createAutomationInputSchema.safeParse(await readJsonBody(context));
|
|
630
|
+
if (!parsed.success)
|
|
631
|
+
return context.json({ error: "invalid_body" }, HTTP_STATUS_BAD_REQUEST);
|
|
632
|
+
if (automationStore.size() >= MAX_AUTOMATIONS) {
|
|
633
|
+
return context.json({ error: "too_many_automations" }, HTTP_STATUS_BAD_REQUEST);
|
|
634
|
+
}
|
|
635
|
+
if (!isValidTriggerInput(parsed.data.trigger)) {
|
|
636
|
+
return context.json({ error: "invalid_schedule" }, HTTP_STATUS_BAD_REQUEST);
|
|
637
|
+
}
|
|
638
|
+
if (!resolveCwdQuery(parsed.data.cwd)) {
|
|
639
|
+
return context.json({ error: "invalid_cwd" }, HTTP_STATUS_BAD_REQUEST);
|
|
640
|
+
}
|
|
641
|
+
if (parsed.data.requestedSecrets !== undefined) {
|
|
642
|
+
const unknown = unknownRequestedSecrets(parsed.data.requestedSecrets);
|
|
643
|
+
if (unknown.length > 0)
|
|
644
|
+
return context.json({ error: "invalid_secret" }, HTTP_STATUS_BAD_REQUEST);
|
|
645
|
+
}
|
|
646
|
+
const automation = automationStore.create(parsed.data);
|
|
647
|
+
broadcastAutomations();
|
|
648
|
+
syncFolderWatchers();
|
|
649
|
+
syncSessionEventListeners();
|
|
650
|
+
return context.json({ automation: toAutomationWithNextRun(automation, new Date()) }, HTTP_STATUS_CREATED);
|
|
651
|
+
});
|
|
652
|
+
api.patch("/automations/:id", async (context) => {
|
|
653
|
+
const parsed = updateAutomationInputSchema.safeParse(await readJsonBody(context));
|
|
654
|
+
if (!parsed.success)
|
|
655
|
+
return context.json({ error: "invalid_body" }, HTTP_STATUS_BAD_REQUEST);
|
|
656
|
+
if (parsed.data.trigger !== undefined && !isValidTriggerInput(parsed.data.trigger)) {
|
|
657
|
+
return context.json({ error: "invalid_schedule" }, HTTP_STATUS_BAD_REQUEST);
|
|
658
|
+
}
|
|
659
|
+
if (parsed.data.cwd !== undefined && !resolveCwdQuery(parsed.data.cwd)) {
|
|
660
|
+
return context.json({ error: "invalid_cwd" }, HTTP_STATUS_BAD_REQUEST);
|
|
661
|
+
}
|
|
662
|
+
if (parsed.data.requestedSecrets !== undefined) {
|
|
663
|
+
const unknown = unknownRequestedSecrets(parsed.data.requestedSecrets);
|
|
664
|
+
if (unknown.length > 0)
|
|
665
|
+
return context.json({ error: "invalid_secret" }, HTTP_STATUS_BAD_REQUEST);
|
|
666
|
+
}
|
|
667
|
+
const existing = automationStore.get(context.req.param("id"));
|
|
668
|
+
if (!existing)
|
|
669
|
+
return context.json({ error: "not_found" }, HTTP_STATUS_NOT_FOUND);
|
|
670
|
+
// A PATCH never un-finishes — re-enabling a finished automation must go
|
|
671
|
+
// through reset so it can't accidentally fire past its limit.
|
|
672
|
+
if (existing.lifecycle === "finished" && parsed.data.enabled === true) {
|
|
673
|
+
return context.json({ error: "automation_finished" }, HTTP_STATUS_BAD_REQUEST);
|
|
674
|
+
}
|
|
675
|
+
const automation = automationStore.update(context.req.param("id"), parsed.data);
|
|
676
|
+
if (!automation)
|
|
677
|
+
return context.json({ error: "not_found" }, HTTP_STATUS_NOT_FOUND);
|
|
678
|
+
broadcastAutomations();
|
|
679
|
+
syncFolderWatchers();
|
|
680
|
+
syncSessionEventListeners();
|
|
681
|
+
return context.json({ automation: toAutomationWithNextRun(automation, new Date()) });
|
|
682
|
+
});
|
|
683
|
+
api.delete("/automations/:id", (context) => {
|
|
684
|
+
if (!automationStore.remove(context.req.param("id"))) {
|
|
685
|
+
return context.json({ error: "not_found" }, HTTP_STATUS_NOT_FOUND);
|
|
686
|
+
}
|
|
687
|
+
broadcastAutomations();
|
|
688
|
+
syncFolderWatchers();
|
|
689
|
+
syncSessionEventListeners();
|
|
690
|
+
return context.json({ ok: true });
|
|
691
|
+
});
|
|
692
|
+
api.post("/automations/:id/run", (context) => {
|
|
693
|
+
const automation = automationStore.get(context.req.param("id"));
|
|
694
|
+
if (!automation)
|
|
695
|
+
return context.json({ error: "not_found" }, HTTP_STATUS_NOT_FOUND);
|
|
696
|
+
// tryLaunch only returns null for non-manual triggers (the finished/disabled
|
|
697
|
+
// guard); a manual launch always succeeds. The guard satisfies the shared
|
|
698
|
+
// nullable return type before reading run.runId.
|
|
699
|
+
const run = tryLaunch(automation, "manual");
|
|
700
|
+
if (!run)
|
|
701
|
+
return context.json({ error: "launch_failed" }, HTTP_STATUS_BAD_REQUEST);
|
|
702
|
+
return context.json({ runId: run.runId });
|
|
703
|
+
});
|
|
704
|
+
api.post("/automations/:id/reset", async (context) => {
|
|
705
|
+
const parsed = resetAutomationInputSchema.safeParse((await readJsonBody(context)) ?? {});
|
|
706
|
+
if (!parsed.success)
|
|
707
|
+
return context.json({ error: "invalid_body" }, HTTP_STATUS_BAD_REQUEST);
|
|
708
|
+
const automation = automationStore.reset(context.req.param("id"), parsed.data.clearHistory);
|
|
709
|
+
if (!automation)
|
|
710
|
+
return context.json({ error: "not_found" }, HTTP_STATUS_NOT_FOUND);
|
|
711
|
+
broadcastAutomations();
|
|
712
|
+
// Reset re-enables + reactivates; a watch automation resumes watching.
|
|
713
|
+
syncFolderWatchers();
|
|
714
|
+
syncSessionEventListeners();
|
|
715
|
+
return context.json({ automation: toAutomationWithNextRun(automation, new Date()) });
|
|
716
|
+
});
|
|
717
|
+
// Fire a webhook-triggered automation. The :id is the automation's webhook
|
|
718
|
+
// capability token (Discord-style: anyone with the URL can fire it). The body
|
|
719
|
+
// is intentionally ignored — the command/cwd are fixed at create time, so a
|
|
720
|
+
// webhook is a pure signal like schedule/watch/event. The network policy
|
|
721
|
+
// middleware already gates this to the bound surface (loopback, or any
|
|
722
|
+
// private host on a tailnet/non-loopback bind, which covers tailscale's
|
|
723
|
+
// 100.64.0.0/10 CGNAT range), so a POST from another tailnet device reaches
|
|
724
|
+
// it with no extra wiring. Always 2xx on a valid+active id so a CI retry
|
|
725
|
+
// loop never amplifies: duplicates inside the debounce window coalesce, and a
|
|
726
|
+
// POST while a run is in flight is silently dropped (both return 202).
|
|
727
|
+
api.post("/webhooks/:id", (context) => {
|
|
728
|
+
const automation = automationStore.getByWebhookId(context.req.param("id"));
|
|
729
|
+
if (!automation)
|
|
730
|
+
return context.json({ error: "not_found" }, HTTP_STATUS_NOT_FOUND);
|
|
731
|
+
if (!automation.enabled || automation.lifecycle === "finished") {
|
|
732
|
+
return context.json({ error: "automation_not_active" }, HTTP_STATUS_CONFLICT);
|
|
733
|
+
}
|
|
734
|
+
webhookTriggerManager.trigger(automation);
|
|
735
|
+
return context.json({ accepted: true }, HTTP_STATUS_ACCEPTED);
|
|
736
|
+
});
|
|
737
|
+
api.notFound((context) => context.json({ error: "not_found" }, HTTP_STATUS_NOT_FOUND));
|
|
738
|
+
return api;
|
|
739
|
+
};
|
|
740
|
+
export const createServer = async (options = {}) => {
|
|
741
|
+
const port = options.port ?? DEFAULT_PORT;
|
|
742
|
+
const host = options.host ?? DEFAULT_HOST;
|
|
743
|
+
const staticRoot = typeof options.staticRoot === "string" ? path.resolve(options.staticRoot) : null;
|
|
744
|
+
const isLoopbackBind = isLoopbackHost(host);
|
|
745
|
+
if (!isLoopbackBind) {
|
|
746
|
+
console.warn(`⚠ non-loopback bind (${host}): any client on the private network can open an unauthenticated shell`);
|
|
747
|
+
}
|
|
748
|
+
// The session manager owns every live PTY for the daemon's lifetime. A PTY
|
|
749
|
+
// persists across client detach (closing a tab detaches instead of killing
|
|
750
|
+
// it) so the session picker can re-attach to it; it dies on shell exit, an
|
|
751
|
+
// explicit kill from the picker, or the dormant-idle sweep. Multiple clients
|
|
752
|
+
// may attach to one PTY and fan out output/resize to all of them. The hooks
|
|
753
|
+
// close over managers defined further below; they only fire at runtime
|
|
754
|
+
// (attach/detach/output/exit), so referencing the later consts here is safe.
|
|
755
|
+
const stateDirectory = options.stateDirectory ?? path.join(os.homedir(), ".localterm");
|
|
756
|
+
const shimsDir = path.join(stateDirectory, SECRETS_SHIMS_DIRNAME);
|
|
757
|
+
const registry = new SessionManager({
|
|
758
|
+
shimsDir,
|
|
759
|
+
sendControl: safeSend,
|
|
760
|
+
hooks: {
|
|
761
|
+
onOutputActivity: () => caffeinateManager.noteOutputActivity(),
|
|
762
|
+
onSessionActivity: () => caffeinateManager.pokeAuto(),
|
|
763
|
+
onSessionEvent: (event, cwd) => sessionEventManager.onSessionEvent(event, cwd),
|
|
764
|
+
onAutomationExit: (automationId, runId, exitCode) => {
|
|
765
|
+
automationStore.updateRun(automationId, runId, {
|
|
766
|
+
status: exitCode === 0 ? "completed" : "failed",
|
|
767
|
+
exitCode,
|
|
768
|
+
finishedAt: Date.now(),
|
|
769
|
+
});
|
|
770
|
+
broadcastAutomations();
|
|
771
|
+
closeRunTabIfRequested(automationId, runId);
|
|
772
|
+
folderWatchManager.notifyRunFinished(automationId);
|
|
773
|
+
sessionEventManager.notifyRunFinished(automationId);
|
|
774
|
+
},
|
|
775
|
+
onClientExit: (ws, exitCode) => {
|
|
776
|
+
const targetId = wsToTargetId.get(ws);
|
|
777
|
+
if (targetId && (exitCode === null || exitCode === 0))
|
|
778
|
+
void cdpClient?.closeTab(targetId);
|
|
779
|
+
},
|
|
780
|
+
},
|
|
781
|
+
});
|
|
782
|
+
const app = new Hono();
|
|
783
|
+
app.use("*", createNetworkPolicyMiddleware(host, () => publicOrigin));
|
|
784
|
+
const { injectWebSocket, upgradeWebSocket, wss } = createNodeWebSocket({ app });
|
|
785
|
+
wss.options.maxPayload = 256 * 1024;
|
|
786
|
+
const automationStore = new AutomationStore(path.join(stateDirectory, "automations.json"));
|
|
787
|
+
const automationRunTracker = new AutomationRunTracker();
|
|
788
|
+
const automationScheduler = new AutomationScheduler(automationStore);
|
|
789
|
+
// Folder-watch triggers: one fs.watch per watch automation's cwd (no polling).
|
|
790
|
+
// isRunInFlight gates overlap (a launched/running latest run blocks a new
|
|
791
|
+
// launch); getAutomation re-reads live state when the debounce fires.
|
|
792
|
+
const folderWatchManager = new FolderWatchManager({
|
|
793
|
+
debounceMs: AUTOMATION_WATCH_DEBOUNCE_MS,
|
|
794
|
+
postRunGraceMs: AUTOMATION_WATCH_POST_RUN_GRACE_MS,
|
|
795
|
+
isRunInFlight: (automationId) => {
|
|
796
|
+
const status = automationStore.get(automationId)?.runs[0]?.status;
|
|
797
|
+
return status === "launched" || status === "running";
|
|
798
|
+
},
|
|
799
|
+
getAutomation: (automationId) => automationStore.get(automationId),
|
|
896
800
|
});
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
return
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
return context.json({ error: "not_a_git_repo" }, HTTP_STATUS_BAD_REQUEST);
|
|
907
|
-
return context.json(file);
|
|
801
|
+
const syncFolderWatchers = () => folderWatchManager.sync(automationStore.list());
|
|
802
|
+
const sessionEventManager = new SessionEventManager({
|
|
803
|
+
debounceMs: AUTOMATION_EVENT_DEBOUNCE_MS,
|
|
804
|
+
postRunGraceMs: AUTOMATION_WATCH_POST_RUN_GRACE_MS,
|
|
805
|
+
isRunInFlight: (automationId) => {
|
|
806
|
+
const status = automationStore.get(automationId)?.runs[0]?.status;
|
|
807
|
+
return status === "launched" || status === "running";
|
|
808
|
+
},
|
|
809
|
+
getAutomation: (automationId) => automationStore.get(automationId),
|
|
908
810
|
});
|
|
909
|
-
|
|
910
|
-
//
|
|
911
|
-
//
|
|
912
|
-
//
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
catch (error) {
|
|
921
|
-
return context.json({ error: "git_failed", message: worktreeErrorMessage(error) }, HTTP_STATUS_BAD_REQUEST);
|
|
922
|
-
}
|
|
811
|
+
const syncSessionEventListeners = () => sessionEventManager.sync(automationStore.list());
|
|
812
|
+
// Webhook triggers: a POST to /api/webhooks/:id arms a trailing debounce per
|
|
813
|
+
// automation (coalesces duplicate delivery) with an in-flight overlap guard.
|
|
814
|
+
// Stateless vs the watch/event managers — nothing to arm, so no sync().
|
|
815
|
+
const webhookTriggerManager = new WebhookTriggerManager({
|
|
816
|
+
debounceMs: AUTOMATION_WEBHOOK_DEBOUNCE_MS,
|
|
817
|
+
isRunInFlight: (automationId) => {
|
|
818
|
+
const status = automationStore.get(automationId)?.runs[0]?.status;
|
|
819
|
+
return status === "launched" || status === "running";
|
|
820
|
+
},
|
|
821
|
+
getAutomation: (automationId) => automationStore.get(automationId),
|
|
923
822
|
});
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
//
|
|
929
|
-
//
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
});
|
|
944
|
-
child.unref();
|
|
945
|
-
}
|
|
946
|
-
catch (error) {
|
|
947
|
-
return context.json({ error: "launch_failed", message: worktreeErrorMessage(error) }, HTTP_STATUS_BAD_REQUEST);
|
|
948
|
-
}
|
|
949
|
-
return context.json({ ok: true });
|
|
823
|
+
const heartbeatStore = new HeartbeatStore(path.join(stateDirectory, "daemon-heartbeat.json"));
|
|
824
|
+
const caffeinateController = options.caffeinateController ?? new CaffeinateController();
|
|
825
|
+
const caffeinatePreferencesStore = new CaffeinatePreferencesStore(path.join(stateDirectory, "caffeinate.json"));
|
|
826
|
+
const worktreeConfigStore = new WorktreeConfigStore(stateDirectory);
|
|
827
|
+
// Per-process secret injection: a backend (macOS Keychain on darwin) holds
|
|
828
|
+
// secret values; a secret is an identity + the env var it exports
|
|
829
|
+
// (~/.localterm/secrets.json, names + env var only — never values), and a
|
|
830
|
+
// process is a binary name plus the secret names it should receive
|
|
831
|
+
// (~/.localterm/processes.json) — the same multi-select model automations use
|
|
832
|
+
// for requestedSecrets. The daemon generates a PATH shim per process in
|
|
833
|
+
// ~/.localterm/shims; localterm's shell hook prepends the shims dir so the
|
|
834
|
+
// shims shadow the real binaries and inject the secret(s) at exec time.
|
|
835
|
+
// The one-time migrator rewrites a pre-flip secrets.json (with `programs`) to
|
|
836
|
+
// the new shape + a processes.json before the stores load either file.
|
|
837
|
+
migrateSecretsToProcesses(stateDirectory);
|
|
838
|
+
const secretBackend = options.secretBackend ?? createDefaultSecretBackend();
|
|
839
|
+
const secretStore = new SecretStore({
|
|
840
|
+
filePath: path.join(stateDirectory, SECRETS_FILENAME),
|
|
841
|
+
shimsDir,
|
|
950
842
|
});
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
const sessionsOnWorktree = registry.sessionsInPath(targetPath);
|
|
962
|
-
if (sessionsOnWorktree.length > 0) {
|
|
963
|
-
const count = sessionsOnWorktree.length;
|
|
964
|
-
return context.json({
|
|
965
|
-
error: "active_pty",
|
|
966
|
-
message: `${count} shell${count === 1 ? "" : "s"} still open in this worktree — close ${count === 1 ? "it" : "them"} first`,
|
|
967
|
-
}, HTTP_STATUS_CONFLICT);
|
|
968
|
-
}
|
|
969
|
-
try {
|
|
970
|
-
await removeGitWorktree(cwd, targetPath);
|
|
971
|
-
return context.json({ ok: true });
|
|
972
|
-
}
|
|
973
|
-
catch (error) {
|
|
974
|
-
return context.json({ error: "git_failed", message: worktreeErrorMessage(error) }, HTTP_STATUS_BAD_REQUEST);
|
|
975
|
-
}
|
|
843
|
+
const processStore = new ProcessStore(path.join(stateDirectory, PROCESSES_FILENAME));
|
|
844
|
+
const syncSecretShims = () => regenerateShims(processStore.list(), secretStore.envVarByName(), shimsDir, secretBackend);
|
|
845
|
+
syncSecretShims();
|
|
846
|
+
const caffeinateManager = new CaffeinateManager({
|
|
847
|
+
controller: caffeinateController,
|
|
848
|
+
store: caffeinatePreferencesStore,
|
|
849
|
+
listSessionPids: () => registry.pids(),
|
|
850
|
+
snapshotProcesses: options.caffeinateSnapshotProcesses,
|
|
851
|
+
batteryProbe: options.caffeinateBatteryProbe,
|
|
852
|
+
hasRecentOutput: (pids, withinMs) => registry.hasRecentOutput(pids, withinMs),
|
|
976
853
|
});
|
|
977
|
-
//
|
|
978
|
-
//
|
|
979
|
-
//
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
854
|
+
// Open dev ports: the daemon reads the process tree (ps) and the listening
|
|
855
|
+
// socket table (lsof) on demand while the ports modal is open. Both are
|
|
856
|
+
// injectable so tests can drive the list deterministically without a real
|
|
857
|
+
// listener; the tree snapshot defaults to the same `ps` read keep-awake's
|
|
858
|
+
// automatic mode uses (one shared subprocess per poll).
|
|
859
|
+
const portsSnapshotProcesses = options.portsSnapshotProcesses ?? defaultCaffeinateSnapshotProcesses;
|
|
860
|
+
const portsSnapshotListeners = options.portsSnapshotListeners ?? defaultSnapshotListeners;
|
|
861
|
+
const clientSockets = new Set();
|
|
862
|
+
// CDP target paired with each WS via the {type:"identify"} handshake, so the
|
|
863
|
+
// manager's onClientExit hook can drive closeTab on a clean shell exit for
|
|
864
|
+
// that specific socket. Per-WS (a CDP target belongs to one page); cleared
|
|
865
|
+
// on detach.
|
|
866
|
+
const wsToTargetId = new Map();
|
|
867
|
+
const cdpBackgroundTabsDisabled = process.env.LOCALTERM_DISABLE_CDP_TABS === "1";
|
|
868
|
+
// One persistent CDP socket for the daemon's lifetime — opened once at start
|
|
869
|
+
// (below), so the user clears the browser's remote-debugging prompt a single
|
|
870
|
+
// time rather than on every run. Skipped when a caller injects its own
|
|
871
|
+
// `tabController` (it owns tab control) or when disabled via env.
|
|
872
|
+
const cdpClient = options.tabController || cdpBackgroundTabsDisabled
|
|
873
|
+
? null
|
|
874
|
+
: new CdpClient({
|
|
875
|
+
detect: options.cdpDetect,
|
|
876
|
+
// Only page-type targets on the daemon's own origin get an ambient token
|
|
877
|
+
// injected — unrelated tabs the user has open in their debugged browser
|
|
878
|
+
// stay untouched. `actualPort` is bound by the http server's listen
|
|
879
|
+
// callback below; the filter is only invoked at targetCreated event
|
|
880
|
+
// time (after CdpClient.connect, which runs after listen), so it reads
|
|
881
|
+
// the resolved port rather than the pre-bind placeholder. `publicOrigin`
|
|
882
|
+
// is read live for the same reason — set post-bind via setPublicUrl.
|
|
883
|
+
tabUrlFilter: (candidateUrl) => isLocaltermTabUrl(candidateUrl, actualPort, host, publicOrigin, localOrigin),
|
|
884
|
+
});
|
|
885
|
+
const tabController = options.tabController ?? {
|
|
886
|
+
open: async (url) => {
|
|
887
|
+
// Best case: if a debug-enabled Chromium browser is running, open the run
|
|
888
|
+
// tab via CDP with `background: true` so it lands *behind* the active tab
|
|
889
|
+
// (a true background tab, no focus steal) and stays closeable. This is how
|
|
890
|
+
// browser-harness-js does it, over a connection we keep alive across runs.
|
|
891
|
+
if (cdpClient) {
|
|
892
|
+
const handle = await cdpClient.openBackgroundTab(url);
|
|
893
|
+
if (handle)
|
|
894
|
+
return handle;
|
|
895
|
+
}
|
|
896
|
+
// Fallback: the OS opener. `background: true` is macOS `open -g`, which at
|
|
897
|
+
// least keeps the browser app from coming to the foreground; ignored
|
|
898
|
+
// elsewhere. Used whenever CDP isn't available (non-Chromium default
|
|
899
|
+
// browser, remote debugging off, or LOCALTERM_DISABLE_CDP_TABS=1). Not
|
|
900
|
+
// closeable, so `closeOnFinish` is silently a no-op on this path.
|
|
901
|
+
await open(url, { background: true });
|
|
902
|
+
return null;
|
|
903
|
+
},
|
|
904
|
+
close: async (handle) => {
|
|
905
|
+
if (cdpClient)
|
|
906
|
+
await cdpClient.closeTab(handle);
|
|
907
|
+
},
|
|
908
|
+
};
|
|
909
|
+
// Maps a run id -> the tab handle that ran it, so we can close the tab when
|
|
910
|
+
// the command finishes (only set when the opener returned a closeable handle).
|
|
911
|
+
const runTabHandles = new Map();
|
|
912
|
+
// Announced REMOTE surface origin for mobile/remote tabs + the `--open`
|
|
913
|
+
// browser + the network-policy host allowlist (tailnet / portless / null =
|
|
914
|
+
// loopback). A `let` rather than a const so the CLI can swap it in after
|
|
915
|
+
// `listen` resolves the bound port and surface; `tryLaunch` and the CDP
|
|
916
|
+
// `tabUrlFilter` read it live, so a post-bind `setPublicUrl` takes effect
|
|
917
|
+
// for runs and token injection without re-wiring either closure.
|
|
918
|
+
let publicOrigin = options.publicUrl ?? null;
|
|
919
|
+
const setPublicUrl = (url) => {
|
|
920
|
+
publicOrigin = url;
|
|
921
|
+
};
|
|
922
|
+
// Announced LOCAL surface origin automation-run tabs open at — a daemon-local
|
|
923
|
+
// origin (portless / loopback) that doesn't ride the tailnet, so a flapping
|
|
924
|
+
// `tailscale serve` can't fail the run-tab load and the automation. Read live
|
|
925
|
+
// by `tryLaunch` and the CDP `tabUrlFilter` for the same reason as
|
|
926
|
+
// `publicOrigin`; falls back to `publicOrigin` (then the loopback default)
|
|
927
|
+
// when unset so a caller that only set `publicUrl` keeps the prior behavior.
|
|
928
|
+
let localOrigin = options.localUrl ?? null;
|
|
929
|
+
const setLocalUrl = (url) => {
|
|
930
|
+
localOrigin = url;
|
|
931
|
+
};
|
|
932
|
+
// Project the newest run as the legacy `lastRun` for back-compat clients.
|
|
933
|
+
const deriveLastRun = (automation) => {
|
|
934
|
+
const latest = automation.runs[0];
|
|
935
|
+
if (!latest)
|
|
936
|
+
return null;
|
|
937
|
+
return {
|
|
938
|
+
runId: latest.runId,
|
|
939
|
+
at: latest.finishedAt ?? latest.startedAt ?? latest.scheduledFor,
|
|
940
|
+
status: latest.status,
|
|
941
|
+
exitCode: latest.exitCode,
|
|
942
|
+
};
|
|
986
943
|
};
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
if (automationStore.size() >= MAX_AUTOMATIONS) {
|
|
993
|
-
return context.json({ error: "too_many_automations" }, HTTP_STATUS_BAD_REQUEST);
|
|
994
|
-
}
|
|
995
|
-
if (!isValidTriggerInput(parsed.data.trigger)) {
|
|
996
|
-
return context.json({ error: "invalid_schedule" }, HTTP_STATUS_BAD_REQUEST);
|
|
997
|
-
}
|
|
998
|
-
if (!resolveCwdQuery(parsed.data.cwd)) {
|
|
999
|
-
return context.json({ error: "invalid_cwd" }, HTTP_STATUS_BAD_REQUEST);
|
|
1000
|
-
}
|
|
1001
|
-
if (parsed.data.requestedSecrets !== undefined) {
|
|
1002
|
-
const unknown = unknownRequestedSecrets(parsed.data.requestedSecrets);
|
|
1003
|
-
if (unknown.length > 0)
|
|
1004
|
-
return context.json({ error: "invalid_secret" }, HTTP_STATUS_BAD_REQUEST);
|
|
1005
|
-
}
|
|
1006
|
-
const automation = automationStore.create(parsed.data);
|
|
1007
|
-
broadcastAutomations();
|
|
1008
|
-
syncFolderWatchers();
|
|
1009
|
-
syncSessionEventListeners();
|
|
1010
|
-
return context.json({ automation: toAutomationWithNextRun(automation, new Date()) }, HTTP_STATUS_CREATED);
|
|
944
|
+
const toAutomationWithNextRun = (automation, from) => ({
|
|
945
|
+
...automation,
|
|
946
|
+
nextRunAt: computeNextAutomationRunAt(automation, from),
|
|
947
|
+
cron: automation.trigger.kind === "schedule" ? compileSchedule(automation.trigger.schedule) : null,
|
|
948
|
+
lastRun: deriveLastRun(automation),
|
|
1011
949
|
});
|
|
1012
|
-
|
|
1013
|
-
const
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
const unknown = unknownRequestedSecrets(parsed.data.requestedSecrets);
|
|
1024
|
-
if (unknown.length > 0)
|
|
1025
|
-
return context.json({ error: "invalid_secret" }, HTTP_STATUS_BAD_REQUEST);
|
|
1026
|
-
}
|
|
1027
|
-
const existing = automationStore.get(context.req.param("id"));
|
|
1028
|
-
if (!existing)
|
|
1029
|
-
return context.json({ error: "not_found" }, HTTP_STATUS_NOT_FOUND);
|
|
1030
|
-
// A PATCH never un-finishes — re-enabling a finished automation must go
|
|
1031
|
-
// through reset so it can't accidentally fire past its limit.
|
|
1032
|
-
if (existing.lifecycle === "finished" && parsed.data.enabled === true) {
|
|
1033
|
-
return context.json({ error: "automation_finished" }, HTTP_STATUS_BAD_REQUEST);
|
|
950
|
+
const listAutomationsWithNextRun = () => {
|
|
951
|
+
const from = new Date();
|
|
952
|
+
return automationStore.list().map((automation) => toAutomationWithNextRun(automation, from));
|
|
953
|
+
};
|
|
954
|
+
const broadcastAutomations = () => {
|
|
955
|
+
const payload = {
|
|
956
|
+
type: "automations",
|
|
957
|
+
automations: listAutomationsWithNextRun(),
|
|
958
|
+
};
|
|
959
|
+
for (const clientSocket of clientSockets) {
|
|
960
|
+
safeSend(clientSocket, payload);
|
|
1034
961
|
}
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
962
|
+
};
|
|
963
|
+
const caffeinateStatePayload = () => ({
|
|
964
|
+
type: "caffeinate",
|
|
965
|
+
supported: caffeinateManager.supported,
|
|
966
|
+
active: caffeinateManager.active,
|
|
967
|
+
mode: caffeinateManager.mode,
|
|
968
|
+
activityGate: caffeinateManager.activityGate,
|
|
969
|
+
batteryThreshold: caffeinateManager.batteryThreshold,
|
|
970
|
+
defaultCommands: [...caffeinateManager.defaultCommands],
|
|
971
|
+
commands: caffeinateManager.commands,
|
|
972
|
+
activeTrigger: caffeinateManager.activeTrigger,
|
|
1042
973
|
});
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
974
|
+
// The daemon owns the single keep-awake process, so its state is broadcast to
|
|
975
|
+
// every tab — exactly like automations — and the coffee controls stay in sync.
|
|
976
|
+
const broadcastCaffeinate = () => {
|
|
977
|
+
const payload = caffeinateStatePayload();
|
|
978
|
+
for (const clientSocket of clientSockets) {
|
|
979
|
+
safeSend(clientSocket, payload);
|
|
1046
980
|
}
|
|
981
|
+
};
|
|
982
|
+
caffeinateManager.on("change", broadcastCaffeinate);
|
|
983
|
+
// Open a browser tab for a run and record it in history. Scheduled and watch
|
|
984
|
+
// launches count toward the limit (and can finish the automation); manual
|
|
985
|
+
// launches never count and are allowed even on a finished/disabled automation.
|
|
986
|
+
const tryLaunch = (automation, trigger) => {
|
|
987
|
+
if (trigger !== "manual") {
|
|
988
|
+
const current = automationStore.get(automation.id);
|
|
989
|
+
if (!current || !current.enabled || current.lifecycle === "finished")
|
|
990
|
+
return null;
|
|
991
|
+
}
|
|
992
|
+
const run = automationRunTracker.create(automation);
|
|
993
|
+
const counts = trigger !== "manual";
|
|
994
|
+
automationStore.appendRun(automation.id, {
|
|
995
|
+
runId: run.runId,
|
|
996
|
+
scheduledFor: trigger === "schedule"
|
|
997
|
+
? Math.floor(run.createdAt / MS_PER_MINUTE) * MS_PER_MINUTE
|
|
998
|
+
: run.createdAt,
|
|
999
|
+
startedAt: run.createdAt,
|
|
1000
|
+
finishedAt: null,
|
|
1001
|
+
status: "launched",
|
|
1002
|
+
exitCode: null,
|
|
1003
|
+
trigger,
|
|
1004
|
+
countsTowardLimit: counts,
|
|
1005
|
+
});
|
|
1006
|
+
if (counts)
|
|
1007
|
+
automationStore.incrementRunCount(automation.id);
|
|
1047
1008
|
broadcastAutomations();
|
|
1009
|
+
// A watch automation that just reached its limit is now "finished"; stop
|
|
1010
|
+
// watching its folder promptly instead of waiting for the next mutation.
|
|
1048
1011
|
syncFolderWatchers();
|
|
1049
1012
|
syncSessionEventListeners();
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
//
|
|
1057
|
-
//
|
|
1058
|
-
//
|
|
1059
|
-
const
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1013
|
+
// Open the run tab at the announced LOCAL surface origin when the CLI
|
|
1014
|
+
// resolved one (portless / loopback) — run tabs open in the daemon's own
|
|
1015
|
+
// debugged browser, where a flapping `tailscale serve` (laptop wake, DERP
|
|
1016
|
+
// relay, cert renewal) would fail the tab load and the automation, so they
|
|
1017
|
+
// never ride the tailnet even when `publicOrigin` is the tailnet URL. Fall
|
|
1018
|
+
// back to `publicOrigin` (then the loopback form) so a caller that only set
|
|
1019
|
+
// `publicUrl` keeps the prior single-surface behavior. A bare origin (no
|
|
1020
|
+
// path) is the contract, so the `new URL` base rewrites any stray path and
|
|
1021
|
+
// searchParams encodes the id.
|
|
1022
|
+
const runUrl = new URL(localOrigin ?? publicOrigin ?? `http://${FRIENDLY_HOSTNAME}:${actualPort}`);
|
|
1023
|
+
runUrl.searchParams.set(AUTOMATION_RUN_QUERY_PARAM, run.runId);
|
|
1024
|
+
// Resolve requested secrets before opening the run tab so the env is set on
|
|
1025
|
+
// the pending run by the time the WS claims it. The claim happens only after
|
|
1026
|
+
// the browser loads this tab, which is gated on the resolution below, so
|
|
1027
|
+
// `onOpen` always sees the resolved env. The launch stays synchronous up to
|
|
1028
|
+
// here — the pending run + "launched" history are already recorded, so the
|
|
1029
|
+
// `isRunInFlight` overlap guard holds across the await. A secret-resolution
|
|
1030
|
+
// error is logged but does not block the tab (the run still starts, just
|
|
1031
|
+
// without the failed secret).
|
|
1032
|
+
void (async () => {
|
|
1033
|
+
try {
|
|
1034
|
+
const secretEnv = await buildAutomationSecretEnv(automation.requestedSecrets, secretStore, secretBackend);
|
|
1035
|
+
automationRunTracker.setEnv(run.runId, secretEnv);
|
|
1036
|
+
}
|
|
1037
|
+
catch (error) {
|
|
1038
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1039
|
+
console.warn(`failed to resolve secrets for automation "${automation.name}": ${message}`);
|
|
1040
|
+
}
|
|
1041
|
+
try {
|
|
1042
|
+
const handle = await tabController.open(runUrl.href);
|
|
1043
|
+
// Remember the tab so `automation-exit` can close it if closeOnFinish.
|
|
1044
|
+
if (handle)
|
|
1045
|
+
runTabHandles.set(run.runId, handle);
|
|
1046
|
+
}
|
|
1047
|
+
catch (error) {
|
|
1048
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1049
|
+
console.warn(`failed to open a browser tab for automation "${automation.name}": ${message}`);
|
|
1050
|
+
}
|
|
1051
|
+
})();
|
|
1052
|
+
return run;
|
|
1053
|
+
};
|
|
1054
|
+
// Close a finished run's tab when the automation opted into closeOnFinish.
|
|
1055
|
+
const closeRunTabIfRequested = (automationId, runId) => {
|
|
1056
|
+
const handle = runTabHandles.get(runId);
|
|
1057
|
+
runTabHandles.delete(runId);
|
|
1058
|
+
if (!handle)
|
|
1059
|
+
return;
|
|
1060
|
+
const automation = automationStore.get(automationId);
|
|
1061
|
+
if (!automation?.closeOnFinish)
|
|
1062
|
+
return;
|
|
1063
|
+
void tabController.close(handle).catch((error) => {
|
|
1064
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1065
|
+
console.warn(`failed to close automation tab (run ${runId}): ${message}`);
|
|
1066
|
+
});
|
|
1067
|
+
};
|
|
1068
|
+
// On boot, settle the state the dead process left behind: any run still
|
|
1069
|
+
// "launched"/"running" can never resume (the run tracker is in-memory), so it
|
|
1070
|
+
// becomes "missed"; and if the daemon was down across scheduled times, record
|
|
1071
|
+
// those as "skipped" so the user can see what didn't run while the machine was
|
|
1072
|
+
// off. Skipped runs never launch and never count toward a limit. No clients
|
|
1073
|
+
// exist yet, so nothing is broadcast.
|
|
1074
|
+
const reconcileOnStartup = (now) => {
|
|
1075
|
+
const lastAliveAt = heartbeatStore.read();
|
|
1076
|
+
const hadOutage = lastAliveAt !== null && now - lastAliveAt >= AUTOMATION_RECONCILE_MIN_DOWNTIME_MS;
|
|
1077
|
+
for (const automation of automationStore.list()) {
|
|
1078
|
+
for (const run of automation.runs) {
|
|
1079
|
+
if (run.status === "launched" || run.status === "running") {
|
|
1080
|
+
automationStore.updateRun(automation.id, run.runId, {
|
|
1081
|
+
status: "missed",
|
|
1082
|
+
finishedAt: now,
|
|
1083
|
+
});
|
|
1084
|
+
}
|
|
1085
|
+
}
|
|
1086
|
+
if (!hadOutage || !automation.enabled || automation.lifecycle === "finished")
|
|
1087
|
+
continue;
|
|
1088
|
+
for (const occurrence of enumerateMissedOccurrences(automation, lastAliveAt, now)) {
|
|
1089
|
+
automationStore.appendRun(automation.id, {
|
|
1090
|
+
runId: randomUUID(),
|
|
1091
|
+
scheduledFor: occurrence,
|
|
1092
|
+
startedAt: null,
|
|
1093
|
+
finishedAt: occurrence,
|
|
1094
|
+
status: "skipped",
|
|
1095
|
+
exitCode: null,
|
|
1096
|
+
trigger: "schedule",
|
|
1097
|
+
countsTowardLimit: false,
|
|
1098
|
+
});
|
|
1099
|
+
}
|
|
1093
1100
|
}
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1101
|
+
};
|
|
1102
|
+
const ctx = {
|
|
1103
|
+
registry,
|
|
1104
|
+
cdpClient,
|
|
1105
|
+
secretBackend,
|
|
1106
|
+
secretStore,
|
|
1107
|
+
shimsDir,
|
|
1108
|
+
processStore,
|
|
1109
|
+
syncSecretShims,
|
|
1110
|
+
automationStore,
|
|
1111
|
+
broadcastAutomations,
|
|
1112
|
+
syncFolderWatchers,
|
|
1113
|
+
syncSessionEventListeners,
|
|
1114
|
+
webhookTriggerManager,
|
|
1115
|
+
worktreeConfigStore,
|
|
1116
|
+
portsSnapshotProcesses,
|
|
1117
|
+
portsSnapshotListeners,
|
|
1118
|
+
toAutomationWithNextRun,
|
|
1119
|
+
listAutomationsWithNextRun,
|
|
1120
|
+
tryLaunch,
|
|
1121
|
+
};
|
|
1122
|
+
const api = buildApiRoutes(ctx);
|
|
1098
1123
|
app.route("/api", api);
|
|
1099
1124
|
app.get("/ws", upgradeWebSocket((context) => {
|
|
1100
1125
|
let activeWs = null;
|