pi-feats 0.1.4 → 0.1.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +90 -9
- package/extensions/api-server/README.md +21 -1
- package/extensions/api-server/application-log-store.ts +4 -2
- package/extensions/api-server/application-runtime.ts +79 -8
- package/extensions/api-server/application-store.ts +23 -10
- package/extensions/api-server/index.ts +6 -2
- package/extensions/api-server/profile-store.ts +109 -39
- package/extensions/api-server/server.ts +220 -44
- package/extensions/cli-resources.ts +144 -113
- package/extensions/cli-tui-mode.ts +7 -0
- package/extensions/context-memory/index.ts +6 -1
- package/extensions/conversation-search/index.ts +42 -0
- package/extensions/guardrails/index.ts +2 -1
- package/extensions/lib/application-context.ts +2 -0
- package/extensions/lib/builtin-tools.ts +24 -0
- package/extensions/lib/cli-tui-mode.ts +72 -0
- package/extensions/lib/context-memory.ts +6 -1
- package/extensions/lib/conversation-search.ts +33 -0
- package/extensions/lib/package-resources.ts +58 -0
- package/extensions/lib/pi-command.ts +11 -0
- package/extensions/lib/profile-sandbox.ts +14 -3
- package/extensions/lib/remote-hosts.ts +7 -5
- package/extensions/lib/workflow-evaluator.ts +113 -0
- package/extensions/lib/workflow-harness.ts +409 -43
- package/extensions/pi-console-webui/app/[section]/page.tsx +1 -1
- package/extensions/pi-console-webui/app/api/admin/models/route.ts +22 -0
- package/extensions/pi-console-webui/app/globals.css +3 -0
- package/extensions/pi-console-webui/components/application-handler-editor.tsx +1 -1
- package/extensions/pi-console-webui/components/application-logs.tsx +4 -4
- package/extensions/pi-console-webui/components/application-mappings.tsx +10 -12
- package/extensions/pi-console-webui/components/application-sessions.tsx +2 -2
- package/extensions/pi-console-webui/components/application-settings.tsx +12 -6
- package/extensions/pi-console-webui/components/chat-workspace.tsx +17 -7
- package/extensions/pi-console-webui/components/console-page.tsx +4 -0
- package/extensions/pi-console-webui/components/console-state.tsx +1 -1
- package/extensions/pi-console-webui/components/console.tsx +32 -11
- package/extensions/pi-console-webui/components/guardrails-panel.tsx +2 -2
- package/extensions/pi-console-webui/components/models-config-form.tsx +31 -0
- package/extensions/pi-console-webui/components/profile-model-form.tsx +54 -0
- package/extensions/pi-console-webui/components/pulse-resources.tsx +7 -6
- package/extensions/pi-console-webui/components/sequential-workflow-config.tsx +32 -0
- package/extensions/pi-console-webui/components/skill-resources.tsx +4 -3
- package/extensions/pi-console-webui/components/ui/input.tsx +1 -1
- package/extensions/pi-console-webui/index.ts +2 -1
- package/extensions/pi-console-webui/lib/admin-models.ts +38 -0
- package/extensions/pi-console-webui/lib/page-metadata.ts +4 -1
- package/extensions/pi-console-webui/next.config.js +1 -0
- package/extensions/profile-ssh.ts +274 -0
- package/extensions/profiles.ts +69 -25
- package/extensions/pulse/index.ts +148 -16
- package/extensions/pulse/store.ts +132 -18
- package/extensions/sequential-workflow.ts +121 -79
- package/extensions/skill-sources/store.ts +29 -10
- package/package.json +21 -9
- package/scripts/build-console-webui.mjs +7 -0
- package/scripts/console-webui-cache.mjs +79 -0
- package/scripts/ensure-console-build.mjs +43 -0
- package/skills/profile-ssh/SKILL.md +65 -0
- package/skills/sequential-workflow/SKILL.md +4 -3
- package/themes/legacy-dark.json +85 -0
|
@@ -4,11 +4,14 @@ import { mkdirSync } from "node:fs";
|
|
|
4
4
|
import { dirname } from "node:path";
|
|
5
5
|
|
|
6
6
|
export type PulseType = "cron" | "heartbeat";
|
|
7
|
-
export type Pulse = { id: string; name: string; description: string; type: PulseType; schedule: string; prompt: string; thread_session_id: string; result: string | null; profile: string; enabled: boolean; nextRunAt: string | null; lastRunAt: string | null };
|
|
7
|
+
export type Pulse = { id: string; name: string; description: string; type: PulseType; schedule: string; prompt: string; thread_session_id: string; insertIntoApiSession: boolean; result: string | null; profile: string; enabled: boolean; nextRunAt: string | null; lastRunAt: string | null };
|
|
8
8
|
export type PulseHistory = Pulse & { startedAt: string; finishedAt: string | null; status: string; error: string | null };
|
|
9
|
-
export type PulseRun = { id: string; startedAt: string; finishedAt: string | null; status: "running" | "success" | "error"; response: string | null; error: string | null };
|
|
9
|
+
export type PulseRun = { id: string; sessionId: string | null; startedAt: string; finishedAt: string | null; status: "running" | "success" | "error"; response: string | null; error: string | null };
|
|
10
|
+
export type PulseExecutionSession = { sessionId: string; pulseId: string; pulseName: string; pulseType: PulseType; runId: string; status: PulseRun["status"]; startedAt: string; finishedAt: string | null };
|
|
11
|
+
export type ClaimedPulse = { pulse: Pulse; runId: string };
|
|
10
12
|
type Row = Record<string, unknown>;
|
|
11
13
|
const iso = (date = new Date()) => date.toISOString();
|
|
14
|
+
export const pulseId = (type: PulseType, date = new Date()): string => `pulse-${type}_${date.toISOString().slice(0, 16).replace("T", "-").replace(":", "-")}`;
|
|
12
15
|
|
|
13
16
|
function fieldMatches(field: string, value: number, min: number, max: number): boolean {
|
|
14
17
|
return field.split(",").some((part) => {
|
|
@@ -58,29 +61,50 @@ export class PulseStore {
|
|
|
58
61
|
private db: DatabaseSync;
|
|
59
62
|
constructor(path: string) {
|
|
60
63
|
mkdirSync(dirname(path), { recursive: true }); this.db = new DatabaseSync(path); this.db.exec("PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON;");
|
|
61
|
-
this.db.exec(`CREATE TABLE IF NOT EXISTS pulses (id TEXT PRIMARY KEY, name TEXT NOT NULL UNIQUE, description TEXT NOT NULL, type TEXT NOT NULL CHECK(type IN ('cron','heartbeat')), schedule TEXT NOT NULL, prompt TEXT NOT NULL, thread_session_id TEXT NOT NULL, result TEXT);
|
|
62
|
-
CREATE TABLE IF NOT EXISTS pulse_control (pulse_id TEXT PRIMARY KEY REFERENCES pulses(id) ON DELETE CASCADE, profile TEXT NOT NULL, enabled INTEGER NOT NULL DEFAULT 1, next_run_at TEXT, last_run_at TEXT, updated_at TEXT NOT NULL);
|
|
64
|
+
this.db.exec(`CREATE TABLE IF NOT EXISTS pulses (id TEXT PRIMARY KEY, name TEXT NOT NULL UNIQUE, description TEXT NOT NULL, type TEXT NOT NULL CHECK(type IN ('cron','heartbeat')), schedule TEXT NOT NULL, prompt TEXT NOT NULL, thread_session_id TEXT NOT NULL, insert_into_api_session INTEGER NOT NULL DEFAULT 1, result TEXT);
|
|
65
|
+
CREATE TABLE IF NOT EXISTS pulse_control (pulse_id TEXT PRIMARY KEY REFERENCES pulses(id) ON DELETE CASCADE, profile TEXT NOT NULL, enabled INTEGER NOT NULL DEFAULT 1, next_run_at TEXT, last_run_at TEXT, claimed_at TEXT, active_run_id TEXT, claim_owner TEXT, lease_expires_at TEXT, updated_at TEXT NOT NULL);
|
|
63
66
|
CREATE TABLE IF NOT EXISTS pulse_state (pulse_id TEXT PRIMARY KEY REFERENCES pulses(id) ON DELETE CASCADE, handoff TEXT NOT NULL DEFAULT '', updated_at TEXT NOT NULL);
|
|
64
|
-
CREATE TABLE IF NOT EXISTS pulse_runs (id TEXT PRIMARY KEY, pulse_id TEXT NOT NULL REFERENCES pulses(id) ON DELETE CASCADE, started_at TEXT NOT NULL, finished_at TEXT, status TEXT NOT NULL, response TEXT, error TEXT);
|
|
67
|
+
CREATE TABLE IF NOT EXISTS pulse_runs (id TEXT PRIMARY KEY, pulse_id TEXT NOT NULL REFERENCES pulses(id) ON DELETE CASCADE, session_id TEXT, started_at TEXT NOT NULL, finished_at TEXT, status TEXT NOT NULL, response TEXT, error TEXT);
|
|
68
|
+
CREATE TABLE IF NOT EXISTS pulse_tick_lease (name TEXT PRIMARY KEY, owner TEXT NOT NULL, expires_at TEXT NOT NULL, updated_at TEXT NOT NULL);
|
|
65
69
|
CREATE INDEX IF NOT EXISTS pulse_runs_pulse_started ON pulse_runs(pulse_id, started_at DESC);`);
|
|
66
70
|
// Migrate databases created before the denormalized latest result column.
|
|
67
71
|
const columns = this.db.prepare("PRAGMA table_info(pulses)").all() as Row[];
|
|
68
72
|
if (!columns.some((column) => column.name === "result")) this.db.exec("ALTER TABLE pulses ADD COLUMN result TEXT");
|
|
73
|
+
if (!columns.some((column) => column.name === "insert_into_api_session")) this.db.exec("ALTER TABLE pulses ADD COLUMN insert_into_api_session INTEGER NOT NULL DEFAULT 1");
|
|
74
|
+
const runColumns = this.db.prepare("PRAGMA table_info(pulse_runs)").all() as Row[];
|
|
75
|
+
if (!runColumns.some((column) => column.name === "session_id")) this.db.exec("ALTER TABLE pulse_runs ADD COLUMN session_id TEXT");
|
|
76
|
+
const controlColumns = this.db.prepare("PRAGMA table_info(pulse_control)").all() as Row[];
|
|
77
|
+
for (const column of ["claimed_at", "active_run_id", "claim_owner", "lease_expires_at"]) if (!controlColumns.some((item) => item.name === column)) this.db.exec(`ALTER TABLE pulse_control ADD COLUMN ${column} TEXT`);
|
|
78
|
+
// Older ticks could create overlapping running rows. Resolve them before
|
|
79
|
+
// installing the database-level invariant that permits only one.
|
|
80
|
+
const duplicates = this.db.prepare("SELECT pulse_id FROM pulse_runs WHERE status='running' GROUP BY pulse_id HAVING COUNT(*) > 1").all() as Row[];
|
|
81
|
+
for (const duplicate of duplicates) {
|
|
82
|
+
const rows = this.db.prepare("SELECT id FROM pulse_runs WHERE pulse_id=? AND status='running' ORDER BY started_at DESC, id DESC").all(duplicate.pulse_id) as Row[];
|
|
83
|
+
for (const row of rows.slice(1)) this.db.prepare("UPDATE pulse_runs SET status='error',finished_at=?,error=? WHERE id=?").run(iso(), "Superseded while enforcing the single active Pulse run invariant.", row.id);
|
|
84
|
+
}
|
|
85
|
+
// Adopt a pre-migration active run so a legacy tick cannot be joined by a
|
|
86
|
+
// new scheduler until its bounded lease expires.
|
|
87
|
+
const active = this.db.prepare("SELECT pulse_id,id,started_at FROM pulse_runs WHERE status='running'").all() as Row[];
|
|
88
|
+
for (const run of active) this.db.prepare("UPDATE pulse_control SET active_run_id=COALESCE(active_run_id,?),claim_owner=COALESCE(claim_owner,'legacy'),claimed_at=COALESCE(claimed_at,?),lease_expires_at=COALESCE(lease_expires_at,?),next_run_at=NULL,updated_at=? WHERE pulse_id=?").run(run.id, run.started_at, new Date(Date.now() + 15 * 60_000).toISOString(), iso(), run.pulse_id);
|
|
89
|
+
this.db.exec("CREATE UNIQUE INDEX IF NOT EXISTS pulse_runs_one_active ON pulse_runs(pulse_id) WHERE status='running'; CREATE INDEX IF NOT EXISTS pulse_control_due ON pulse_control(enabled, active_run_id, next_run_at);");
|
|
69
90
|
this.db.exec("UPDATE pulses SET result=(SELECT response FROM pulse_runs WHERE pulse_id=pulses.id AND status='success' ORDER BY finished_at DESC LIMIT 1) WHERE result IS NULL");
|
|
70
91
|
// Keep records created before the schedule-based classifier in sync.
|
|
71
92
|
for (const row of this.db.prepare("SELECT id, schedule, type FROM pulses").all() as Row[]) { const type = pulseType(String(row.schedule)); if (row.type !== type) this.db.prepare("UPDATE pulses SET type=? WHERE id=?").run(type, row.id); }
|
|
72
93
|
}
|
|
73
|
-
private pulse(row: Row): Pulse { return { id: String(row.id), name: String(row.name), description: String(row.description), type: row.type as PulseType, schedule: String(row.schedule), prompt: String(row.prompt), thread_session_id: String(row.thread_session_id), result: row.result == null ? null : String(row.result), profile: String(row.profile), enabled: Boolean(row.enabled), nextRunAt: row.next_run_at ? String(row.next_run_at) : null, lastRunAt: row.last_run_at ? String(row.last_run_at) : null }; }
|
|
94
|
+
private pulse(row: Row): Pulse { return { id: String(row.id), name: String(row.name), description: String(row.description), type: row.type as PulseType, schedule: String(row.schedule), prompt: String(row.prompt), thread_session_id: String(row.thread_session_id), insertIntoApiSession: Boolean(row.insert_into_api_session), result: row.result == null ? null : String(row.result), profile: String(row.profile), enabled: Boolean(row.enabled), nextRunAt: row.next_run_at ? String(row.next_run_at) : null, lastRunAt: row.last_run_at ? String(row.last_run_at) : null }; }
|
|
74
95
|
list(profile?: string): Pulse[] { const query = `SELECT p.*, c.profile, c.enabled, c.next_run_at, c.last_run_at FROM pulses p JOIN pulse_control c ON c.pulse_id=p.id${profile ? " WHERE c.profile=?" : ""} ORDER BY p.name`; return this.db.prepare(query).all(...(profile ? [profile] : [])) .map((row) => this.pulse(row as Row)); }
|
|
75
96
|
get(name: string, profile?: string): Pulse | undefined { return this.list(profile).find((item) => item.name === name); }
|
|
76
97
|
history(profile: string): PulseHistory[] { return this.db.prepare(`SELECT p.*, c.profile, c.enabled, c.next_run_at, c.last_run_at, r.started_at AS startedAt, r.finished_at AS finishedAt, r.status, r.error FROM pulses p JOIN pulse_control c ON c.pulse_id=p.id JOIN pulse_runs r ON r.id=(SELECT id FROM pulse_runs WHERE pulse_id=p.id AND finished_at IS NOT NULL ORDER BY started_at DESC LIMIT 1) WHERE c.profile=? AND p.schedule LIKE '@once:%' ORDER BY r.started_at DESC`).all(profile).map((row) => { const record = row as Row; return { ...this.pulse(record), startedAt: String(record.startedAt), finishedAt: record.finishedAt == null ? null : String(record.finishedAt), status: String(record.status), error: record.error == null ? null : String(record.error) }; }); }
|
|
77
|
-
|
|
78
|
-
|
|
98
|
+
executionSessions(profile: string): Map<string, PulseExecutionSession> { const rows = this.db.prepare("SELECT r.session_id,p.id AS pulse_id,p.name AS pulse_name,p.type AS pulse_type,r.id AS run_id,r.status,r.started_at,r.finished_at FROM pulse_runs r JOIN pulses p ON p.id=r.pulse_id JOIN pulse_control c ON c.pulse_id=p.id WHERE c.profile=? AND r.session_id IS NOT NULL ORDER BY r.started_at DESC").all(profile) as Row[]; return new Map(rows.map((row) => [String(row.session_id), { sessionId: String(row.session_id), pulseId: String(row.pulse_id), pulseName: String(row.pulse_name), pulseType: row.pulse_type as PulseType, runId: String(row.run_id), status: row.status as PulseRun["status"], startedAt: String(row.started_at), finishedAt: row.finished_at == null ? null : String(row.finished_at) }])); }
|
|
99
|
+
runs(profile: string, name: string, start: string, end: string, limit = 100): PulseRun[] { const pulse = this.get(name, profile); if (!pulse) throw new Error("Pulse not found."); return this.db.prepare("SELECT id, session_id, started_at, finished_at, status, response, error FROM pulse_runs WHERE pulse_id=? AND started_at>=? AND started_at<=? ORDER BY started_at DESC LIMIT ?").all(pulse.id, start, end, limit).map((row) => { const item = row as Row; return { id: String(item.id), sessionId: item.session_id == null ? null : String(item.session_id), startedAt: String(item.started_at), finishedAt: item.finished_at == null ? null : String(item.finished_at), status: item.status as PulseRun["status"], response: item.response == null ? null : String(item.response), error: item.error == null ? null : String(item.error) }; }); }
|
|
100
|
+
create(input: Omit<Pulse, "id" | "enabled" | "nextRunAt" | "lastRunAt" | "type" | "result" | "insertIntoApiSession"> & { insertIntoApiSession?: boolean }): Pulse {
|
|
79
101
|
if (!/^[A-Za-z][A-Za-z0-9_-]{0,63}$/.test(input.name)) throw new Error("Invalid pulse name.");
|
|
80
102
|
if (!validSchedule(input.schedule)) throw new Error("Invalid schedule. Use a five-field UTC cron expression or @once:<ISO-8601>.");
|
|
81
|
-
const type = pulseType(input.schedule),
|
|
82
|
-
this.db.prepare("
|
|
83
|
-
|
|
103
|
+
const type = pulseType(input.schedule), baseId = pulseId(type); let id = baseId, suffix = 2;
|
|
104
|
+
while (this.db.prepare("SELECT 1 FROM pulses WHERE id=?").get(id)) id = `${baseId}-${String(suffix++).padStart(2, "0")}`;
|
|
105
|
+
const next = nextRun(input.schedule)?.toISOString() ?? null;
|
|
106
|
+
this.db.prepare("INSERT INTO pulses (id, name, description, type, schedule, prompt, thread_session_id, insert_into_api_session, result) VALUES (?, ?, ?, ?, ?, ?, ?, ?, NULL)").run(id, input.name, input.description, type, input.schedule, input.prompt, input.thread_session_id, input.insertIntoApiSession !== false ? 1 : 0);
|
|
107
|
+
this.db.prepare("INSERT INTO pulse_control (pulse_id, profile, enabled, next_run_at, last_run_at, claimed_at, updated_at) VALUES (?, ?, 1, ?, NULL, NULL, ?)").run(id, input.profile, next, iso());
|
|
84
108
|
if (type === "heartbeat") this.db.prepare("INSERT INTO pulse_state VALUES (?, '', ?)").run(id, iso());
|
|
85
109
|
return this.get(input.name, input.profile)!;
|
|
86
110
|
}
|
|
@@ -90,19 +114,109 @@ export class PulseStore {
|
|
|
90
114
|
this.db.prepare("UPDATE pulse_control SET enabled=?, next_run_at=?, updated_at=? WHERE pulse_id=?").run(enabled ? 1 : 0, next, iso(), pulse.id);
|
|
91
115
|
return this.get(name, profile)!;
|
|
92
116
|
}
|
|
93
|
-
update(name: string, profile: string | undefined, input: Pick<Pulse, "description" | "schedule" | "prompt" | "thread_session_id">): Pulse {
|
|
117
|
+
update(name: string, profile: string | undefined, input: Pick<Pulse, "description" | "schedule" | "prompt" | "thread_session_id" | "insertIntoApiSession"> & { name?: string }): Pulse {
|
|
94
118
|
const pulse = this.get(name, profile); if (!pulse) throw new Error("Pulse not found.");
|
|
119
|
+
const nextName = input.name ?? pulse.name;
|
|
120
|
+
if (!/^[A-Za-z][A-Za-z0-9_-]{0,63}$/.test(nextName)) throw new Error("Invalid pulse name.");
|
|
95
121
|
if (!validSchedule(input.schedule)) throw new Error("Invalid schedule. Use a five-field UTC cron expression or @once:<ISO-8601>.");
|
|
96
122
|
const type = pulseType(input.schedule);
|
|
97
|
-
this.db.prepare("UPDATE pulses SET description=?, type=?, schedule=?, prompt=?, thread_session_id=? WHERE id=?").run(input.description, type, input.schedule, input.prompt, input.thread_session_id, pulse.id);
|
|
123
|
+
this.db.prepare("UPDATE pulses SET name=?, description=?, type=?, schedule=?, prompt=?, thread_session_id=?, insert_into_api_session=? WHERE id=?").run(nextName, input.description, type, input.schedule, input.prompt, input.thread_session_id, input.insertIntoApiSession ? 1 : 0, pulse.id);
|
|
98
124
|
this.db.prepare("UPDATE pulse_control SET next_run_at=?, updated_at=? WHERE pulse_id=?").run(pulse.enabled ? nextRun(input.schedule)?.toISOString() ?? null : null, iso(), pulse.id);
|
|
99
|
-
return this.get(
|
|
125
|
+
return this.get(nextName, profile)!;
|
|
100
126
|
}
|
|
101
127
|
delete(name: string, profile?: string): void { const pulse = this.get(name, profile); if (!pulse) throw new Error("Pulse not found."); this.db.prepare("DELETE FROM pulses WHERE id=?").run(pulse.id); }
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
128
|
+
deleteProfile(profile: string): void { this.db.prepare("DELETE FROM pulses WHERE id IN (SELECT pulse_id FROM pulse_control WHERE profile=?)").run(profile); }
|
|
129
|
+
due(now = iso()): Pulse[] { return this.db.prepare("SELECT p.*, c.profile, c.enabled, c.next_run_at, c.last_run_at FROM pulses p JOIN pulse_control c ON c.pulse_id=p.id WHERE c.enabled=1 AND c.active_run_id IS NULL AND c.next_run_at IS NOT NULL AND c.next_run_at<=? ORDER BY c.next_run_at").all(now).map((row) => this.pulse(row as Row)); }
|
|
130
|
+
claimDue(now = iso(), owner = "unknown", leaseMs = 15 * 60_000): ClaimedPulse[] {
|
|
131
|
+
const claimed: ClaimedPulse[] = [], expiresAt = new Date(new Date(now).getTime() + leaseMs).toISOString();
|
|
132
|
+
this.db.exec("BEGIN IMMEDIATE");
|
|
133
|
+
try {
|
|
134
|
+
for (const pulse of this.due(now)) {
|
|
135
|
+
const runId = randomUUID();
|
|
136
|
+
const result = this.db.prepare("UPDATE pulse_control SET claimed_at=?,active_run_id=?,claim_owner=?,lease_expires_at=?,next_run_at=NULL,updated_at=? WHERE pulse_id=? AND enabled=1 AND active_run_id IS NULL AND next_run_at IS NOT NULL AND next_run_at<=?").run(now, runId, owner, expiresAt, now, pulse.id, now);
|
|
137
|
+
if (!result.changes) continue;
|
|
138
|
+
this.db.prepare("INSERT INTO pulse_runs (id, pulse_id, session_id, started_at, finished_at, status, response, error) VALUES (?, ?, NULL, ?, NULL, 'running', NULL, NULL)").run(runId, pulse.id, now);
|
|
139
|
+
claimed.push({ pulse, runId });
|
|
140
|
+
}
|
|
141
|
+
this.db.exec("COMMIT");
|
|
142
|
+
return claimed;
|
|
143
|
+
} catch (error) { this.db.exec("ROLLBACK"); throw error; }
|
|
144
|
+
}
|
|
145
|
+
setRunSession(runId: string, sessionId: string): void { this.db.prepare("UPDATE pulse_runs SET session_id=? WHERE id=? AND status='running'").run(sessionId, runId); }
|
|
146
|
+
renewClaim(pulseId: string, runId: string, owner: string, leaseMs = 15 * 60_000): boolean { return Boolean(this.db.prepare("UPDATE pulse_control SET lease_expires_at=?,updated_at=? WHERE pulse_id=? AND active_run_id=? AND claim_owner=?").run(new Date(Date.now() + leaseMs).toISOString(), iso(), pulseId, runId, owner).changes); }
|
|
147
|
+
expiredClaims(profile?: string, now = iso()): number {
|
|
148
|
+
const filter = profile ? " AND c.profile=?" : "";
|
|
149
|
+
return Number((this.db.prepare(`SELECT COUNT(*) AS count FROM pulse_control c WHERE c.active_run_id IS NOT NULL AND c.lease_expires_at IS NOT NULL AND c.lease_expires_at<=?${filter}`).get(now, ...(profile ? [profile] : [])) as Row).count);
|
|
150
|
+
}
|
|
151
|
+
recoverExpiredClaims(profile?: string, now = iso()): number {
|
|
152
|
+
// Recovery is deliberately operator-triggered. A lease expiring alone does
|
|
153
|
+
// not prove that a child process is dead, so automatic recovery could run
|
|
154
|
+
// the same work twice. An operator can use this after confirming the old
|
|
155
|
+
// scheduler/worker is gone (for example, after a container restart).
|
|
156
|
+
const filter = profile ? " AND c.profile=?" : "";
|
|
157
|
+
const rows = this.db.prepare(`SELECT p.*, c.profile, c.active_run_id FROM pulses p JOIN pulse_control c ON c.pulse_id=p.id WHERE c.active_run_id IS NOT NULL AND c.lease_expires_at IS NOT NULL AND c.lease_expires_at<=?${filter}`).all(now, ...(profile ? [profile] : [])) as Row[];
|
|
158
|
+
this.db.exec("BEGIN IMMEDIATE");
|
|
159
|
+
try {
|
|
160
|
+
for (const row of rows) {
|
|
161
|
+
const pulse = this.pulse(row), runId = String(row.active_run_id), retry = nextRun(pulse.schedule, new Date(Date.now() + 60_000))?.toISOString() ?? null;
|
|
162
|
+
this.db.prepare("UPDATE pulse_runs SET finished_at=?,status='error',error=? WHERE id=? AND status='running'").run(now, "Recovered expired Pulse claim by operator.", runId);
|
|
163
|
+
this.db.prepare("UPDATE pulse_control SET next_run_at=?,claimed_at=NULL,active_run_id=NULL,claim_owner=NULL,lease_expires_at=NULL,updated_at=? WHERE pulse_id=? AND active_run_id=?").run(retry, now, pulse.id, runId);
|
|
164
|
+
}
|
|
165
|
+
this.db.exec("COMMIT"); return rows.length;
|
|
166
|
+
} catch (error) { this.db.exec("ROLLBACK"); throw error; }
|
|
167
|
+
}
|
|
168
|
+
complete(pulse: Pulse, runId: string, response: string, handoff?: string, owner = "unknown"): void {
|
|
169
|
+
const now = iso(), next = nextRun(pulse.schedule)?.toISOString() ?? null;
|
|
170
|
+
this.db.exec("BEGIN IMMEDIATE");
|
|
171
|
+
try {
|
|
172
|
+
const owns = this.db.prepare("SELECT 1 FROM pulse_control WHERE pulse_id=? AND active_run_id=? AND claim_owner=?").get(pulse.id, runId, owner);
|
|
173
|
+
if (owns) {
|
|
174
|
+
this.db.prepare("UPDATE pulse_runs SET finished_at=?,status='success',response=? WHERE id=? AND status='running'").run(now, response, runId);
|
|
175
|
+
this.db.prepare("UPDATE pulse_control SET last_run_at=?,next_run_at=?,claimed_at=NULL,active_run_id=NULL,claim_owner=NULL,lease_expires_at=NULL,updated_at=? WHERE pulse_id=? AND active_run_id=? AND claim_owner=?").run(now, next, now, pulse.id, runId, owner);
|
|
176
|
+
this.db.prepare("UPDATE pulses SET result=? WHERE id=?").run(response, pulse.id);
|
|
177
|
+
if (pulse.type === "heartbeat" && handoff !== undefined) this.db.prepare("UPDATE pulse_state SET handoff=?,updated_at=? WHERE pulse_id=?").run(handoff, now, pulse.id);
|
|
178
|
+
}
|
|
179
|
+
this.db.exec("COMMIT");
|
|
180
|
+
} catch (error) { this.db.exec("ROLLBACK"); throw error; }
|
|
181
|
+
}
|
|
182
|
+
fail(pulse: Pulse, runId: string, error: string, owner = "unknown"): void {
|
|
183
|
+
const now = iso(), retry = nextRun(pulse.schedule, new Date(Date.now() + 60_000))?.toISOString() ?? null;
|
|
184
|
+
this.db.exec("BEGIN IMMEDIATE");
|
|
185
|
+
try {
|
|
186
|
+
const owns = this.db.prepare("SELECT 1 FROM pulse_control WHERE pulse_id=? AND active_run_id=? AND claim_owner=?").get(pulse.id, runId, owner);
|
|
187
|
+
if (owns) {
|
|
188
|
+
this.db.prepare("UPDATE pulse_runs SET finished_at=?,status='error',error=? WHERE id=? AND status='running'").run(now, error, runId);
|
|
189
|
+
this.db.prepare("UPDATE pulse_control SET next_run_at=?,claimed_at=NULL,active_run_id=NULL,claim_owner=NULL,lease_expires_at=NULL,updated_at=? WHERE pulse_id=? AND active_run_id=? AND claim_owner=?").run(retry, now, pulse.id, runId, owner);
|
|
190
|
+
}
|
|
191
|
+
this.db.exec("COMMIT");
|
|
192
|
+
} catch (cause) { this.db.exec("ROLLBACK"); throw cause; }
|
|
193
|
+
}
|
|
194
|
+
finalizeRunning(name: string, profile: string, runId: string, status: "success" | "error"): PulseRun {
|
|
195
|
+
const pulse = this.get(name, profile); if (!pulse) throw new Error("Pulse not found.");
|
|
196
|
+
const now = iso(), result = status === "success" ? "Manually finalized as successful." : "Manually finalized as failed.", next = status === "success" ? nextRun(pulse.schedule)?.toISOString() ?? null : nextRun(pulse.schedule, new Date(Date.now() + 60_000))?.toISOString() ?? null;
|
|
197
|
+
this.db.exec("BEGIN IMMEDIATE");
|
|
198
|
+
try {
|
|
199
|
+
const run = this.db.prepare("UPDATE pulse_runs SET finished_at=?,status=?,response=?,error=? WHERE id=? AND pulse_id=? AND status='running'").run(now, status, status === "success" ? result : null, status === "error" ? result : null, runId, pulse.id);
|
|
200
|
+
if (!run.changes) throw new Error("Pulse run is not running.");
|
|
201
|
+
const control = this.db.prepare("UPDATE pulse_control SET last_run_at=?,next_run_at=?,claimed_at=NULL,active_run_id=NULL,claim_owner=NULL,lease_expires_at=NULL,updated_at=? WHERE pulse_id=? AND active_run_id=?").run(now, next, now, pulse.id, runId);
|
|
202
|
+
if (!control.changes) throw new Error("Pulse run is no longer active.");
|
|
203
|
+
if (status === "success") this.db.prepare("UPDATE pulses SET result=? WHERE id=?").run(result, pulse.id);
|
|
204
|
+
this.db.exec("COMMIT");
|
|
205
|
+
} catch (error) { this.db.exec("ROLLBACK"); throw error; }
|
|
206
|
+
const row = this.db.prepare("SELECT id,session_id,started_at,finished_at,status,response,error FROM pulse_runs WHERE id=?").get(runId) as Row;
|
|
207
|
+
return { id: String(row.id), sessionId: row.session_id == null ? null : String(row.session_id), startedAt: String(row.started_at), finishedAt: row.finished_at == null ? null : String(row.finished_at), status: row.status as PulseRun["status"], response: row.response == null ? null : String(row.response), error: row.error == null ? null : String(row.error) };
|
|
208
|
+
}
|
|
209
|
+
claimTickLease(owner: string, leaseMs = 90_000): boolean {
|
|
210
|
+
const now = iso(), expiresAt = new Date(Date.now() + leaseMs).toISOString();
|
|
211
|
+
this.db.exec("BEGIN IMMEDIATE");
|
|
212
|
+
try {
|
|
213
|
+
const current = this.db.prepare("SELECT owner,expires_at FROM pulse_tick_lease WHERE name='scheduler'").get() as Row | undefined;
|
|
214
|
+
if (current && String(current.owner) !== owner && String(current.expires_at) > now) { this.db.exec("COMMIT"); return false; }
|
|
215
|
+
this.db.prepare("INSERT INTO pulse_tick_lease(name,owner,expires_at,updated_at) VALUES ('scheduler',?,?,?) ON CONFLICT(name) DO UPDATE SET owner=excluded.owner,expires_at=excluded.expires_at,updated_at=excluded.updated_at").run(owner, expiresAt, now);
|
|
216
|
+
this.db.exec("COMMIT"); return true;
|
|
217
|
+
} catch (error) { this.db.exec("ROLLBACK"); throw error; }
|
|
218
|
+
}
|
|
219
|
+
releaseTickLease(owner: string): void { this.db.prepare("DELETE FROM pulse_tick_lease WHERE name='scheduler' AND owner=?").run(owner); }
|
|
106
220
|
handoff(id: string): string { return String((this.db.prepare("SELECT handoff FROM pulse_state WHERE pulse_id=?").get(id) as Row | undefined)?.handoff ?? ""); }
|
|
107
221
|
retarget(profile: string, from: string, to: string): void { this.db.prepare("UPDATE pulses SET thread_session_id=? WHERE id IN (SELECT pulse_id FROM pulse_control WHERE profile=?) AND thread_session_id=?").run(to, profile, from); }
|
|
108
222
|
}
|