@forwardimpact/outpost 3.1.2 → 3.1.4

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,11 @@
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
8
 
16
9
  const logger = createLogger("outpost");
17
10
  import { join, resolve } from "node:path";
18
11
  import { homedir } from "node:os";
19
- import { computeNextWakeAt } from "./scheduler.js";
12
+ import { computeNextWakeAt, nowFromClock } from "./scheduler.js";
20
13
 
21
14
  /** Unix-socket IPC server that handles status queries, wake requests, and shutdown commands. */
22
15
  export class SocketServer {
@@ -28,6 +21,11 @@ export class SocketServer {
28
21
  #cacheDir;
29
22
  #daemonStartedAt;
30
23
  #server;
24
+ #fsSync;
25
+ #clock;
26
+ #proc;
27
+ #resolveShutdown;
28
+ #shutdownPromise;
31
29
 
32
30
  /**
33
31
  * @param {string} socketPath
@@ -38,6 +36,8 @@ export class SocketServer {
38
36
  * @param {Function} logFn
39
37
  * @param {string} cacheDir
40
38
  * @param {number} daemonStartedAt
39
+ * @param {import("@forwardimpact/libutil/runtime").Runtime} runtime
40
+ * Injected runtime bag (uses `fsSync`, `clock`, `proc`).
41
41
  */
42
42
  constructor(
43
43
  socketPath,
@@ -48,6 +48,7 @@ export class SocketServer {
48
48
  logFn,
49
49
  cacheDir,
50
50
  daemonStartedAt,
51
+ runtime,
51
52
  ) {
52
53
  if (!socketPath) throw new Error("socketPath is required");
53
54
  if (!scheduler) throw new Error("scheduler is required");
@@ -56,6 +57,9 @@ export class SocketServer {
56
57
  if (!loadConfig) throw new Error("loadConfig is required");
57
58
  if (!logFn) throw new Error("logFn is required");
58
59
  if (!cacheDir) throw new Error("cacheDir is required");
60
+ if (!runtime?.fsSync) throw new Error("runtime.fsSync is required");
61
+ if (!runtime?.clock) throw new Error("runtime.clock is required");
62
+ if (!runtime?.proc) throw new Error("runtime.proc is required");
59
63
  this.#socketPath = socketPath;
60
64
  this.#agentRunner = agentRunner;
61
65
  this.#stateManager = stateManager;
@@ -63,6 +67,21 @@ export class SocketServer {
63
67
  this.#log = logFn;
64
68
  this.#cacheDir = cacheDir;
65
69
  this.#daemonStartedAt = daemonStartedAt;
70
+ this.#fsSync = runtime.fsSync;
71
+ this.#clock = runtime.clock;
72
+ this.#proc = runtime.proc;
73
+ this.#shutdownPromise = new Promise((r) => {
74
+ this.#resolveShutdown = r;
75
+ });
76
+ }
77
+
78
+ /**
79
+ * Resolves once a shutdown has been requested (via socket or signal). The
80
+ * daemon awaits this, then the bin translates it to `runtime.proc.exit(0)`.
81
+ * @returns {Promise<void>}
82
+ */
83
+ whenStopped() {
84
+ return this.#shutdownPromise;
66
85
  }
67
86
 
68
87
  /**
@@ -90,13 +109,13 @@ export class SocketServer {
90
109
  * @returns {string|null}
91
110
  */
92
111
  #latestFileByMtime(dir, filter) {
93
- const matches = readdirSync(dir).filter(filter);
112
+ const matches = this.#fsSync.readdirSync(dir).filter(filter);
94
113
  if (matches.length === 0) return null;
95
114
  let latest = join(dir, matches[0]);
96
- let latestMtime = statSync(latest).mtimeMs;
115
+ let latestMtime = this.#fsSync.statSync(latest).mtimeMs;
97
116
  for (let i = 1; i < matches.length; i++) {
98
117
  const p = join(dir, matches[i]);
99
- const mt = statSync(p).mtimeMs;
118
+ const mt = this.#fsSync.statSync(p).mtimeMs;
100
119
  if (mt > latestMtime) {
101
120
  latest = p;
102
121
  latestMtime = mt;
@@ -113,7 +132,7 @@ export class SocketServer {
113
132
  */
114
133
  #resolveBriefingFile(agentName, agentConfig) {
115
134
  const stateDir = join(this.#cacheDir, "state");
116
- if (existsSync(stateDir)) {
135
+ if (this.#fsSync.existsSync(stateDir)) {
117
136
  const prefix = agentName.replace(/-/g, "_") + "_";
118
137
  const found = this.#latestFileByMtime(
119
138
  stateDir,
@@ -128,8 +147,9 @@ export class SocketServer {
128
147
  "knowledge",
129
148
  "Briefings",
130
149
  );
131
- if (existsSync(dir)) {
132
- const files = readdirSync(dir)
150
+ if (this.#fsSync.existsSync(dir)) {
151
+ const files = this.#fsSync
152
+ .readdirSync(dir)
133
153
  .filter((f) => f.endsWith(".md"))
134
154
  .sort()
135
155
  .reverse();
@@ -143,10 +163,10 @@ export class SocketServer {
143
163
  /**
144
164
  * @param {import('node:net').Socket} socket
145
165
  */
146
- #handleStatusRequest(socket) {
147
- const config = this.#loadConfig();
148
- const state = this.#stateManager.load();
149
- const now = new Date();
166
+ async #handleStatusRequest(socket) {
167
+ const config = await this.#loadConfig();
168
+ const state = await this.#stateManager.load();
169
+ const now = nowFromClock(this.#clock);
150
170
  const agents = {};
151
171
 
152
172
  for (const [name, agent] of Object.entries(config.agents)) {
@@ -169,7 +189,7 @@ export class SocketServer {
169
189
  this.#send(socket, {
170
190
  type: "status",
171
191
  uptime: this.#daemonStartedAt
172
- ? Math.floor((Date.now() - this.#daemonStartedAt) / 1000)
192
+ ? Math.floor((this.#clock.now() - this.#daemonStartedAt) / 1000)
173
193
  : 0,
174
194
  agents,
175
195
  });
@@ -179,7 +199,7 @@ export class SocketServer {
179
199
  * @param {import('node:net').Socket} socket
180
200
  * @param {string} line
181
201
  */
182
- #handleMessage(socket, line) {
202
+ async #handleMessage(socket, line) {
183
203
  let request;
184
204
  try {
185
205
  request = JSON.parse(line);
@@ -194,8 +214,8 @@ export class SocketServer {
194
214
  this.#log("Shutdown requested via socket.");
195
215
  this.#send(socket, { type: "ack", command: "shutdown" });
196
216
  socket.end();
197
- this.#agentRunner.killActiveChildren();
198
- process.exit(0);
217
+ this.#requestShutdown();
218
+ return;
199
219
  }
200
220
 
201
221
  if (request.type === "wake") {
@@ -203,7 +223,7 @@ export class SocketServer {
203
223
  this.#send(socket, { type: "error", message: "Missing agent name" });
204
224
  return;
205
225
  }
206
- const config = this.#loadConfig();
226
+ const config = await this.#loadConfig();
207
227
  const agent = config.agents[request.agent];
208
228
  if (!agent) {
209
229
  this.#send(socket, {
@@ -217,7 +237,7 @@ export class SocketServer {
217
237
  command: "wake",
218
238
  agent: request.agent,
219
239
  });
220
- const state = this.#stateManager.load();
240
+ const state = await this.#stateManager.load();
221
241
  this.#agentRunner
222
242
  .wake(request.agent, agent, state, config.env)
223
243
  .catch(() => {});
@@ -231,12 +251,27 @@ export class SocketServer {
231
251
  }
232
252
 
233
253
  /**
234
- * Remove any existing socket file, bind the server, and register SIGTERM/SIGINT handlers to kill active children before exit.
254
+ * Tear down active children and the listening socket, then signal the daemon
255
+ * (via `whenStopped`) that it is safe to exit. The bin owns the actual
256
+ * `runtime.proc.exit` call (design Decision 4).
257
+ */
258
+ #requestShutdown() {
259
+ this.#agentRunner.killActiveChildren();
260
+ if (this.#server) this.#server.close();
261
+ try {
262
+ this.#fsSync.unlinkSync(this.#socketPath);
263
+ } catch {}
264
+ this.#resolveShutdown();
265
+ }
266
+
267
+ /**
268
+ * Remove any existing socket file, bind the server, and register
269
+ * SIGTERM/SIGINT handlers that request a graceful shutdown.
235
270
  * @returns {import('node:net').Server}
236
271
  */
237
272
  start() {
238
273
  try {
239
- unlinkSync(this.#socketPath);
274
+ this.#fsSync.unlinkSync(this.#socketPath);
240
275
  } catch {}
241
276
 
242
277
  this.#server = createServer((socket) => {
@@ -247,14 +282,14 @@ export class SocketServer {
247
282
  while ((idx = buffer.indexOf("\n")) !== -1) {
248
283
  const line = buffer.slice(0, idx).trim();
249
284
  buffer = buffer.slice(idx + 1);
250
- if (line) this.#handleMessage(socket, line);
285
+ if (line) void this.#handleMessage(socket, line);
251
286
  }
252
287
  });
253
288
  socket.on("error", () => {});
254
289
  });
255
290
 
256
291
  this.#server.listen(this.#socketPath, () => {
257
- chmodSync(this.#socketPath, 0o600);
292
+ this.#fsSync.chmodSync(this.#socketPath, 0o600);
258
293
  this.#log(`Socket server listening on ${this.#socketPath}`);
259
294
  });
260
295
 
@@ -262,16 +297,8 @@ export class SocketServer {
262
297
  this.#log(`Socket server error: ${err.message}`);
263
298
  });
264
299
 
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);
300
+ this.#proc.on("SIGTERM", () => this.#requestShutdown());
301
+ this.#proc.on("SIGINT", () => this.#requestShutdown());
275
302
 
276
303
  return this.#server;
277
304
  }
@@ -284,7 +311,7 @@ export class SocketServer {
284
311
  this.#server.close();
285
312
  }
286
313
  try {
287
- unlinkSync(this.#socketPath);
314
+ this.#fsSync.unlinkSync(this.#socketPath);
288
315
  } catch {}
289
316
  }
290
317
  }
@@ -292,16 +319,20 @@ export class SocketServer {
292
319
  /**
293
320
  * Connect to the daemon socket and request graceful shutdown.
294
321
  * @param {string} socketPath
322
+ * @param {import("@forwardimpact/libutil/runtime").Runtime} runtime
323
+ * Injected runtime bag (uses `fsSync` and `clock`).
295
324
  * @returns {Promise<boolean>}
296
325
  */
297
- export async function requestShutdown(socketPath) {
298
- if (!existsSync(socketPath)) {
326
+ export async function requestShutdown(socketPath, runtime) {
327
+ if (!runtime?.fsSync) throw new Error("runtime.fsSync is required");
328
+ if (!runtime?.clock) throw new Error("runtime.clock is required");
329
+ if (!runtime.fsSync.existsSync(socketPath)) {
299
330
  logger.info("Daemon not running (no socket).");
300
331
  return false;
301
332
  }
302
333
 
303
334
  return new Promise((resolve) => {
304
- const timeout = setTimeout(() => {
335
+ const timeout = runtime.clock.setTimeout(() => {
305
336
  logger.info("Shutdown timed out.");
306
337
  socket.destroy();
307
338
  resolve(false);
@@ -315,7 +346,7 @@ export async function requestShutdown(socketPath) {
315
346
  socket.on("data", (data) => {
316
347
  buffer += data.toString();
317
348
  if (buffer.includes("\n")) {
318
- clearTimeout(timeout);
349
+ runtime.clock.clearTimeout(timeout);
319
350
  logger.info("Daemon stopped.");
320
351
  socket.destroy();
321
352
  resolve(true);
@@ -323,13 +354,13 @@ export async function requestShutdown(socketPath) {
323
354
  });
324
355
 
325
356
  socket.on("error", () => {
326
- clearTimeout(timeout);
357
+ runtime.clock.clearTimeout(timeout);
327
358
  logger.info("Daemon not running (connection refused).");
328
359
  resolve(false);
329
360
  });
330
361
 
331
362
  socket.on("close", () => {
332
- clearTimeout(timeout);
363
+ runtime.clock.clearTimeout(timeout);
333
364
  resolve(true);
334
365
  });
335
366
  });
@@ -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
- }