ftown-bridge 0.11.2 → 0.12.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.
@@ -0,0 +1,18 @@
1
+ import type { Loop, LoopSchedule } from './types.js';
2
+ /**
3
+ * Epoch ms of the next fire strictly after fromMs.
4
+ *
5
+ * - interval: fromMs + everyMs, floored at a 1000ms minimum cadence so a
6
+ * misconfigured sub-second loop cannot busy-spin the scheduler.
7
+ * - cron: the next occurrence strictly after `fromMs` in the loop's timezone.
8
+ * Throws on a malformed cron expression (callers validate before persisting).
9
+ */
10
+ export declare function computeNextRun(schedule: LoopSchedule, fromMs: number): number;
11
+ /**
12
+ * Whether a loop should fire on a tick at `nowMs`.
13
+ *
14
+ * A manual run request bypasses both the enabled flag and the schedule (it is a
15
+ * one-shot override cleared on the next fire). Otherwise the loop must be
16
+ * enabled, have a computed nextRunAt, and that target must be at or before now.
17
+ */
18
+ export declare function isDue(loop: Loop, nowMs: number): boolean;
@@ -0,0 +1,35 @@
1
+ import cronParser from 'cron-parser';
2
+ /**
3
+ * Epoch ms of the next fire strictly after fromMs.
4
+ *
5
+ * - interval: fromMs + everyMs, floored at a 1000ms minimum cadence so a
6
+ * misconfigured sub-second loop cannot busy-spin the scheduler.
7
+ * - cron: the next occurrence strictly after `fromMs` in the loop's timezone.
8
+ * Throws on a malformed cron expression (callers validate before persisting).
9
+ */
10
+ export function computeNextRun(schedule, fromMs) {
11
+ if (schedule.kind === 'interval')
12
+ return fromMs + Math.max(1000, schedule.everyMs);
13
+ const it = cronParser.parseExpression(schedule.expression, {
14
+ currentDate: new Date(fromMs),
15
+ tz: schedule.tz,
16
+ });
17
+ return it.next().toDate().getTime();
18
+ }
19
+ /**
20
+ * Whether a loop should fire on a tick at `nowMs`.
21
+ *
22
+ * A manual run request bypasses both the enabled flag and the schedule (it is a
23
+ * one-shot override cleared on the next fire). Otherwise the loop must be
24
+ * enabled, have a computed nextRunAt, and that target must be at or before now.
25
+ */
26
+ export function isDue(loop, nowMs) {
27
+ if (loop.runNowRequested)
28
+ return true; // manual fire bypasses enabled + schedule
29
+ if (!loop.enabled)
30
+ return false;
31
+ if (!loop.nextRunAt)
32
+ return false;
33
+ return Date.parse(loop.nextRunAt) <= nowMs;
34
+ }
35
+ //# sourceMappingURL=loop-schedule.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"loop-schedule.js","sourceRoot":"","sources":["../src/loop-schedule.ts"],"names":[],"mappings":"AAAA,OAAO,UAAU,MAAM,aAAa,CAAC;AAIrC;;;;;;;GAOG;AACH,MAAM,UAAU,cAAc,CAAC,QAAsB,EAAE,MAAc;IACnE,IAAI,QAAQ,CAAC,IAAI,KAAK,UAAU;QAAE,OAAO,MAAM,GAAG,IAAI,CAAC,GAAG,CAAC,IAAI,EAAE,QAAQ,CAAC,OAAO,CAAC,CAAC;IACnF,MAAM,EAAE,GAAG,UAAU,CAAC,eAAe,CAAC,QAAQ,CAAC,UAAU,EAAE;QACzD,WAAW,EAAE,IAAI,IAAI,CAAC,MAAM,CAAC;QAC7B,EAAE,EAAE,QAAQ,CAAC,EAAE;KAChB,CAAC,CAAC;IACH,OAAO,EAAE,CAAC,IAAI,EAAE,CAAC,MAAM,EAAE,CAAC,OAAO,EAAE,CAAC;AACtC,CAAC;AAED;;;;;;GAMG;AACH,MAAM,UAAU,KAAK,CAAC,IAAU,EAAE,KAAa;IAC7C,IAAI,IAAI,CAAC,eAAe;QAAE,OAAO,IAAI,CAAC,CAAC,0CAA0C;IACjF,IAAI,CAAC,IAAI,CAAC,OAAO;QAAE,OAAO,KAAK,CAAC;IAChC,IAAI,CAAC,IAAI,CAAC,SAAS;QAAE,OAAO,KAAK,CAAC;IAClC,OAAO,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,SAAS,CAAC,IAAI,KAAK,CAAC;AAC7C,CAAC"}
@@ -0,0 +1,141 @@
1
+ import { type LoopRuntimeMutator } from './loop-store.js';
2
+ import type { CreateFtownSessionInput } from './create-ftown-session.js';
3
+ import type { RemoveFtownSessionOptions } from './remove-ftown-session.js';
4
+ import type { Loop, Session } from './types.js';
5
+ /** Base tick cadence; also the finalize grace so a just-spawned PTY is not mistaken for exited. */
6
+ export declare const LOOP_TICK_INTERVAL_MS = 30000;
7
+ export interface FlightResult {
8
+ stdout: string;
9
+ stderr: string;
10
+ exitCode: number;
11
+ }
12
+ /** Preflight/postflight primitive: promisified child_process.exec (captures exit code + timeout). */
13
+ export type RunFlight = (command: string, cwd: string | undefined, timeoutMs?: number, extraEnv?: Record<string, string>) => Promise<FlightResult>;
14
+ /** In-process flight spawn — wraps createFtownSession(sessionDeps, input) in index.ts. */
15
+ export type SpawnSession = (input: CreateFtownSessionInput) => Promise<Session>;
16
+ /** In-process run removal — wraps removeFtownSession({store,runner,centrifugo,userId}, id, opts) in index.ts. */
17
+ export type RemoveSession = (id: string, options?: RemoveFtownSessionOptions) => Promise<Session | null>;
18
+ export interface SchedulerStore {
19
+ loadSession(id: string): Promise<Session | null>;
20
+ loadTerminalLog(id: string): Promise<string>;
21
+ listSessions(): Promise<Session[]>;
22
+ }
23
+ export interface SchedulerRunner {
24
+ isRunning(id: string): boolean;
25
+ stop(id: string): boolean;
26
+ }
27
+ export interface SchedulerCentrifugo {
28
+ publishLoopUpdate(userId: string, loop: Loop): Promise<void>;
29
+ }
30
+ export interface LoopStoreApi {
31
+ listLoops(): Loop[];
32
+ /** Fresh-read → mutate scheduler-owned fields → save; null when deleted concurrently. */
33
+ mutateLoopRuntime(id: string, fn: LoopRuntimeMutator): Loop | null;
34
+ }
35
+ export interface SchedulerDeps {
36
+ store: SchedulerStore;
37
+ runner: SchedulerRunner;
38
+ centrifugo: SchedulerCentrifugo;
39
+ userId: string;
40
+ /** Built in index.ts as (input) => createFtownSession(sessionDeps, input) — the direct in-process call. */
41
+ spawnSession: SpawnSession;
42
+ /** Built in index.ts as (id, opts) => removeFtownSession({store,runner,centrifugo,userId}, id, opts). */
43
+ removeSession: RemoveSession;
44
+ /** Loop persistence. Defaults to the real ~/.ftown/loops.json store. */
45
+ loops?: LoopStoreApi;
46
+ /** Flight runner. Defaults to the exec-based runFlightCommand. */
47
+ runFlight?: RunFlight;
48
+ /** Clock seam. Defaults to Date.now. */
49
+ now?: () => number;
50
+ }
51
+ /**
52
+ * Runs child_process.exec and normalizes the result to { stdout, stderr,
53
+ * exitCode }. Never rejects — the exit code is the signal (a timeout maps to
54
+ * 124, any other failure to the real exit code or 1).
55
+ *
56
+ * The hard budget is enforced by SIGKILL on the whole process GROUP, NOT by
57
+ * exec's built-in `timeout`. exec's timeout only sends SIGTERM to the spawned
58
+ * `/bin/sh`; a detached grandchild that keeps the stdout pipe open, or a child
59
+ * that traps SIGTERM, would keep the exec callback from ever firing — and since
60
+ * the scheduler awaits every flight inside a single re-entrancy-guarded tick,
61
+ * ONE such flight would wedge the entire scheduler permanently. Spawning
62
+ * `detached` makes the child its own group leader, so `kill(-pid, SIGKILL)`
63
+ * takes down the whole tree and the flight can never exceed its budget.
64
+ */
65
+ export declare function runFlightCommand(command: string, cwd: string | undefined, timeoutMs?: number, extraEnv?: Record<string, string>): Promise<FlightResult>;
66
+ /**
67
+ * The scheduled-loops engine. On each 30s tick it FINALIZES each loop's
68
+ * in-flight run(s) (Phase A) before deciding whether to FIRE a new one
69
+ * (Phase B). All side effects go through injected collaborators so it is
70
+ * unit-testable without a live bridge, real fs or real timers (mirrors
71
+ * workflow-runner.ts).
72
+ *
73
+ * Persistence rule: the scheduler NEVER writes a whole detached Loop back
74
+ * across an await. Every runtime-field change goes through
75
+ * store.mutateLoopRuntime (fresh-read → mutate → save), so a loop deleted or
76
+ * user-edited during a long flight is neither resurrected nor clobbered.
77
+ */
78
+ export declare class LoopScheduler {
79
+ private readonly store;
80
+ private readonly runner;
81
+ private readonly centrifugo;
82
+ private readonly userId;
83
+ private readonly spawnSession;
84
+ private readonly removeSession;
85
+ private readonly loops;
86
+ private readonly runFlight;
87
+ private readonly now;
88
+ /** Re-entrancy guard: tick N+1 never overlaps N. */
89
+ private tickRunning;
90
+ /** Set once start() runs (after reconcileOnStart). kick() no-ops before this so an
91
+ * early run_loop_now cannot trigger an un-reconciled tick that stampedes overdue loops. */
92
+ private started;
93
+ /** Per-loop in-memory fire lock, shared by tick + run_loop_now/kick. */
94
+ private readonly firingLoops;
95
+ /** loopId -> (runSessionId -> fire-time ms). Every run THIS process spawned, so under
96
+ * overlapPolicy:'allow' each concurrent run is finalized/postflighted/maxRuntime-checked
97
+ * independently — not just the newest. Rebuilt lazily from the persisted primary on restart. */
98
+ private readonly inFlight;
99
+ private timer;
100
+ constructor(deps: SchedulerDeps);
101
+ start(): void;
102
+ stop(): void;
103
+ /** Immediate, guarded, out-of-band tick (used by run_loop_now). No-op until start()
104
+ * has run, so a kick that races startup cannot fire before reconcileOnStart. */
105
+ kick(): void;
106
+ /** Drop scheduler tracking for a deleted loop and stop any run it left alive, so a
107
+ * just-deleted loop never leaks a live AI session with nothing left to finalize it. */
108
+ onLoopDeleted(loop: Loop): void;
109
+ /**
110
+ * Missed-schedule policy, run once before the first tick: for every loop whose
111
+ * nextRunAt is missing or already past, recompute it from now (skip missed
112
+ * occurrences; never stampede overdue loops). runNowRequested is preserved so a
113
+ * manual override survives a restart and still fires on the first tick. A loop
114
+ * with a corrupt persisted schedule is skipped here (logged) and reported as an
115
+ * error on its first fire.
116
+ */
117
+ reconcileOnStart(now?: number): Promise<void>;
118
+ tick(now?: number): Promise<void>;
119
+ private processLoop;
120
+ /** Phase A: finalize each in-flight run once its PTY is confirmed gone (past grace)
121
+ * or over its per-run maxRuntime budget. Under 'allow' this walks every tracked run,
122
+ * not just the newest, so none is orphaned. */
123
+ private finalizePhase;
124
+ /** Seed the persisted primary run into in-memory tracking after a restart (when this
125
+ * process has spawned nothing yet for the loop), so a run left 'running' by a prior
126
+ * process is still finalized. */
127
+ private ensureTracked;
128
+ private track;
129
+ /** Phase B: fire the loop if due, honoring the per-loop lock and the overlap policy. */
130
+ private firePhase;
131
+ /** Advance the schedule up front (so failures/skips never stampede), then preflight → flight. */
132
+ private fireLoop;
133
+ /** Resolve one finished run to ok/error, update the loop badge (only if this is the
134
+ * loop's tracked/latest run), then run postflight + retention for it. */
135
+ private finalizeRun;
136
+ private runPostflight;
137
+ /** Keep the newest N run-sessions for this loop; prune older finished ones. */
138
+ private pruneRuns;
139
+ private persist;
140
+ private publish;
141
+ }