fifony 0.1.43 → 0.1.47

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.
Files changed (65) hide show
  1. package/app/dist/assets/{CommandPalette-M4VAMxCU.js → CommandPalette-CL8p78lG.js} +1 -1
  2. package/app/dist/assets/{KeyboardShortcutsHelp-DkvPUXQq.js → KeyboardShortcutsHelp-CqEFfGcE.js} +1 -1
  3. package/app/dist/assets/OnboardingWizard-BmI50ZUv.js +1 -0
  4. package/app/dist/assets/analytics.lazy-CXGjZabc.js +1 -0
  5. package/app/dist/assets/{api-CkVfYg_m.js → api-CEr_D4e5.js} +1 -1
  6. package/app/dist/assets/{createLucideIcon-Dfk_Hxud.js → createLucideIcon-luywpIq4.js} +1 -1
  7. package/app/dist/assets/index-CEaccpYh.js +96 -0
  8. package/app/dist/assets/index-CzzWGzux.css +1 -0
  9. package/app/dist/assets/vendor-uqBx3VSC.js +9 -0
  10. package/app/dist/index.html +12 -12
  11. package/app/dist/service-worker.js +15 -5
  12. package/dist/agent/pty-daemon.js +3 -2
  13. package/dist/agent/run-local.js +71 -52
  14. package/dist/{agent-RMQTTUEC.js → agent-DFSFG6DG.js} +18 -12
  15. package/dist/{analytics-broadcaster-O6YBP66L.js → analytics-broadcaster-O4AE3RUK.js} +21 -14
  16. package/dist/approve-plan.command-QGQZZXTQ.js +17 -0
  17. package/dist/{chunk-E2EWEYA4.js → chunk-2PRRKBG6.js} +20 -10
  18. package/dist/chunk-5AMWD66T.js +38 -0
  19. package/dist/{chunk-QQQLP3PL.js → chunk-7TXZYZR5.js} +9 -37
  20. package/dist/chunk-AAVROEQC.js +859 -0
  21. package/dist/{chunk-ESWHDHH6.js → chunk-AAZKYWOY.js} +4 -4
  22. package/dist/chunk-EBCSQFPR.js +682 -0
  23. package/dist/{chunk-BRSR26VK.js → chunk-FH7HUPZX.js} +2 -2
  24. package/dist/chunk-HOIOVUHI.js +35 -0
  25. package/dist/{chunk-AILXZ2TD.js → chunk-JRLWLZOD.js} +20 -13
  26. package/dist/{chunk-YRSH2CLW.js → chunk-K36BWMUV.js} +1741 -1216
  27. package/dist/chunk-N4KFNX2G.js +370 -0
  28. package/dist/chunk-PACI3T4I.js +125 -0
  29. package/dist/{chunk-FJNH3G2Z.js → chunk-PI7Y77R3.js} +38 -663
  30. package/dist/{chunk-DVU3CXWA.js → chunk-PXTIWKLQ.js} +2 -1
  31. package/dist/{chunk-SOBLO4YZ.js → chunk-QH6VCTET.js} +316 -127
  32. package/dist/{chunk-MVTGAKQK.js → chunk-QHISYRXJ.js} +2 -2
  33. package/dist/{chunk-42AMQAJG.js → chunk-VM5QAYP5.js} +2 -2
  34. package/dist/cli.js +17 -11
  35. package/dist/create-issue.command-VAKYRECC.js +24 -0
  36. package/dist/{fsm-issue-YGGF7SIL.js → fsm-issue-EHTSKMFN.js} +9 -8
  37. package/dist/fsm-service-7O4AJG2R.js +32 -0
  38. package/dist/{helpers-L7NYO5XS.js → helpers-ON2S7UEF.js} +2 -2
  39. package/dist/{issue-log-broadcaster-WZAHISYB.js → issue-log-broadcaster-FZGVEEIX.js} +20 -13
  40. package/dist/{issues-3QRR7KM6.js → issues-3YNNTB4U.js} +10 -7
  41. package/dist/{log-analyzer-K7MXQB4T.js → log-analyzer-EIX6R6PP.js} +82 -18
  42. package/dist/logger-IFLXTQPS.js +11 -0
  43. package/dist/mcp/server.js +2 -2
  44. package/dist/merge-workspace.command-T2NIGR4M.js +24 -0
  45. package/dist/{parallel-executor-6INE6NDO.js → parallel-executor-DWESCNX3.js} +20 -14
  46. package/dist/queue-workers-V57BYXAY.js +38 -0
  47. package/dist/replan-issue.command-2GQ3QXCR.js +17 -0
  48. package/dist/retry-issue.command-GJBUUYDJ.js +17 -0
  49. package/dist/scheduler-KYILMWLD.js +32 -0
  50. package/dist/{settings-ZAWDCFP2.js → settings-SOTIS6ZD.js} +32 -12
  51. package/dist/settings.resource-JMD3JQOS.js +30 -0
  52. package/dist/{store-M6NCKMZY.js → store-S3NAYZ3S.js} +18 -12
  53. package/dist/{web-push-AX5IIK3P.js → web-push-QCTLS7EJ.js} +3 -3
  54. package/dist/websocket-T2Y3BY4B.js +61 -0
  55. package/dist/{workspace-CJTWFWTJ.js → workspace-OS7GPMCN.js} +7 -6
  56. package/package.json +8 -5
  57. package/app/dist/assets/OnboardingWizard-B7V9hoCR.js +0 -1
  58. package/app/dist/assets/analytics.lazy-zVJdF880.js +0 -1
  59. package/app/dist/assets/index-BpiCi7Ew.css +0 -1
  60. package/app/dist/assets/index-D2INW0zc.js +0 -47
  61. package/app/dist/assets/vendor-BEoYbFV1.js +0 -9
  62. package/dist/queue-workers-XFZK3TT5.js +0 -32
  63. package/dist/replan-issue.command-4UCWYHGZ.js +0 -15
  64. package/dist/scheduler-ZP7GOZDW.js +0 -26
  65. package/dist/settings.resource-5CW456AZ.js +0 -24
@@ -0,0 +1,859 @@
1
+ import {
2
+ S3DB_SERVICES_RESOURCE,
3
+ now
4
+ } from "./chunk-VM5QAYP5.js";
5
+ import {
6
+ logger
7
+ } from "./chunk-PXTIWKLQ.js";
8
+ import {
9
+ isProcessAlive
10
+ } from "./chunk-3NE23NYW.js";
11
+
12
+ // src/persistence/plugins/fsm-service.ts
13
+ import {
14
+ closeSync,
15
+ existsSync,
16
+ openSync,
17
+ readFileSync,
18
+ readSync,
19
+ rmSync,
20
+ statSync,
21
+ writeFileSync
22
+ } from "fs";
23
+ import { join, resolve } from "path";
24
+ import { spawn } from "child_process";
25
+
26
+ // src/domains/service-env.ts
27
+ var ENV_KEY_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
28
+ function isValidServiceEnvKey(key) {
29
+ return ENV_KEY_PATTERN.test(key);
30
+ }
31
+ function normalizeServiceEnvironment(value) {
32
+ if (value === void 0 || value === null) {
33
+ return { env: {}, errors: [] };
34
+ }
35
+ if (typeof value !== "object" || Array.isArray(value)) {
36
+ return { env: {}, errors: ["Environment must be an object map of KEY -> value."] };
37
+ }
38
+ const env = {};
39
+ const errors = [];
40
+ for (const [rawKey, rawValue] of Object.entries(value)) {
41
+ const key = rawKey.trim();
42
+ if (!key) continue;
43
+ if (!isValidServiceEnvKey(key)) {
44
+ errors.push(`Invalid environment variable name: ${rawKey}`);
45
+ continue;
46
+ }
47
+ env[key] = rawValue === void 0 || rawValue === null ? "" : String(rawValue);
48
+ }
49
+ return { env, errors };
50
+ }
51
+ function mergeServiceEnvironment(globalEnv, serviceEnv) {
52
+ return {
53
+ ...globalEnv ?? {},
54
+ ...serviceEnv ?? {}
55
+ };
56
+ }
57
+ function shellQuoteEnvValue(value) {
58
+ return `'${value.replace(/'/g, `'"'"'`)}'`;
59
+ }
60
+ function buildServiceCommand(command, globalEnv, serviceEnv) {
61
+ const baseCommand = command.trim();
62
+ if (!baseCommand) return "";
63
+ const env = mergeServiceEnvironment(globalEnv, serviceEnv);
64
+ const assignments = Object.entries(env).map(([key, value]) => `${key}=${shellQuoteEnvValue(value)}`);
65
+ return assignments.length > 0 ? `${assignments.join(" ")} ${baseCommand}` : baseCommand;
66
+ }
67
+
68
+ // src/persistence/plugins/traffic-proxy-server.ts
69
+ import { createServer } from "http";
70
+ import {
71
+ createHttpForwardProxy
72
+ } from "raffel";
73
+
74
+ // src/domains/traffic-proxy.ts
75
+ var TrafficRingBuffer = class {
76
+ constructor(capacity = 1e3) {
77
+ this.capacity = capacity;
78
+ this.buf = new Array(capacity);
79
+ }
80
+ buf;
81
+ head = 0;
82
+ count = 0;
83
+ push(entry) {
84
+ this.buf[this.head] = entry;
85
+ this.head = (this.head + 1) % this.capacity;
86
+ if (this.count < this.capacity) this.count++;
87
+ }
88
+ getAll() {
89
+ if (this.count === 0) return [];
90
+ if (this.count < this.capacity) return this.buf.slice(0, this.count);
91
+ return [...this.buf.slice(this.head), ...this.buf.slice(0, this.head)];
92
+ }
93
+ getRecent(n) {
94
+ const all = this.getAll();
95
+ return n >= all.length ? all : all.slice(-n);
96
+ }
97
+ clear() {
98
+ this.head = 0;
99
+ this.count = 0;
100
+ }
101
+ get size() {
102
+ return this.count;
103
+ }
104
+ };
105
+ function edgeKey(source, target) {
106
+ return `${source}\0${target}`;
107
+ }
108
+ var MAX_TOP_PATHS = 5;
109
+ var MAX_P95_SAMPLES = 200;
110
+ var ServiceGraphAccumulator = class {
111
+ edges = /* @__PURE__ */ new Map();
112
+ totalRequests = 0;
113
+ capturedSince = (/* @__PURE__ */ new Date()).toISOString();
114
+ record(entry) {
115
+ const src = entry.sourceServiceId ?? "unknown";
116
+ const tgt = entry.targetServiceId ?? "external";
117
+ const key = edgeKey(src, tgt);
118
+ this.totalRequests++;
119
+ let edge = this.edges.get(key);
120
+ if (!edge) {
121
+ edge = {
122
+ source: src,
123
+ target: tgt,
124
+ requestCount: 0,
125
+ errorCount: 0,
126
+ latency: { sum: 0, count: 0, values: [] },
127
+ lastSeenAt: entry.startedAt,
128
+ pathCounts: /* @__PURE__ */ new Map()
129
+ };
130
+ this.edges.set(key, edge);
131
+ }
132
+ edge.requestCount++;
133
+ if (entry.statusCode >= 400 || entry.error) edge.errorCount++;
134
+ edge.latency.sum += entry.durationMs;
135
+ edge.latency.count++;
136
+ if (edge.latency.values.length >= MAX_P95_SAMPLES) edge.latency.values.shift();
137
+ edge.latency.values.push(entry.durationMs);
138
+ edge.lastSeenAt = entry.startedAt;
139
+ edge.pathCounts.set(entry.path, (edge.pathCounts.get(entry.path) ?? 0) + 1);
140
+ }
141
+ getGraph(services) {
142
+ const nodes = services.map((s) => ({
143
+ id: s.id,
144
+ name: s.name,
145
+ state: s.state,
146
+ port: s.port
147
+ }));
148
+ const edges = [];
149
+ for (const e of this.edges.values()) {
150
+ const sorted = [...e.latency.values].sort((a, b) => a - b);
151
+ const pct = (p) => sorted.length > 0 ? sorted[Math.min(Math.floor(sorted.length * p), sorted.length - 1)] : 0;
152
+ const topPaths = [...e.pathCounts.entries()].sort((a, b) => b[1] - a[1]).slice(0, MAX_TOP_PATHS).map(([path, count]) => ({ path, count }));
153
+ edges.push({
154
+ source: e.source,
155
+ target: e.target,
156
+ requestCount: e.requestCount,
157
+ errorCount: e.errorCount,
158
+ avgLatencyMs: e.latency.count > 0 ? Math.round(e.latency.sum / e.latency.count) : 0,
159
+ p50LatencyMs: pct(0.5),
160
+ p90LatencyMs: pct(0.9),
161
+ p95LatencyMs: pct(0.95),
162
+ p99LatencyMs: pct(0.99),
163
+ lastSeenAt: e.lastSeenAt,
164
+ topPaths
165
+ });
166
+ }
167
+ return {
168
+ nodes,
169
+ edges,
170
+ capturedSince: this.capturedSince,
171
+ totalRequests: this.totalRequests
172
+ };
173
+ }
174
+ reset() {
175
+ this.edges.clear();
176
+ this.totalRequests = 0;
177
+ this.capturedSince = (/* @__PURE__ */ new Date()).toISOString();
178
+ }
179
+ };
180
+ function extractServiceIdFromProxyAuth(headers) {
181
+ const auth = headers["proxy-authorization"];
182
+ if (!auth) return null;
183
+ const match = auth.match(/^Basic\s+(.+)$/i);
184
+ if (!match) return null;
185
+ try {
186
+ const decoded = Buffer.from(match[1], "base64").toString("utf8");
187
+ const colon = decoded.indexOf(":");
188
+ return colon > 0 ? decoded.slice(0, colon) : decoded;
189
+ } catch {
190
+ return null;
191
+ }
192
+ }
193
+ function resolveTargetService(url, services) {
194
+ try {
195
+ const parsed = new URL(url);
196
+ const port = Number(parsed.port || (parsed.protocol === "https:" ? 443 : 80));
197
+ const match = services.find((s) => s.port === port);
198
+ return match?.id ?? null;
199
+ } catch {
200
+ return null;
201
+ }
202
+ }
203
+ function buildProxyEnvVars(proxyPort, serviceId, dashboardPort, extraNoPorts = []) {
204
+ const proxyUrl = `http://${encodeURIComponent(serviceId)}:fifony@localhost:${proxyPort}`;
205
+ const noProxyList = [`localhost:${dashboardPort}`, ...extraNoPorts.map((p) => `localhost:${p}`)].join(",");
206
+ return {
207
+ HTTP_PROXY: proxyUrl,
208
+ http_proxy: proxyUrl,
209
+ NO_PROXY: noProxyList,
210
+ no_proxy: noProxyList
211
+ };
212
+ }
213
+ var entrySeq = 0;
214
+ function buildTrafficEntry(method, url, requestSize, statusCode, responseSize, sourceServiceId, targetServiceId, startTime, error) {
215
+ let path;
216
+ try {
217
+ path = new URL(url).pathname;
218
+ } catch {
219
+ path = url;
220
+ }
221
+ return {
222
+ id: `tr_${Date.now()}_${++entrySeq}`,
223
+ sourceServiceId,
224
+ targetServiceId,
225
+ method,
226
+ url,
227
+ path,
228
+ statusCode,
229
+ requestSize,
230
+ responseSize,
231
+ startedAt: new Date(startTime).toISOString(),
232
+ durationMs: Date.now() - startTime,
233
+ error
234
+ };
235
+ }
236
+
237
+ // src/persistence/plugins/traffic-proxy-server.ts
238
+ var server = null;
239
+ var proxy = null;
240
+ var buffer = null;
241
+ var graph = null;
242
+ var boundPort = null;
243
+ var onEntryCallback = null;
244
+ var servicesAccessor = null;
245
+ function setServicesAccessor(fn) {
246
+ servicesAccessor = fn;
247
+ }
248
+ async function startTrafficProxy(options = {}) {
249
+ if (server) {
250
+ logger.warn("Traffic proxy already running, skipping start");
251
+ return boundPort;
252
+ }
253
+ const port = options.port ?? 0;
254
+ const bufferSize = options.bufferSize ?? 1e3;
255
+ buffer = new TrafficRingBuffer(bufferSize);
256
+ graph = new ServiceGraphAccumulator();
257
+ onEntryCallback = options.onEntry ?? null;
258
+ proxy = createHttpForwardProxy({
259
+ timeout: 3e4,
260
+ maxBodySize: 10 * 1024 * 1024
261
+ });
262
+ server = createServer((req, res) => {
263
+ const startTime = Date.now();
264
+ const method = req.method ?? "GET";
265
+ const url = req.url ?? "";
266
+ const sourceId = extractServiceIdFromProxyAuth(
267
+ req.headers
268
+ );
269
+ const services = servicesAccessor?.() ?? [];
270
+ const targetId = resolveTargetService(url, services);
271
+ const requestSize = Number(req.headers["content-length"] ?? 0);
272
+ res.on("finish", () => {
273
+ const entry = buildTrafficEntry(
274
+ method,
275
+ url,
276
+ requestSize,
277
+ res.statusCode,
278
+ Number(res.getHeader("content-length") ?? 0),
279
+ sourceId,
280
+ targetId,
281
+ startTime
282
+ );
283
+ buffer?.push(entry);
284
+ graph?.record(entry);
285
+ onEntryCallback?.(entry);
286
+ });
287
+ proxy.requestHandler(req, res);
288
+ });
289
+ return new Promise((resolve2, reject) => {
290
+ server.listen(port, "127.0.0.1", () => {
291
+ const addr = server.address();
292
+ boundPort = typeof addr === "object" && addr ? addr.port : port;
293
+ logger.info({ port: boundPort }, "Mesh traffic proxy started");
294
+ resolve2(boundPort);
295
+ });
296
+ server.on("error", (err) => {
297
+ logger.error({ err }, "Mesh traffic proxy failed to start");
298
+ reject(err);
299
+ });
300
+ });
301
+ }
302
+ async function stopTrafficProxy() {
303
+ if (!server) return;
304
+ return new Promise((resolve2) => {
305
+ server.close(() => {
306
+ logger.info("Mesh traffic proxy stopped");
307
+ server = null;
308
+ proxy = null;
309
+ boundPort = null;
310
+ resolve2();
311
+ });
312
+ });
313
+ }
314
+ function getTrafficProxyPort() {
315
+ return boundPort;
316
+ }
317
+ function isTrafficProxyRunning() {
318
+ return server !== null;
319
+ }
320
+ function getTrafficBuffer() {
321
+ return buffer;
322
+ }
323
+ function getServiceGraph() {
324
+ return graph;
325
+ }
326
+ function getTrafficProxyStats() {
327
+ return proxy?.stats ?? null;
328
+ }
329
+
330
+ // src/persistence/plugins/fsm-service.ts
331
+ var STARTING_GRACE_MS = 3e3;
332
+ var STOPPING_KILL_MS = 5e3;
333
+ var SERVICE_WATCHER_INTERVAL_MS = 5e3;
334
+ function pidPath(fifonyDir, id) {
335
+ return join(fifonyDir, `service-${id}.pid`);
336
+ }
337
+ function serviceLogPath(fifonyDir, id) {
338
+ return join(fifonyDir, `service-${id}.log`);
339
+ }
340
+ var ERROR_PATTERN = /\b(ERROR|Exception|FATAL|FAIL)\b/gi;
341
+ var errorCountCache = /* @__PURE__ */ new Map();
342
+ function countLogErrors(logFile) {
343
+ if (!existsSync(logFile)) return 0;
344
+ try {
345
+ const size = statSync(logFile).size;
346
+ if (size === 0) {
347
+ errorCountCache.delete(logFile);
348
+ return 0;
349
+ }
350
+ const cached = errorCountCache.get(logFile);
351
+ if (cached && size < cached.size) {
352
+ errorCountCache.delete(logFile);
353
+ return countLogErrors(logFile);
354
+ }
355
+ if (cached && size === cached.size) return cached.count;
356
+ const readFrom = cached ? cached.size : Math.max(0, size - 8192);
357
+ const readSize = size - readFrom;
358
+ if (readSize <= 0) return cached?.count ?? 0;
359
+ const fd = openSync(logFile, "r");
360
+ const buf = Buffer.alloc(readSize);
361
+ readSync(fd, buf, 0, readSize, readFrom);
362
+ closeSync(fd);
363
+ const matches = buf.toString("utf8").match(ERROR_PATTERN);
364
+ const delta = matches ? matches.length : 0;
365
+ const total = (cached?.count ?? 0) + delta;
366
+ errorCountCache.set(logFile, { size, count: total });
367
+ return total;
368
+ } catch {
369
+ return 0;
370
+ }
371
+ }
372
+ function readPidInfo(fifonyDir, id) {
373
+ const path = pidPath(fifonyDir, id);
374
+ if (!existsSync(path)) return null;
375
+ try {
376
+ const data = JSON.parse(readFileSync(path, "utf8"));
377
+ if (!data?.pid || typeof data.pid !== "number") return null;
378
+ if (!data.state) {
379
+ data.state = isProcessAlive(data.pid) ? "running" : "crashed";
380
+ data.crashCount ??= 0;
381
+ }
382
+ return data;
383
+ } catch {
384
+ return null;
385
+ }
386
+ }
387
+ function writePidInfo(fifonyDir, id, info) {
388
+ writeFileSync(pidPath(fifonyDir, id), JSON.stringify(info));
389
+ }
390
+ function removePidInfo(fifonyDir, id) {
391
+ try {
392
+ rmSync(pidPath(fifonyDir, id), { force: true });
393
+ } catch {
394
+ }
395
+ }
396
+ function spawnProcess(entry, targetRoot, fifonyDir, globalEnv) {
397
+ const cwd = entry.cwd ? resolve(targetRoot, entry.cwd) : targetRoot;
398
+ const log = serviceLogPath(fifonyDir, entry.id);
399
+ let mergedGlobalEnv = globalEnv;
400
+ const proxyPort = getTrafficProxyPort();
401
+ if (proxyPort) {
402
+ const dashPort = Number(process.env.FIFONY_PORT ?? 4e3);
403
+ mergedGlobalEnv = { ...globalEnv ?? {}, ...buildProxyEnvVars(proxyPort, entry.id, dashPort) };
404
+ }
405
+ const command = buildServiceCommand(entry.command, mergedGlobalEnv, entry.env);
406
+ try {
407
+ writeFileSync(log, "");
408
+ } catch {
409
+ }
410
+ const logFd = openSync(log, "a");
411
+ const child = spawn(command, [], {
412
+ shell: true,
413
+ cwd,
414
+ detached: true,
415
+ stdio: ["ignore", logFd, logFd]
416
+ });
417
+ try {
418
+ closeSync(logFd);
419
+ } catch {
420
+ }
421
+ child.unref();
422
+ if (child.pid === void 0) {
423
+ throw new Error(`Failed to spawn service process: ${command}`);
424
+ }
425
+ return { pid: child.pid, command };
426
+ }
427
+ function autoRestartBackoffMs(crashCount) {
428
+ return Math.min(Math.pow(2, crashCount) * 1e3, 6e4);
429
+ }
430
+ function getServiceStatus(entry, fifonyDir) {
431
+ const info = readPidInfo(fifonyDir, entry.id);
432
+ const alive = info !== null && isProcessAlive(info.pid);
433
+ let state;
434
+ if (!info) {
435
+ state = "stopped";
436
+ } else if (info.state === "stopping") {
437
+ state = alive ? "stopping" : "stopped";
438
+ } else if (info.state === "starting" || info.state === "running") {
439
+ state = alive ? info.state : "crashed";
440
+ } else {
441
+ state = info.state;
442
+ }
443
+ const logFile = serviceLogPath(fifonyDir, entry.id);
444
+ let logSize = 0;
445
+ if (existsSync(logFile)) {
446
+ try {
447
+ logSize = statSync(logFile).size;
448
+ } catch {
449
+ }
450
+ }
451
+ const startedAt = info?.startedAt ?? null;
452
+ const running = state === "starting" || state === "running";
453
+ const uptime = startedAt && running ? Date.now() - Date.parse(startedAt) : 0;
454
+ return {
455
+ id: entry.id,
456
+ name: entry.name,
457
+ command: entry.command,
458
+ cwd: entry.cwd,
459
+ env: entry.env,
460
+ autoStart: entry.autoStart,
461
+ autoRestart: entry.autoRestart,
462
+ maxCrashes: entry.maxCrashes,
463
+ port: entry.port,
464
+ state,
465
+ running,
466
+ pid: alive ? info?.pid ?? null : null,
467
+ startedAt,
468
+ uptime: Number.isFinite(uptime) ? uptime : 0,
469
+ logSize,
470
+ crashCount: info?.crashCount ?? 0,
471
+ errorCount: countLogErrors(logFile),
472
+ nextRetryAt: info?.nextRetryAt
473
+ };
474
+ }
475
+ function getAllServiceStatuses(entries, fifonyDir) {
476
+ return entries.map((e) => getServiceStatus(e, fifonyDir));
477
+ }
478
+ function readServiceLogTail(id, fifonyDir, bytes = 8192) {
479
+ const log = serviceLogPath(fifonyDir, id);
480
+ if (!existsSync(log)) return "";
481
+ try {
482
+ const size = statSync(log).size;
483
+ const readSize = Math.min(size, bytes);
484
+ const fd = openSync(log, "r");
485
+ const buf = Buffer.alloc(readSize);
486
+ readSync(fd, buf, 0, readSize, Math.max(0, size - readSize));
487
+ closeSync(fd);
488
+ return buf.toString("utf8");
489
+ } catch {
490
+ return "";
491
+ }
492
+ }
493
+ function reconcileServiceStates(entries, fifonyDir) {
494
+ for (const entry of entries) {
495
+ const info = readPidInfo(fifonyDir, entry.id);
496
+ if (!info) continue;
497
+ if (info.state === "stopped") continue;
498
+ if (!isProcessAlive(info.pid)) {
499
+ const crashCount = (info.crashCount ?? 0) + 1;
500
+ writePidInfo(fifonyDir, entry.id, {
501
+ ...info,
502
+ state: "crashed",
503
+ crashCount,
504
+ lastCrashAt: now()
505
+ });
506
+ logger.info({ id: entry.id, crashCount }, "[Service] Boot: process dead \u2192 crashed");
507
+ }
508
+ }
509
+ }
510
+ var SERVICE_STATE_MACHINE_ID = "service-lifecycle";
511
+ var TRIGGER_INTERVAL_MS = SERVICE_WATCHER_INTERVAL_MS;
512
+ var serviceRuntime = null;
513
+ function setServiceRuntime(ctx) {
514
+ serviceRuntime = ctx;
515
+ }
516
+ function getServiceRuntime() {
517
+ return serviceRuntime;
518
+ }
519
+ var serviceResourceStateApi = null;
520
+ function setServiceResourceStateApi(api) {
521
+ serviceResourceStateApi = api;
522
+ }
523
+ async function sendServiceEvent(entityId, event, context = {}) {
524
+ if (!serviceResourceStateApi) {
525
+ throw new Error("Service state machine not initialized");
526
+ }
527
+ try {
528
+ await serviceResourceStateApi.send(entityId, event, context);
529
+ } catch (err) {
530
+ if (String(err).includes("not found") || String(err).includes("not initialized")) {
531
+ await serviceResourceStateApi.initialize(entityId, { state: "stopped" });
532
+ await serviceResourceStateApi.send(entityId, event, context);
533
+ } else {
534
+ throw err;
535
+ }
536
+ }
537
+ }
538
+ function resolveServiceEntry(entityId) {
539
+ if (!serviceRuntime) return null;
540
+ return (serviceRuntime.getEntries() ?? []).find((e) => e.id === entityId) ?? null;
541
+ }
542
+ var serviceStateMachineConfig = {
543
+ persistTransitions: true,
544
+ workerId: `fifony-svc-${process.pid}`,
545
+ lockTimeout: 5e3,
546
+ lockTTL: 30,
547
+ enableFunctionTriggers: true,
548
+ triggerCheckInterval: TRIGGER_INTERVAL_MS,
549
+ stateMachines: {
550
+ [SERVICE_STATE_MACHINE_ID]: {
551
+ resource: S3DB_SERVICES_RESOURCE,
552
+ stateField: "state",
553
+ initialState: "stopped",
554
+ autoCleanup: false,
555
+ states: {
556
+ stopped: {
557
+ on: { START: "starting" },
558
+ entry: "onEnterStopped"
559
+ },
560
+ starting: {
561
+ on: {
562
+ GRACE_ELAPSED: "running",
563
+ PROCESS_DIED: "crashed",
564
+ STOP: "stopping"
565
+ },
566
+ entry: "spawnService",
567
+ triggers: [{
568
+ type: "function",
569
+ interval: TRIGGER_INTERVAL_MS,
570
+ condition: async (context, entityId) => {
571
+ if (!serviceRuntime) return false;
572
+ const info = readPidInfo(serviceRuntime.fifonyDir, entityId);
573
+ if (!info) return false;
574
+ const alive = isProcessAlive(info.pid);
575
+ if (!alive) return true;
576
+ const ageMs = Date.now() - Date.parse(info.startedAt);
577
+ return ageMs >= STARTING_GRACE_MS;
578
+ },
579
+ sendEvent: "GRACE_ELAPSED",
580
+ // default event; overridden by eventName resolver below
581
+ eventName: (context) => {
582
+ const entityId = context.entityId ?? context.id ?? "";
583
+ if (!serviceRuntime || !entityId) return "PROCESS_DIED";
584
+ const info = readPidInfo(serviceRuntime.fifonyDir, entityId);
585
+ if (!info || !isProcessAlive(info.pid)) return "PROCESS_DIED";
586
+ return "GRACE_ELAPSED";
587
+ }
588
+ }]
589
+ },
590
+ running: {
591
+ on: {
592
+ PROCESS_DIED: "crashed",
593
+ STOP: "stopping"
594
+ },
595
+ entry: "onEnterRunning",
596
+ triggers: [{
597
+ type: "function",
598
+ interval: TRIGGER_INTERVAL_MS,
599
+ condition: async (context, entityId) => {
600
+ if (!serviceRuntime) return false;
601
+ const info = readPidInfo(serviceRuntime.fifonyDir, entityId);
602
+ if (!info) return true;
603
+ return !isProcessAlive(info.pid);
604
+ },
605
+ sendEvent: "PROCESS_DIED"
606
+ }]
607
+ },
608
+ stopping: {
609
+ on: {
610
+ PROCESS_EXITED: "stopped",
611
+ KILL_TIMEOUT: "stopped"
612
+ },
613
+ entry: "sendSigterm",
614
+ triggers: [{
615
+ type: "function",
616
+ interval: TRIGGER_INTERVAL_MS,
617
+ condition: async (context, entityId) => {
618
+ if (!serviceRuntime) return false;
619
+ const info = readPidInfo(serviceRuntime.fifonyDir, entityId);
620
+ if (!info) return true;
621
+ const alive = isProcessAlive(info.pid);
622
+ if (!alive) return true;
623
+ const stoppingAgeMs = info.stoppingAt ? Date.now() - Date.parse(info.stoppingAt) : STOPPING_KILL_MS + 1;
624
+ return stoppingAgeMs >= STOPPING_KILL_MS;
625
+ },
626
+ sendEvent: "PROCESS_EXITED",
627
+ // default
628
+ eventName: (context) => {
629
+ const entityId = context.entityId ?? context.id ?? "";
630
+ if (!serviceRuntime || !entityId) return "PROCESS_EXITED";
631
+ const info = readPidInfo(serviceRuntime.fifonyDir, entityId);
632
+ if (!info || !isProcessAlive(info.pid)) return "PROCESS_EXITED";
633
+ return "KILL_TIMEOUT";
634
+ }
635
+ }]
636
+ },
637
+ crashed: {
638
+ on: {
639
+ AUTO_RESTART: "starting",
640
+ START: "starting",
641
+ STOP: "stopping"
642
+ },
643
+ entry: "recordCrash",
644
+ triggers: [{
645
+ type: "function",
646
+ interval: TRIGGER_INTERVAL_MS,
647
+ condition: async (context, entityId) => {
648
+ if (!serviceRuntime) return false;
649
+ const entry = resolveServiceEntry(entityId);
650
+ if (!entry) return false;
651
+ const info = readPidInfo(serviceRuntime.fifonyDir, entityId);
652
+ if (!info) return false;
653
+ const autoRestart = entry.autoRestart ?? false;
654
+ const maxCrashes = entry.maxCrashes ?? 5;
655
+ if (!autoRestart || (info.crashCount ?? 0) >= maxCrashes) return false;
656
+ const nextRetryMs = info.nextRetryAt ? Date.parse(info.nextRetryAt) : 0;
657
+ return Date.now() >= nextRetryMs;
658
+ },
659
+ sendEvent: "AUTO_RESTART"
660
+ }]
661
+ }
662
+ }
663
+ }
664
+ },
665
+ // ── Actions ────────────────────────────────────────────────────────────────
666
+ // (context, event, machine) — context is the payload from send()
667
+ actions: {
668
+ spawnService: async (context, _event, machine) => {
669
+ if (!serviceRuntime) {
670
+ logger.warn({ entityId: machine.entityId }, "[ServiceFSM] spawnService called but runtime not set");
671
+ return;
672
+ }
673
+ const entry = resolveServiceEntry(machine.entityId);
674
+ if (!entry) {
675
+ logger.warn({ entityId: machine.entityId }, "[ServiceFSM] spawnService \u2014 entry not found");
676
+ return;
677
+ }
678
+ const { fifonyDir, targetRoot } = serviceRuntime;
679
+ const globalEnv = serviceRuntime.getGlobalEnv();
680
+ const existing = readPidInfo(fifonyDir, entry.id);
681
+ if (existing && isProcessAlive(existing.pid)) {
682
+ try {
683
+ process.kill(-existing.pid, "SIGKILL");
684
+ } catch {
685
+ }
686
+ try {
687
+ process.kill(existing.pid, "SIGKILL");
688
+ } catch {
689
+ }
690
+ }
691
+ const spawned = spawnProcess(entry, targetRoot, fifonyDir, globalEnv);
692
+ const isAutoRestart = _event === "AUTO_RESTART";
693
+ const prevCrashCount = existing?.crashCount ?? 0;
694
+ writePidInfo(fifonyDir, entry.id, {
695
+ pid: spawned.pid,
696
+ command: spawned.command,
697
+ startedAt: now(),
698
+ state: "starting",
699
+ crashCount: isAutoRestart ? prevCrashCount : 0
700
+ });
701
+ logger.info(
702
+ { id: entry.id, pid: spawned.pid, event: _event },
703
+ `[ServiceFSM] spawnService \u2192 starting (${isAutoRestart ? "auto-restart" : "manual"})`
704
+ );
705
+ serviceRuntime.onTransition?.({
706
+ id: entry.id,
707
+ from: context.previousState ?? "none",
708
+ to: "starting",
709
+ pid: spawned.pid,
710
+ reason: isAutoRestart ? `auto-restart (crash #${prevCrashCount})` : "manual start"
711
+ });
712
+ },
713
+ sendSigterm: async (context, _event, machine) => {
714
+ if (!serviceRuntime) return;
715
+ const { fifonyDir } = serviceRuntime;
716
+ const info = readPidInfo(fifonyDir, machine.entityId);
717
+ if (!info) return;
718
+ if (isProcessAlive(info.pid)) {
719
+ try {
720
+ process.kill(-info.pid, "SIGTERM");
721
+ } catch {
722
+ }
723
+ }
724
+ writePidInfo(fifonyDir, machine.entityId, {
725
+ ...info,
726
+ state: "stopping",
727
+ stoppingAt: now()
728
+ });
729
+ logger.info({ id: machine.entityId, pid: info.pid }, "[ServiceFSM] sendSigterm \u2192 stopping");
730
+ serviceRuntime.onTransition?.({
731
+ id: machine.entityId,
732
+ from: context.previousState ?? "running",
733
+ to: "stopping",
734
+ pid: info.pid,
735
+ reason: "manual stop"
736
+ });
737
+ },
738
+ recordCrash: async (context, _event, machine) => {
739
+ if (!serviceRuntime) return;
740
+ const { fifonyDir } = serviceRuntime;
741
+ const entry = resolveServiceEntry(machine.entityId);
742
+ const info = readPidInfo(fifonyDir, machine.entityId);
743
+ if (!info) return;
744
+ const crashCount = (info.crashCount ?? 0) + 1;
745
+ const maxCrashes = entry?.maxCrashes ?? 5;
746
+ const autoRestart = entry?.autoRestart ?? false;
747
+ const nextRetryAt = autoRestart && crashCount < maxCrashes ? new Date(Date.now() + autoRestartBackoffMs(crashCount)).toISOString() : void 0;
748
+ writePidInfo(fifonyDir, machine.entityId, {
749
+ ...info,
750
+ state: "crashed",
751
+ crashCount,
752
+ lastCrashAt: now(),
753
+ nextRetryAt
754
+ });
755
+ logger.warn(
756
+ { id: machine.entityId, crashCount, nextRetryAt },
757
+ "[ServiceFSM] recordCrash \u2192 crashed"
758
+ );
759
+ serviceRuntime.onTransition?.({
760
+ id: machine.entityId,
761
+ from: context.previousState ?? "running",
762
+ to: "crashed",
763
+ pid: null,
764
+ reason: `process died (crash #${crashCount})`
765
+ });
766
+ },
767
+ onEnterStopped: async (context, _event, machine) => {
768
+ if (!serviceRuntime) return;
769
+ const { fifonyDir } = serviceRuntime;
770
+ if (_event === "KILL_TIMEOUT") {
771
+ const info = readPidInfo(fifonyDir, machine.entityId);
772
+ if (info && isProcessAlive(info.pid)) {
773
+ try {
774
+ process.kill(-info.pid, "SIGKILL");
775
+ } catch {
776
+ }
777
+ try {
778
+ process.kill(info.pid, "SIGKILL");
779
+ } catch {
780
+ }
781
+ }
782
+ logger.info({ id: machine.entityId }, "[ServiceFSM] onEnterStopped \u2014 SIGKILL after stop timeout");
783
+ }
784
+ removePidInfo(fifonyDir, machine.entityId);
785
+ logger.info({ id: machine.entityId, event: _event }, "[ServiceFSM] onEnterStopped \u2014 pid file removed");
786
+ serviceRuntime.onTransition?.({
787
+ id: machine.entityId,
788
+ from: context.previousState ?? "stopping",
789
+ to: "stopped",
790
+ pid: null,
791
+ reason: _event === "KILL_TIMEOUT" ? "SIGKILL after stop timeout" : "process exited"
792
+ });
793
+ },
794
+ onEnterRunning: async (context, _event, machine) => {
795
+ if (!serviceRuntime) return;
796
+ const { fifonyDir } = serviceRuntime;
797
+ const info = readPidInfo(fifonyDir, machine.entityId);
798
+ if (info) {
799
+ writePidInfo(fifonyDir, machine.entityId, { ...info, state: "running" });
800
+ }
801
+ logger.info({ id: machine.entityId, pid: info?.pid }, "[ServiceFSM] onEnterRunning \u2014 grace period elapsed");
802
+ serviceRuntime.onTransition?.({
803
+ id: machine.entityId,
804
+ from: "starting",
805
+ to: "running",
806
+ pid: info?.pid ?? null,
807
+ reason: "startup grace period elapsed"
808
+ });
809
+ }
810
+ },
811
+ // ── Guards ─────────────────────────────────────────────────────────────────
812
+ guards: {
813
+ graceElapsed: async (context, _event, machine) => {
814
+ if (!serviceRuntime) return false;
815
+ const info = readPidInfo(serviceRuntime.fifonyDir, machine.entityId);
816
+ if (!info) return false;
817
+ if (!isProcessAlive(info.pid)) return false;
818
+ const ageMs = Date.now() - Date.parse(info.startedAt);
819
+ return ageMs >= STARTING_GRACE_MS;
820
+ },
821
+ canAutoRestart: async (context, _event, machine) => {
822
+ if (!serviceRuntime) return false;
823
+ const entry = resolveServiceEntry(machine.entityId);
824
+ if (!entry) return false;
825
+ const info = readPidInfo(serviceRuntime.fifonyDir, machine.entityId);
826
+ if (!info) return false;
827
+ const autoRestart = entry.autoRestart ?? false;
828
+ const maxCrashes = entry.maxCrashes ?? 5;
829
+ if (!autoRestart || (info.crashCount ?? 0) >= maxCrashes) return false;
830
+ const nextRetryMs = info.nextRetryAt ? Date.parse(info.nextRetryAt) : 0;
831
+ return Date.now() >= nextRetryMs;
832
+ }
833
+ }
834
+ };
835
+
836
+ export {
837
+ normalizeServiceEnvironment,
838
+ setServicesAccessor,
839
+ startTrafficProxy,
840
+ stopTrafficProxy,
841
+ getTrafficProxyPort,
842
+ isTrafficProxyRunning,
843
+ getTrafficBuffer,
844
+ getServiceGraph,
845
+ getTrafficProxyStats,
846
+ SERVICE_WATCHER_INTERVAL_MS,
847
+ serviceLogPath,
848
+ getServiceStatus,
849
+ getAllServiceStatuses,
850
+ readServiceLogTail,
851
+ reconcileServiceStates,
852
+ SERVICE_STATE_MACHINE_ID,
853
+ setServiceRuntime,
854
+ getServiceRuntime,
855
+ setServiceResourceStateApi,
856
+ sendServiceEvent,
857
+ serviceStateMachineConfig
858
+ };
859
+ //# sourceMappingURL=chunk-AAVROEQC.js.map