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.
- package/dist/centrifugo-client.d.ts +4 -1
- package/dist/centrifugo-client.js +27 -0
- package/dist/centrifugo-client.js.map +1 -1
- package/dist/codex-installer.js +8 -2
- package/dist/codex-installer.js.map +1 -1
- package/dist/create-ftown-session.d.ts +1 -0
- package/dist/create-ftown-session.js +1 -0
- package/dist/create-ftown-session.js.map +1 -1
- package/dist/index.js +160 -7
- package/dist/index.js.map +1 -1
- package/dist/loop-schedule.d.ts +18 -0
- package/dist/loop-schedule.js +35 -0
- package/dist/loop-schedule.js.map +1 -0
- package/dist/loop-scheduler.d.ts +141 -0
- package/dist/loop-scheduler.js +487 -0
- package/dist/loop-scheduler.js.map +1 -0
- package/dist/loop-store.d.ts +36 -0
- package/dist/loop-store.js +128 -0
- package/dist/loop-store.js.map +1 -0
- package/dist/loop-validation.d.ts +14 -0
- package/dist/loop-validation.js +95 -0
- package/dist/loop-validation.js.map +1 -0
- package/dist/types.d.ts +85 -2
- package/package.json +2 -1
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { v4 as uuidv4 } from 'uuid';
|
|
5
|
+
import { computeNextRun } from './loop-schedule.js';
|
|
6
|
+
function loopsPath() {
|
|
7
|
+
return join(homedir(), '.ftown', 'loops.json');
|
|
8
|
+
}
|
|
9
|
+
/** Tolerant loader: returns { loops: [] } on a missing OR corrupt file. Never throws. */
|
|
10
|
+
function loadLoops() {
|
|
11
|
+
try {
|
|
12
|
+
const path = loopsPath();
|
|
13
|
+
if (!existsSync(path))
|
|
14
|
+
return { loops: [] };
|
|
15
|
+
const parsed = JSON.parse(readFileSync(path, 'utf8'));
|
|
16
|
+
return { loops: Array.isArray(parsed.loops) ? parsed.loops : [] };
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
return { loops: [] };
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
function saveLoops(data) {
|
|
23
|
+
mkdirSync(join(homedir(), '.ftown'), { recursive: true, mode: 0o700 });
|
|
24
|
+
const path = loopsPath();
|
|
25
|
+
const tmp = `${path}.tmp`;
|
|
26
|
+
writeFileSync(tmp, JSON.stringify(data, null, 2) + '\n', { mode: 0o600 });
|
|
27
|
+
renameSync(tmp, path); // atomic
|
|
28
|
+
}
|
|
29
|
+
export function listLoops() {
|
|
30
|
+
return loadLoops().loops;
|
|
31
|
+
}
|
|
32
|
+
export function getLoop(id) {
|
|
33
|
+
return loadLoops().loops.find((loop) => loop.id === id);
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Mint a Loop from a client draft: fresh id/timestamps, zeroed counters, and a
|
|
37
|
+
* nextRunAt computed from now — even when created disabled, so re-enabling has a
|
|
38
|
+
* target. Throws (via computeNextRun) on a malformed cron expression.
|
|
39
|
+
*/
|
|
40
|
+
export function createLoop(draft) {
|
|
41
|
+
const data = loadLoops();
|
|
42
|
+
const now = new Date();
|
|
43
|
+
const loop = {
|
|
44
|
+
...draft,
|
|
45
|
+
id: uuidv4(),
|
|
46
|
+
createdAt: now.toISOString(),
|
|
47
|
+
updatedAt: now.toISOString(),
|
|
48
|
+
nextRunAt: new Date(computeNextRun(draft.schedule, now.getTime())).toISOString(),
|
|
49
|
+
runCount: 0,
|
|
50
|
+
skipCount: 0,
|
|
51
|
+
};
|
|
52
|
+
data.loops.push(loop);
|
|
53
|
+
saveLoops(data);
|
|
54
|
+
return loop;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Merge `patch` over an existing loop. id + createdAt are immutable; updatedAt is
|
|
58
|
+
* bumped. A schedule change recomputes nextRunAt from now (the old target is
|
|
59
|
+
* stale). Returns null when no loop has that id.
|
|
60
|
+
*/
|
|
61
|
+
export function updateLoop(id, patch) {
|
|
62
|
+
const data = loadLoops();
|
|
63
|
+
const index = data.loops.findIndex((loop) => loop.id === id);
|
|
64
|
+
if (index === -1)
|
|
65
|
+
return null;
|
|
66
|
+
const existing = data.loops[index];
|
|
67
|
+
const updated = {
|
|
68
|
+
...existing,
|
|
69
|
+
...patch,
|
|
70
|
+
id: existing.id,
|
|
71
|
+
// A loop is owned by exactly one bridge (the one it is persisted on); its
|
|
72
|
+
// bridgeId is the RPC routing key. Never let a patch move it — a stray
|
|
73
|
+
// patch.bridgeId would otherwise desync the record from the routing guard.
|
|
74
|
+
bridgeId: existing.bridgeId,
|
|
75
|
+
createdAt: existing.createdAt,
|
|
76
|
+
updatedAt: new Date().toISOString(),
|
|
77
|
+
};
|
|
78
|
+
if (patch.schedule) {
|
|
79
|
+
updated.nextRunAt = new Date(computeNextRun(updated.schedule, Date.now())).toISOString();
|
|
80
|
+
}
|
|
81
|
+
data.loops[index] = updated;
|
|
82
|
+
saveLoops(data);
|
|
83
|
+
return updated;
|
|
84
|
+
}
|
|
85
|
+
export function deleteLoop(id) {
|
|
86
|
+
const data = loadLoops();
|
|
87
|
+
const remaining = data.loops.filter((loop) => loop.id !== id);
|
|
88
|
+
if (remaining.length === data.loops.length)
|
|
89
|
+
return false;
|
|
90
|
+
data.loops = remaining;
|
|
91
|
+
saveLoops(data);
|
|
92
|
+
return true;
|
|
93
|
+
}
|
|
94
|
+
/** Insert a loop, or replace the existing one with the same id, in place. */
|
|
95
|
+
export function upsertLoop(loop) {
|
|
96
|
+
const data = loadLoops();
|
|
97
|
+
const index = data.loops.findIndex((existing) => existing.id === loop.id);
|
|
98
|
+
if (index === -1)
|
|
99
|
+
data.loops.push(loop);
|
|
100
|
+
else
|
|
101
|
+
data.loops[index] = loop;
|
|
102
|
+
saveLoops(data);
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Atomically apply a scheduler-owned mutation: reload the loop FRESH from disk,
|
|
106
|
+
* apply `fn`, then save. Returns the merged loop, or null when the id no longer
|
|
107
|
+
* exists (deleted concurrently) — the caller then skips its publish so a deleted
|
|
108
|
+
* loop is never resurrected.
|
|
109
|
+
*
|
|
110
|
+
* The scheduler holds a detached Loop snapshot across long awaits (a 20s
|
|
111
|
+
* preflight, an in-process spawn). Writing that whole stale snapshot back would
|
|
112
|
+
* (a) resurrect a loop deleted mid-flight and (b) clobber a concurrent
|
|
113
|
+
* update_loop patch to user-owned fields. Because `fn` runs on the
|
|
114
|
+
* freshly-loaded record and touches only runtime fields, both hazards are gone:
|
|
115
|
+
* a delete wins (null), and an unrelated enabled/schedule/task patch survives.
|
|
116
|
+
*/
|
|
117
|
+
export function mutateLoopRuntime(id, fn) {
|
|
118
|
+
const data = loadLoops();
|
|
119
|
+
const index = data.loops.findIndex((loop) => loop.id === id);
|
|
120
|
+
if (index === -1)
|
|
121
|
+
return null;
|
|
122
|
+
const loop = data.loops[index];
|
|
123
|
+
fn(loop);
|
|
124
|
+
data.loops[index] = loop;
|
|
125
|
+
saveLoops(data);
|
|
126
|
+
return loop;
|
|
127
|
+
}
|
|
128
|
+
//# sourceMappingURL=loop-store.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"loop-store.js","sourceRoot":"","sources":["../src/loop-store.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { LoopDraft, LoopSchedule } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Loop payload validation, extracted from index.ts so it is unit-testable
|
|
4
|
+
* without importing the CLI entrypoint (which calls program.parse() on load).
|
|
5
|
+
* Every validator returns an error string, or null when the input is valid.
|
|
6
|
+
*/
|
|
7
|
+
export declare const LOOP_HARNESSES: ReadonlySet<string>;
|
|
8
|
+
/** Validate a loop schedule (interval floor + cron parseability). */
|
|
9
|
+
export declare function validateSchedule(schedule: LoopSchedule | undefined): string | null;
|
|
10
|
+
export declare function validateRetention(retention: LoopDraft['retention'] | undefined): string | null;
|
|
11
|
+
/** Full-draft validation for create_loop. */
|
|
12
|
+
export declare function validateLoopDraft(draft: Partial<LoopDraft>): string | null;
|
|
13
|
+
/** Partial validation for update_loop — only the fields present in the patch. */
|
|
14
|
+
export declare function validateLoopPatch(patch: Partial<LoopDraft>): string | null;
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { computeNextRun } from './loop-schedule.js';
|
|
2
|
+
/**
|
|
3
|
+
* Loop payload validation, extracted from index.ts so it is unit-testable
|
|
4
|
+
* without importing the CLI entrypoint (which calls program.parse() on load).
|
|
5
|
+
* Every validator returns an error string, or null when the input is valid.
|
|
6
|
+
*/
|
|
7
|
+
export const LOOP_HARNESSES = new Set([
|
|
8
|
+
'claude',
|
|
9
|
+
'cursor',
|
|
10
|
+
'codex',
|
|
11
|
+
'opencode',
|
|
12
|
+
'shell',
|
|
13
|
+
]);
|
|
14
|
+
/** Validate a loop schedule (interval floor + cron parseability). */
|
|
15
|
+
export function validateSchedule(schedule) {
|
|
16
|
+
if (!schedule || typeof schedule !== 'object')
|
|
17
|
+
return 'schedule is required';
|
|
18
|
+
if (schedule.kind === 'interval') {
|
|
19
|
+
if (typeof schedule.everyMs !== 'number' || !Number.isFinite(schedule.everyMs) || schedule.everyMs < 1000) {
|
|
20
|
+
return 'interval everyMs must be a finite number >= 1000';
|
|
21
|
+
}
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
if (schedule.kind === 'cron') {
|
|
25
|
+
if (typeof schedule.expression !== 'string' || !schedule.expression.trim()) {
|
|
26
|
+
return 'cron expression is required';
|
|
27
|
+
}
|
|
28
|
+
try {
|
|
29
|
+
computeNextRun(schedule, Date.now());
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
return `Invalid cron expression: ${schedule.expression}`;
|
|
33
|
+
}
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
return 'schedule.kind must be "interval" or "cron"';
|
|
37
|
+
}
|
|
38
|
+
export function validateRetention(retention) {
|
|
39
|
+
const value = retention?.autoClearAfterRuns;
|
|
40
|
+
const ok = value === null || (typeof value === 'number' && Number.isFinite(value) && value >= 0);
|
|
41
|
+
return retention && ok ? null : 'retention.autoClearAfterRuns must be null or a non-negative number';
|
|
42
|
+
}
|
|
43
|
+
/** Full-draft validation for create_loop. */
|
|
44
|
+
export function validateLoopDraft(draft) {
|
|
45
|
+
if (!draft || typeof draft !== 'object')
|
|
46
|
+
return 'Invalid loop payload';
|
|
47
|
+
// Required so a create can never slip past the bridge-routing guard and get
|
|
48
|
+
// duplicated across every connected bridge (create mints a fresh id).
|
|
49
|
+
if (typeof draft.bridgeId !== 'string' || !draft.bridgeId.trim())
|
|
50
|
+
return 'bridgeId is required';
|
|
51
|
+
if (typeof draft.name !== 'string' || !draft.name.trim())
|
|
52
|
+
return 'Loop name is required';
|
|
53
|
+
if (typeof draft.task !== 'string' || !draft.task.trim())
|
|
54
|
+
return 'Loop task is required';
|
|
55
|
+
if (typeof draft.harness !== 'string' || !LOOP_HARNESSES.has(draft.harness)) {
|
|
56
|
+
return `Invalid harness: ${String(draft.harness)}`;
|
|
57
|
+
}
|
|
58
|
+
if (draft.overlapPolicy !== 'skip' && draft.overlapPolicy !== 'allow') {
|
|
59
|
+
return 'overlapPolicy must be "skip" or "allow"';
|
|
60
|
+
}
|
|
61
|
+
if (typeof draft.enabled !== 'boolean')
|
|
62
|
+
return 'enabled must be a boolean';
|
|
63
|
+
const retentionError = validateRetention(draft.retention);
|
|
64
|
+
if (retentionError)
|
|
65
|
+
return retentionError;
|
|
66
|
+
return validateSchedule(draft.schedule);
|
|
67
|
+
}
|
|
68
|
+
/** Partial validation for update_loop — only the fields present in the patch. */
|
|
69
|
+
export function validateLoopPatch(patch) {
|
|
70
|
+
if (!patch || typeof patch !== 'object')
|
|
71
|
+
return 'Invalid patch';
|
|
72
|
+
if ('name' in patch && (typeof patch.name !== 'string' || !patch.name.trim())) {
|
|
73
|
+
return 'Loop name must be a non-empty string';
|
|
74
|
+
}
|
|
75
|
+
if ('task' in patch && (typeof patch.task !== 'string' || !patch.task.trim())) {
|
|
76
|
+
return 'Loop task must be a non-empty string';
|
|
77
|
+
}
|
|
78
|
+
if ('harness' in patch && (typeof patch.harness !== 'string' || !LOOP_HARNESSES.has(patch.harness))) {
|
|
79
|
+
return `Invalid harness: ${String(patch.harness)}`;
|
|
80
|
+
}
|
|
81
|
+
if ('overlapPolicy' in patch && patch.overlapPolicy !== 'skip' && patch.overlapPolicy !== 'allow') {
|
|
82
|
+
return 'overlapPolicy must be "skip" or "allow"';
|
|
83
|
+
}
|
|
84
|
+
if ('enabled' in patch && typeof patch.enabled !== 'boolean')
|
|
85
|
+
return 'enabled must be a boolean';
|
|
86
|
+
if ('retention' in patch) {
|
|
87
|
+
const retentionError = validateRetention(patch.retention);
|
|
88
|
+
if (retentionError)
|
|
89
|
+
return retentionError;
|
|
90
|
+
}
|
|
91
|
+
if ('schedule' in patch)
|
|
92
|
+
return validateSchedule(patch.schedule);
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
//# sourceMappingURL=loop-validation.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"loop-validation.js","sourceRoot":"","sources":["../src/loop-validation.ts"],"names":[],"mappings":"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"}
|
package/dist/types.d.ts
CHANGED
|
@@ -19,12 +19,72 @@ export interface Session {
|
|
|
19
19
|
parentSessionId?: string;
|
|
20
20
|
runtime?: SessionRuntime;
|
|
21
21
|
errorReason?: string;
|
|
22
|
+
loopId?: string;
|
|
22
23
|
}
|
|
23
24
|
export type SessionStatus = 'pending' | 'running' | 'completed' | 'error';
|
|
24
25
|
/** Tombstone written to <dataDir>/archive.jsonl when a session is removed. */
|
|
25
26
|
export interface ArchivedSession extends Session {
|
|
26
27
|
removedAt: string;
|
|
27
28
|
}
|
|
29
|
+
export type LoopHarness = 'claude' | 'cursor' | 'codex' | 'opencode' | 'shell';
|
|
30
|
+
export type LoopRunStatus = 'ok' | 'error' | 'running' | 'skipped';
|
|
31
|
+
export type LoopSchedule = {
|
|
32
|
+
kind: 'interval';
|
|
33
|
+
everyMs: number;
|
|
34
|
+
} | {
|
|
35
|
+
kind: 'cron';
|
|
36
|
+
expression: string;
|
|
37
|
+
tz?: string;
|
|
38
|
+
};
|
|
39
|
+
export interface LoopFlight {
|
|
40
|
+
command: string;
|
|
41
|
+
timeoutMs?: number;
|
|
42
|
+
}
|
|
43
|
+
export interface LoopPostflight {
|
|
44
|
+
command: string;
|
|
45
|
+
timeoutMs?: number;
|
|
46
|
+
/** Run postflight even when the fire was preflight-skipped. Default false. */
|
|
47
|
+
runOnSkip?: boolean;
|
|
48
|
+
}
|
|
49
|
+
export interface LoopRetention {
|
|
50
|
+
/** Keep newest N run-sessions; prune older ones. null = keep all. Default 10. */
|
|
51
|
+
autoClearAfterRuns: number | null;
|
|
52
|
+
}
|
|
53
|
+
/** Client-authored fields (create/edit form). */
|
|
54
|
+
export interface LoopDraft {
|
|
55
|
+
name: string;
|
|
56
|
+
bridgeId: string;
|
|
57
|
+
schedule: LoopSchedule;
|
|
58
|
+
harness: LoopHarness;
|
|
59
|
+
workdir?: string;
|
|
60
|
+
task: string;
|
|
61
|
+
model?: string;
|
|
62
|
+
enabled: boolean;
|
|
63
|
+
overlapPolicy: 'skip' | 'allow';
|
|
64
|
+
retention: LoopRetention;
|
|
65
|
+
preflight?: LoopFlight;
|
|
66
|
+
postflight?: LoopPostflight;
|
|
67
|
+
/** Optional deterministic backstop: force-stop + mark 'error' if a flight runs longer. */
|
|
68
|
+
maxRuntimeMs?: number;
|
|
69
|
+
}
|
|
70
|
+
/** Full server-authoritative record (LoopDraft + runtime state). */
|
|
71
|
+
export interface Loop extends LoopDraft {
|
|
72
|
+
id: string;
|
|
73
|
+
createdAt: string;
|
|
74
|
+
updatedAt: string;
|
|
75
|
+
lastRunAt?: string;
|
|
76
|
+
nextRunAt?: string;
|
|
77
|
+
lastStatus?: LoopRunStatus;
|
|
78
|
+
lastSessionId?: string;
|
|
79
|
+
runCount: number;
|
|
80
|
+
skipCount: number;
|
|
81
|
+
/** Transient manual-fire flag; set by run_loop_now, cleared on the next tick. */
|
|
82
|
+
runNowRequested?: boolean;
|
|
83
|
+
}
|
|
84
|
+
/** A "run" is exactly a Session whose loopId === loop.id. No separate store record. */
|
|
85
|
+
export type LoopRun = Session & {
|
|
86
|
+
loopId: string;
|
|
87
|
+
};
|
|
28
88
|
/** Inter-agent mail stored in <dataDir>/sessions/<id>/inbox.jsonl. */
|
|
29
89
|
export interface MailMessage {
|
|
30
90
|
id: string;
|
|
@@ -52,7 +112,7 @@ export interface Command {
|
|
|
52
112
|
payload: CommandPayload;
|
|
53
113
|
requestId: string;
|
|
54
114
|
}
|
|
55
|
-
export type CommandType = 'create_session' | 'stop_session' | 'list_sessions' | 'get_history' | 'retry_session' | 'send_message' | 'rename_session' | 'remove_session' | 'bridge_exec' | 'clear_terminal' | 'update_session_parent';
|
|
115
|
+
export type CommandType = 'create_session' | 'stop_session' | 'list_sessions' | 'get_history' | 'retry_session' | 'send_message' | 'rename_session' | 'remove_session' | 'bridge_exec' | 'clear_terminal' | 'update_session_parent' | 'create_loop' | 'list_loops' | 'update_loop' | 'delete_loop' | 'run_loop_now' | 'get_loop_runs';
|
|
56
116
|
export interface CreateSessionPayload {
|
|
57
117
|
command: string;
|
|
58
118
|
prompt?: string;
|
|
@@ -101,7 +161,30 @@ export interface RemoveSessionPayload {
|
|
|
101
161
|
export interface ClearTerminalPayload {
|
|
102
162
|
sessionId: string;
|
|
103
163
|
}
|
|
104
|
-
export
|
|
164
|
+
export interface CreateLoopPayload extends LoopDraft {
|
|
165
|
+
bridgeId: string;
|
|
166
|
+
}
|
|
167
|
+
export interface ListLoopsPayload {
|
|
168
|
+
bridgeId?: string;
|
|
169
|
+
}
|
|
170
|
+
export interface UpdateLoopPayload {
|
|
171
|
+
bridgeId: string;
|
|
172
|
+
loopId: string;
|
|
173
|
+
patch: Partial<LoopDraft>;
|
|
174
|
+
}
|
|
175
|
+
export interface DeleteLoopPayload {
|
|
176
|
+
bridgeId: string;
|
|
177
|
+
loopId: string;
|
|
178
|
+
}
|
|
179
|
+
export interface RunLoopNowPayload {
|
|
180
|
+
bridgeId: string;
|
|
181
|
+
loopId: string;
|
|
182
|
+
}
|
|
183
|
+
export interface GetLoopRunsPayload {
|
|
184
|
+
bridgeId: string;
|
|
185
|
+
loopId: string;
|
|
186
|
+
}
|
|
187
|
+
export type CommandPayload = CreateSessionPayload | StopSessionPayload | GetHistoryPayload | RenameSessionPayload | RemoveSessionPayload | BridgeExecPayload | ClearTerminalPayload | UpdateSessionParentPayload | CreateLoopPayload | ListLoopsPayload | UpdateLoopPayload | DeleteLoopPayload | RunLoopNowPayload | GetLoopRunsPayload | Record<string, unknown>;
|
|
105
188
|
export interface CommandResponse {
|
|
106
189
|
requestId: string;
|
|
107
190
|
success: boolean;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ftown-bridge",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.0",
|
|
4
4
|
"description": "CLI bridge for ftown — generic PTY-over-Centrifugo relay",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -48,6 +48,7 @@
|
|
|
48
48
|
"@xterm/headless": "^6.0.0",
|
|
49
49
|
"centrifuge": "^5.2.2",
|
|
50
50
|
"commander": "^13.1.0",
|
|
51
|
+
"cron-parser": "^4.9.0",
|
|
51
52
|
"node-pty": "^1.2.0-beta.12",
|
|
52
53
|
"uuid": "^11.1.0",
|
|
53
54
|
"ws": "^8.18.0"
|