@forwardimpact/outpost 3.1.3 → 3.2.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/src/scheduler.js CHANGED
@@ -1,10 +1,43 @@
1
1
  /**
2
2
  * Scheduler — cron matching, shouldWake logic, wake orchestration.
3
+ *
4
+ * The genuine wall-clock read ("what time is it now?") routes through the
5
+ * injected `runtime.clock` (see `Scheduler#wakeDueAgents` and `failAgent`).
6
+ * The pure cron helpers below receive that `now` as an explicit `Date` and
7
+ * construct further `Date` objects only for deterministic date *arithmetic*
8
+ * over caller-supplied or parsed-from-state inputs (never an ambient no-arg
9
+ * `new Date()`); the AST checker cannot distinguish the two, so this file is
10
+ * allow-listed in check-ambient-deps.allow.yml with that reason.
3
11
  */
4
12
 
13
+ import { isoTimestamp } from "@forwardimpact/libutil";
14
+
5
15
  /** Maximum time an agent can be "active" before being considered stale (35 min). */
6
16
  const MAX_AGENT_RUNTIME_MS = 35 * 60_000;
7
17
 
18
+ /**
19
+ * Build a `Date` for "now" from the injected clock's milliseconds. The cron
20
+ * helpers operate on `Date` objects; this is the single seam that turns the
21
+ * wall-clock read (`runtime.clock.now()`) into one, so callers in other
22
+ * modules never construct an ambient `new Date()` themselves.
23
+ * @param {{now: () => number}} clock
24
+ * @returns {Date}
25
+ */
26
+ export function nowFromClock(clock) {
27
+ return new Date(clock.now());
28
+ }
29
+
30
+ /**
31
+ * Format a stored ISO timestamp as a human-readable local time string. Parses
32
+ * an explicit value (never the wall clock); lives here so the only `new Date`
33
+ * construction sites stay in this allow-listed module.
34
+ * @param {string} iso
35
+ * @returns {string}
36
+ */
37
+ export function formatLocalTime(iso) {
38
+ return new Date(Date.parse(iso)).toLocaleString();
39
+ }
40
+
8
41
  // --- Cron matching (pure functions) ------------------------------------------
9
42
 
10
43
  /**
@@ -133,12 +166,13 @@ export function computeNextWakeAt(agent, agentState, now) {
133
166
  /**
134
167
  * @param {Object} agentState
135
168
  * @param {string} error
169
+ * @param {number} nowMs - Wall-clock milliseconds (from `runtime.clock.now()`)
136
170
  */
137
- export function failAgent(agentState, error) {
171
+ export function failAgent(agentState, error, nowMs) {
138
172
  Object.assign(agentState, {
139
173
  status: "failed",
140
174
  startedAt: null,
141
- lastWokeAt: new Date().toISOString(),
175
+ lastWokeAt: isoTimestamp(nowMs),
142
176
  lastError: String(error).slice(0, 500),
143
177
  });
144
178
  }
@@ -151,33 +185,38 @@ export class Scheduler {
151
185
  #stateManager;
152
186
  #agentRunner;
153
187
  #log;
188
+ #clock;
154
189
 
155
190
  /**
156
- * @param {Function} loadConfig - Returns scheduler config
191
+ * @param {() => Promise<Object>} loadConfig - Returns scheduler config
157
192
  * @param {import('./state-manager.js').StateManager} stateManager
158
193
  * @param {import('./agent-runner.js').AgentRunner} agentRunner
159
194
  * @param {Function} logFn
195
+ * @param {import("@forwardimpact/libutil/runtime").Runtime} runtime
196
+ * Injected runtime bag (uses `clock` for the wall-clock read).
160
197
  */
161
- constructor(loadConfig, stateManager, agentRunner, logFn) {
198
+ constructor(loadConfig, stateManager, agentRunner, logFn, runtime) {
162
199
  if (!loadConfig) throw new Error("loadConfig is required");
163
200
  if (!stateManager) throw new Error("stateManager is required");
164
201
  if (!agentRunner) throw new Error("agentRunner is required");
165
202
  if (!logFn) throw new Error("logFn is required");
203
+ if (!runtime?.clock) throw new Error("runtime.clock is required");
166
204
  this.#loadConfig = loadConfig;
167
205
  this.#stateManager = stateManager;
168
206
  this.#agentRunner = agentRunner;
169
207
  this.#log = logFn;
208
+ this.#clock = runtime.clock;
170
209
  }
171
210
 
172
211
  /**
173
212
  * Reset any agents exceeding max runtime, reload config, then wake each agent whose schedule is due.
174
213
  */
175
214
  async wakeDueAgents() {
176
- const config = this.#loadConfig();
177
- const state = this.#stateManager.load();
178
- const now = new Date();
215
+ const config = await this.#loadConfig();
216
+ const state = await this.#stateManager.load();
217
+ const now = nowFromClock(this.#clock);
179
218
 
180
- this.#stateManager.resetStaleAgents(
219
+ await this.#stateManager.resetStaleAgents(
181
220
  state,
182
221
  { reason: "Exceeded maximum runtime", maxAge: MAX_AGENT_RUNTIME_MS },
183
222
  this.#log,
@@ -5,18 +5,9 @@
5
5
  import { createServer } from "node:net";
6
6
  import { createConnection } from "node:net";
7
7
  import { createLogger } from "@forwardimpact/libtelemetry";
8
- import {
9
- existsSync,
10
- unlinkSync,
11
- chmodSync,
12
- readdirSync,
13
- statSync,
14
- } from "node:fs";
15
-
16
- const logger = createLogger("outpost");
17
8
  import { join, resolve } from "node:path";
18
9
  import { homedir } from "node:os";
19
- import { computeNextWakeAt } from "./scheduler.js";
10
+ import { computeNextWakeAt, nowFromClock } from "./scheduler.js";
20
11
 
21
12
  /** Unix-socket IPC server that handles status queries, wake requests, and shutdown commands. */
22
13
  export class SocketServer {
@@ -28,6 +19,11 @@ export class SocketServer {
28
19
  #cacheDir;
29
20
  #daemonStartedAt;
30
21
  #server;
22
+ #fsSync;
23
+ #clock;
24
+ #proc;
25
+ #resolveShutdown;
26
+ #shutdownPromise;
31
27
 
32
28
  /**
33
29
  * @param {string} socketPath
@@ -38,6 +34,8 @@ export class SocketServer {
38
34
  * @param {Function} logFn
39
35
  * @param {string} cacheDir
40
36
  * @param {number} daemonStartedAt
37
+ * @param {import("@forwardimpact/libutil/runtime").Runtime} runtime
38
+ * Injected runtime bag (uses `fsSync`, `clock`, `proc`).
41
39
  */
42
40
  constructor(
43
41
  socketPath,
@@ -48,6 +46,7 @@ export class SocketServer {
48
46
  logFn,
49
47
  cacheDir,
50
48
  daemonStartedAt,
49
+ runtime,
51
50
  ) {
52
51
  if (!socketPath) throw new Error("socketPath is required");
53
52
  if (!scheduler) throw new Error("scheduler is required");
@@ -56,6 +55,9 @@ export class SocketServer {
56
55
  if (!loadConfig) throw new Error("loadConfig is required");
57
56
  if (!logFn) throw new Error("logFn is required");
58
57
  if (!cacheDir) throw new Error("cacheDir is required");
58
+ if (!runtime?.fsSync) throw new Error("runtime.fsSync is required");
59
+ if (!runtime?.clock) throw new Error("runtime.clock is required");
60
+ if (!runtime?.proc) throw new Error("runtime.proc is required");
59
61
  this.#socketPath = socketPath;
60
62
  this.#agentRunner = agentRunner;
61
63
  this.#stateManager = stateManager;
@@ -63,6 +65,21 @@ export class SocketServer {
63
65
  this.#log = logFn;
64
66
  this.#cacheDir = cacheDir;
65
67
  this.#daemonStartedAt = daemonStartedAt;
68
+ this.#fsSync = runtime.fsSync;
69
+ this.#clock = runtime.clock;
70
+ this.#proc = runtime.proc;
71
+ this.#shutdownPromise = new Promise((r) => {
72
+ this.#resolveShutdown = r;
73
+ });
74
+ }
75
+
76
+ /**
77
+ * Resolves once a shutdown has been requested (via socket or signal). The
78
+ * daemon awaits this, then the bin translates it to `runtime.proc.exit(0)`.
79
+ * @returns {Promise<void>}
80
+ */
81
+ whenStopped() {
82
+ return this.#shutdownPromise;
66
83
  }
67
84
 
68
85
  /**
@@ -90,13 +107,13 @@ export class SocketServer {
90
107
  * @returns {string|null}
91
108
  */
92
109
  #latestFileByMtime(dir, filter) {
93
- const matches = readdirSync(dir).filter(filter);
110
+ const matches = this.#fsSync.readdirSync(dir).filter(filter);
94
111
  if (matches.length === 0) return null;
95
112
  let latest = join(dir, matches[0]);
96
- let latestMtime = statSync(latest).mtimeMs;
113
+ let latestMtime = this.#fsSync.statSync(latest).mtimeMs;
97
114
  for (let i = 1; i < matches.length; i++) {
98
115
  const p = join(dir, matches[i]);
99
- const mt = statSync(p).mtimeMs;
116
+ const mt = this.#fsSync.statSync(p).mtimeMs;
100
117
  if (mt > latestMtime) {
101
118
  latest = p;
102
119
  latestMtime = mt;
@@ -113,7 +130,7 @@ export class SocketServer {
113
130
  */
114
131
  #resolveBriefingFile(agentName, agentConfig) {
115
132
  const stateDir = join(this.#cacheDir, "state");
116
- if (existsSync(stateDir)) {
133
+ if (this.#fsSync.existsSync(stateDir)) {
117
134
  const prefix = agentName.replace(/-/g, "_") + "_";
118
135
  const found = this.#latestFileByMtime(
119
136
  stateDir,
@@ -128,8 +145,9 @@ export class SocketServer {
128
145
  "knowledge",
129
146
  "Briefings",
130
147
  );
131
- if (existsSync(dir)) {
132
- const files = readdirSync(dir)
148
+ if (this.#fsSync.existsSync(dir)) {
149
+ const files = this.#fsSync
150
+ .readdirSync(dir)
133
151
  .filter((f) => f.endsWith(".md"))
134
152
  .sort()
135
153
  .reverse();
@@ -143,10 +161,10 @@ export class SocketServer {
143
161
  /**
144
162
  * @param {import('node:net').Socket} socket
145
163
  */
146
- #handleStatusRequest(socket) {
147
- const config = this.#loadConfig();
148
- const state = this.#stateManager.load();
149
- const now = new Date();
164
+ async #handleStatusRequest(socket) {
165
+ const config = await this.#loadConfig();
166
+ const state = await this.#stateManager.load();
167
+ const now = nowFromClock(this.#clock);
150
168
  const agents = {};
151
169
 
152
170
  for (const [name, agent] of Object.entries(config.agents)) {
@@ -169,7 +187,7 @@ export class SocketServer {
169
187
  this.#send(socket, {
170
188
  type: "status",
171
189
  uptime: this.#daemonStartedAt
172
- ? Math.floor((Date.now() - this.#daemonStartedAt) / 1000)
190
+ ? Math.floor((this.#clock.now() - this.#daemonStartedAt) / 1000)
173
191
  : 0,
174
192
  agents,
175
193
  });
@@ -179,7 +197,7 @@ export class SocketServer {
179
197
  * @param {import('node:net').Socket} socket
180
198
  * @param {string} line
181
199
  */
182
- #handleMessage(socket, line) {
200
+ async #handleMessage(socket, line) {
183
201
  let request;
184
202
  try {
185
203
  request = JSON.parse(line);
@@ -194,8 +212,8 @@ export class SocketServer {
194
212
  this.#log("Shutdown requested via socket.");
195
213
  this.#send(socket, { type: "ack", command: "shutdown" });
196
214
  socket.end();
197
- this.#agentRunner.killActiveChildren();
198
- process.exit(0);
215
+ this.#requestShutdown();
216
+ return;
199
217
  }
200
218
 
201
219
  if (request.type === "wake") {
@@ -203,7 +221,7 @@ export class SocketServer {
203
221
  this.#send(socket, { type: "error", message: "Missing agent name" });
204
222
  return;
205
223
  }
206
- const config = this.#loadConfig();
224
+ const config = await this.#loadConfig();
207
225
  const agent = config.agents[request.agent];
208
226
  if (!agent) {
209
227
  this.#send(socket, {
@@ -217,7 +235,7 @@ export class SocketServer {
217
235
  command: "wake",
218
236
  agent: request.agent,
219
237
  });
220
- const state = this.#stateManager.load();
238
+ const state = await this.#stateManager.load();
221
239
  this.#agentRunner
222
240
  .wake(request.agent, agent, state, config.env)
223
241
  .catch(() => {});
@@ -231,12 +249,27 @@ export class SocketServer {
231
249
  }
232
250
 
233
251
  /**
234
- * Remove any existing socket file, bind the server, and register SIGTERM/SIGINT handlers to kill active children before exit.
252
+ * Tear down active children and the listening socket, then signal the daemon
253
+ * (via `whenStopped`) that it is safe to exit. The bin owns the actual
254
+ * `runtime.proc.exit` call (design Decision 4).
255
+ */
256
+ #requestShutdown() {
257
+ this.#agentRunner.killActiveChildren();
258
+ if (this.#server) this.#server.close();
259
+ try {
260
+ this.#fsSync.unlinkSync(this.#socketPath);
261
+ } catch {}
262
+ this.#resolveShutdown();
263
+ }
264
+
265
+ /**
266
+ * Remove any existing socket file, bind the server, and register
267
+ * SIGTERM/SIGINT handlers that request a graceful shutdown.
235
268
  * @returns {import('node:net').Server}
236
269
  */
237
270
  start() {
238
271
  try {
239
- unlinkSync(this.#socketPath);
272
+ this.#fsSync.unlinkSync(this.#socketPath);
240
273
  } catch {}
241
274
 
242
275
  this.#server = createServer((socket) => {
@@ -247,14 +280,14 @@ export class SocketServer {
247
280
  while ((idx = buffer.indexOf("\n")) !== -1) {
248
281
  const line = buffer.slice(0, idx).trim();
249
282
  buffer = buffer.slice(idx + 1);
250
- if (line) this.#handleMessage(socket, line);
283
+ if (line) void this.#handleMessage(socket, line);
251
284
  }
252
285
  });
253
286
  socket.on("error", () => {});
254
287
  });
255
288
 
256
289
  this.#server.listen(this.#socketPath, () => {
257
- chmodSync(this.#socketPath, 0o600);
290
+ this.#fsSync.chmodSync(this.#socketPath, 0o600);
258
291
  this.#log(`Socket server listening on ${this.#socketPath}`);
259
292
  });
260
293
 
@@ -262,16 +295,8 @@ export class SocketServer {
262
295
  this.#log(`Socket server error: ${err.message}`);
263
296
  });
264
297
 
265
- const cleanup = () => {
266
- this.#agentRunner.killActiveChildren();
267
- this.#server.close();
268
- try {
269
- unlinkSync(this.#socketPath);
270
- } catch {}
271
- process.exit(0);
272
- };
273
- process.on("SIGTERM", cleanup);
274
- process.on("SIGINT", cleanup);
298
+ this.#proc.on("SIGTERM", () => this.#requestShutdown());
299
+ this.#proc.on("SIGINT", () => this.#requestShutdown());
275
300
 
276
301
  return this.#server;
277
302
  }
@@ -284,7 +309,7 @@ export class SocketServer {
284
309
  this.#server.close();
285
310
  }
286
311
  try {
287
- unlinkSync(this.#socketPath);
312
+ this.#fsSync.unlinkSync(this.#socketPath);
288
313
  } catch {}
289
314
  }
290
315
  }
@@ -292,16 +317,21 @@ export class SocketServer {
292
317
  /**
293
318
  * Connect to the daemon socket and request graceful shutdown.
294
319
  * @param {string} socketPath
320
+ * @param {import("@forwardimpact/libutil/runtime").Runtime} runtime
321
+ * Injected runtime bag (uses `fsSync` and `clock`).
295
322
  * @returns {Promise<boolean>}
296
323
  */
297
- export async function requestShutdown(socketPath) {
298
- if (!existsSync(socketPath)) {
324
+ export async function requestShutdown(socketPath, runtime) {
325
+ if (!runtime?.fsSync) throw new Error("runtime.fsSync is required");
326
+ if (!runtime?.clock) throw new Error("runtime.clock is required");
327
+ const logger = createLogger("outpost", runtime);
328
+ if (!runtime.fsSync.existsSync(socketPath)) {
299
329
  logger.info("Daemon not running (no socket).");
300
330
  return false;
301
331
  }
302
332
 
303
333
  return new Promise((resolve) => {
304
- const timeout = setTimeout(() => {
334
+ const timeout = runtime.clock.setTimeout(() => {
305
335
  logger.info("Shutdown timed out.");
306
336
  socket.destroy();
307
337
  resolve(false);
@@ -315,7 +345,7 @@ export async function requestShutdown(socketPath) {
315
345
  socket.on("data", (data) => {
316
346
  buffer += data.toString();
317
347
  if (buffer.includes("\n")) {
318
- clearTimeout(timeout);
348
+ runtime.clock.clearTimeout(timeout);
319
349
  logger.info("Daemon stopped.");
320
350
  socket.destroy();
321
351
  resolve(true);
@@ -323,13 +353,13 @@ export async function requestShutdown(socketPath) {
323
353
  });
324
354
 
325
355
  socket.on("error", () => {
326
- clearTimeout(timeout);
356
+ runtime.clock.clearTimeout(timeout);
327
357
  logger.info("Daemon not running (connection refused).");
328
358
  resolve(false);
329
359
  });
330
360
 
331
361
  socket.on("close", () => {
332
- clearTimeout(timeout);
362
+ runtime.clock.clearTimeout(timeout);
333
363
  resolve(true);
334
364
  });
335
365
  });
@@ -2,41 +2,46 @@
2
2
  * StateManager — load/save state.json, reset stale agents.
3
3
  */
4
4
 
5
- import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
6
5
  import { dirname, join } from "node:path";
6
+ import { isoTimestamp } from "@forwardimpact/libutil";
7
7
 
8
8
  /** Persist and query agent scheduler state from a JSON file on disk. */
9
9
  export class StateManager {
10
10
  #statePath;
11
11
  #fs;
12
+ #clock;
12
13
 
13
14
  /**
14
15
  * @param {string} statePath - Path to state.json
15
- * @param {{ readFileSync: Function, writeFileSync: Function, mkdirSync: Function }} fs
16
+ * @param {import("@forwardimpact/libutil/runtime").Runtime} runtime
17
+ * Injected runtime bag (uses `fs` (async) and `clock`).
16
18
  */
17
- constructor(statePath, fs) {
19
+ constructor(statePath, runtime) {
18
20
  if (!statePath) throw new Error("statePath is required");
19
- if (!fs) throw new Error("fs is required");
21
+ if (!runtime?.fs) throw new Error("runtime.fs is required");
22
+ if (!runtime?.clock) throw new Error("runtime.clock is required");
20
23
  this.#statePath = statePath;
21
- this.#fs = fs;
24
+ this.#fs = runtime.fs;
25
+ this.#clock = runtime.clock;
22
26
  }
23
27
 
24
28
  /**
25
- * Read and parse state from disk; on any read or parse error, write a fresh empty state and return it.
26
- * @returns {Object}
29
+ * Read and parse state from disk; on any read or parse error, write a fresh
30
+ * empty state and return it.
31
+ * @returns {Promise<Object>}
27
32
  */
28
- load() {
33
+ async load() {
29
34
  try {
30
- const raw = JSON.parse(this.#fs.readFileSync(this.#statePath, "utf8"));
35
+ const raw = JSON.parse(await this.#fs.readFile(this.#statePath, "utf8"));
31
36
  if (!raw || typeof raw !== "object" || !raw.agents) {
32
37
  const state = { agents: {} };
33
- this.save(state);
38
+ await this.save(state);
34
39
  return state;
35
40
  }
36
41
  return raw;
37
42
  } catch {
38
43
  const state = { agents: {} };
39
- this.save(state);
44
+ await this.save(state);
40
45
  return state;
41
46
  }
42
47
  }
@@ -44,10 +49,11 @@ export class StateManager {
44
49
  /**
45
50
  * Save state to disk
46
51
  * @param {Object} state
52
+ * @returns {Promise<void>}
47
53
  */
48
- save(state) {
49
- this.#fs.mkdirSync(dirname(this.#statePath), { recursive: true });
50
- this.#fs.writeFileSync(
54
+ async save(state) {
55
+ await this.#fs.mkdir(dirname(this.#statePath), { recursive: true });
56
+ await this.#fs.writeFile(
51
57
  this.#statePath,
52
58
  JSON.stringify(state, null, 2) + "\n",
53
59
  );
@@ -58,14 +64,14 @@ export class StateManager {
58
64
  * @param {Object} state
59
65
  * @param {{ reason: string, maxAge?: number }} opts
60
66
  * @param {Function} logFn
61
- * @returns {number} Number of agents reset
67
+ * @returns {Promise<number>} Number of agents reset
62
68
  */
63
- resetStaleAgents(state, { reason, maxAge }, logFn) {
69
+ async resetStaleAgents(state, { reason, maxAge }, logFn) {
64
70
  let resetCount = 0;
65
71
  for (const [name, as] of Object.entries(state.agents)) {
66
72
  if (as.status !== "active") continue;
67
73
  if (maxAge && as.startedAt) {
68
- const elapsed = Date.now() - new Date(as.startedAt).getTime();
74
+ const elapsed = this.#clock.now() - Date.parse(as.startedAt);
69
75
  if (elapsed < maxAge) continue;
70
76
  }
71
77
  logFn(`Resetting stale agent: ${name} (${reason})`);
@@ -76,7 +82,7 @@ export class StateManager {
76
82
  });
77
83
  resetCount++;
78
84
  }
79
- if (resetCount > 0) this.save(state);
85
+ if (resetCount > 0) await this.save(state);
80
86
  return resetCount;
81
87
  }
82
88
 
@@ -86,8 +92,9 @@ export class StateManager {
86
92
  * @param {string} stdout
87
93
  * @param {string} agentName
88
94
  * @param {string} cacheDir - Cache directory for state files
95
+ * @returns {Promise<void>}
89
96
  */
90
- updateAgentState(agentState, stdout, agentName, cacheDir) {
97
+ async updateAgentState(agentState, stdout, agentName, cacheDir) {
91
98
  const lines = stdout.split("\n");
92
99
  const decisionLine = lines.find((l) => l.startsWith("Decision:"));
93
100
  const actionLine = lines.find((l) => l.startsWith("Action:"));
@@ -95,7 +102,7 @@ export class StateManager {
95
102
  Object.assign(agentState, {
96
103
  status: "idle",
97
104
  startedAt: null,
98
- lastWokeAt: new Date().toISOString(),
105
+ lastWokeAt: isoTimestamp(this.#clock.now()),
99
106
  lastDecision: decisionLine
100
107
  ? decisionLine.slice(10).trim()
101
108
  : stdout.slice(0, 200),
@@ -106,21 +113,11 @@ export class StateManager {
106
113
 
107
114
  // Save output as briefing fallback
108
115
  const stateDir = join(cacheDir, "state");
109
- this.#fs.mkdirSync(stateDir, { recursive: true });
116
+ await this.#fs.mkdir(stateDir, { recursive: true });
110
117
  const prefix = agentName.replace(/-/g, "_");
111
- this.#fs.writeFileSync(join(stateDir, `${prefix}_last_output.md`), stdout);
118
+ await this.#fs.writeFile(
119
+ join(stateDir, `${prefix}_last_output.md`),
120
+ stdout,
121
+ );
112
122
  }
113
123
  }
114
-
115
- /**
116
- * Create a StateManager with real fs dependencies
117
- * @param {string} statePath
118
- * @returns {StateManager}
119
- */
120
- export function createStateManager(statePath) {
121
- return new StateManager(statePath, {
122
- readFileSync,
123
- writeFileSync,
124
- mkdirSync,
125
- });
126
- }