@passioncode-ai/fabric-agent-adapter 0.39.1 → 0.41.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/CHANGELOG.md +203 -0
- package/README.md +18 -6
- package/SECURITY.md +27 -6
- package/bin/fabric-agent-adapter.js +71 -6
- package/package.json +2 -2
- package/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +42 -66
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/dashboard-links.md +4 -2
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/operator-channel-and-repository.md +55 -0
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/provider-entry.md +9 -3
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/verification.md +15 -0
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/adapt_project.py +181 -26
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/check_dashboard_link.py +66 -5
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_provider.py +81 -23
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_secrets.py +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +42 -33
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-button.js +21 -9
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-dashboard.css +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/fabric-dashboard.js +2 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/assets/dashboard-kit/kit.json +9 -4
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/README.md +104 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/config.example.json +15 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/knowledge.example.json +13 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/support_agent.py +465 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/board.md +21 -5
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/connectors.md +93 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-kit.md +2 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-links.md +4 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/delegation.md +3 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/estate-record.md +5 -4
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/interop.md +5 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/kit-modules.md +129 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/lifecycle.md +3 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/package.md +5 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/usage.md +2 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_dashboard_layout.mjs +48 -19
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_dashboard_link.py +66 -5
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +11 -184
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_rules.py +204 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/dashboard_kit.py +53 -19
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-auth.mjs +288 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-backups.mjs +327 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-base.mjs +47 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-fixture.mjs +79 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-link.mjs +145 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-session.mjs +848 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-shapes.mjs +340 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board.mjs +42 -1237
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-ceremonies.mjs +7 -18
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-crypto.mjs +41 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-delegation.mjs +15 -46
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-descriptor.mjs +387 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-estate.mjs +11 -68
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-events.mjs +52 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-http.mjs +231 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop.mjs +50 -16
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-jsvalue.mjs +67 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-lock.mjs +235 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-logs.mjs +136 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-openrouter.mjs +335 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-package.mjs +146 -32
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-paths.mjs +69 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-platform.mjs +164 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-profiles.mjs +1 -32
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-protocols.mjs +122 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +177 -2541
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-share.mjs +1 -8
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-supervisors.mjs +404 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-time.mjs +46 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-usage.mjs +458 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_auth.py +408 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_backups.py +399 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_base.py +91 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board.py +64 -1398
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_fixture.py +116 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_link.py +162 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_session.py +882 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_shapes.py +457 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_ceremonies.py +36 -39
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_crypto.py +336 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_delegation.py +113 -222
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_descriptor.py +586 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_estate.py +44 -405
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_events.py +140 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_http.py +402 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_hub.py +6 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop.py +119 -77
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_jsvalue.py +310 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_kit.py +243 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_lock.py +308 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_logs.py +202 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_openrouter.py +431 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_package.py +285 -109
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_paths.py +102 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_platform.py +338 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_profiles.py +32 -39
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_protocols.py +173 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_secrets.py +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +298 -3450
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_share.py +8 -8
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_supervisors.py +437 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_time.py +120 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_usage.py +697 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/kit.json +1621 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/sample_service.py +25 -7
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +29 -60
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/dashboard-links.md +4 -2
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/models-and-providers.md +3 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/operator-channel-and-repository.md +55 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/scripts/check_dashboard_link.py +66 -5
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +3 -3
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/native-host-hooks.md +2 -1
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/session-hooks.md +26 -13
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session.py +122 -1392
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_cli.py +278 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_env.py +539 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_hosts.py +324 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_mail.py +173 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_mcp.py +491 -0
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/host_hooks.py +11 -1
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/hosts/context.mjs +25 -2
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/hosts/openclaw/index.mjs +4 -2
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/hosts/opencode/fabric.mjs +2 -1
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
// One copy of a service, locked before any side effect (LC-01, DEC-0057): the instance lock and the supervisor marker.
|
|
2
|
+
// Part of the Node service kit (FAA-65, layer L3); the twin of fabric_lock.py. Node 20+ (22+ for the Linux lock), no dependency.
|
|
3
|
+
import crypto from 'node:crypto';
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import net from 'node:net';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import { SUPERVISOR_ENV, ServiceError } from './fabric-base.mjs';
|
|
8
|
+
import { atomicWrite, ensurePrivateDir } from './fabric-paths.mjs';
|
|
9
|
+
|
|
10
|
+
export const EXIT_ALREADY_RUNNING = 75;
|
|
11
|
+
|
|
12
|
+
export const LOCK_WAIT_SUPERVISED_SECONDS = 300;
|
|
13
|
+
|
|
14
|
+
export const LOCK_BACKOFF_FIRST_SECONDS = 0.5;
|
|
15
|
+
|
|
16
|
+
export const LOCK_BACKOFF_MAX_SECONDS = 30;
|
|
17
|
+
|
|
18
|
+
const O_EXLOCK = 0x20; // BSD/macOS: open(2) takes flock(LOCK_EX); absent from fs.constants
|
|
19
|
+
|
|
20
|
+
export class AlreadyRunning extends ServiceError {
|
|
21
|
+
constructor(holderPid, lockPath) {
|
|
22
|
+
// FAC-SEM-078: a copy that cannot learn the holder's pid says the holder is unknown.
|
|
23
|
+
super(`Another copy is already running (${holderPid ? `process ${holderPid}` : 'an unknown process'} holds ${lockPath}).`);
|
|
24
|
+
this.holderPid = holderPid;
|
|
25
|
+
this.lockPath = lockPath;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export class InstanceLock {
|
|
30
|
+
constructor(dataDir) {
|
|
31
|
+
this.path = path.join(dataDir, 'service.lock');
|
|
32
|
+
this.fd = null;
|
|
33
|
+
this.server = null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async acquire() {
|
|
37
|
+
ensurePrivateDir(path.dirname(this.path));
|
|
38
|
+
if (process.platform === 'darwin') {
|
|
39
|
+
const flags = fs.constants.O_RDWR | fs.constants.O_CREAT | O_EXLOCK | fs.constants.O_NONBLOCK;
|
|
40
|
+
try {
|
|
41
|
+
this.fd = fs.openSync(this.path, flags, 0o600);
|
|
42
|
+
} catch (error) {
|
|
43
|
+
if (error.code === 'EAGAIN' || error.code === 'EWOULDBLOCK') throw new AlreadyRunning(readPid(this.path), this.path);
|
|
44
|
+
throw error;
|
|
45
|
+
}
|
|
46
|
+
fs.ftruncateSync(this.fd, 0);
|
|
47
|
+
fs.writeSync(this.fd, String(process.pid));
|
|
48
|
+
fs.fsyncSync(this.fd);
|
|
49
|
+
return this;
|
|
50
|
+
}
|
|
51
|
+
// Linux: an abstract unix socket; Windows: a named pipe. Each is exclusive, has no file, and dies
|
|
52
|
+
// with the process. The Python kit holds the same name beside its file lock (fabric_service.lock_name).
|
|
53
|
+
const name = lockName(this.path);
|
|
54
|
+
if (!name) throw new ServiceError(`No instance lock on ${process.platform}: the kit supports macOS, Linux and Windows.`);
|
|
55
|
+
const names = process.platform === 'linux' ? await abstractNames() : 'exact';
|
|
56
|
+
if (names === 'none' || names === 'padded') {
|
|
57
|
+
// Node 18 and 20 cannot bind the name the Python kit binds: 18 and early 20 make every name 108 NUL bytes (one lock
|
|
58
|
+
// for every service on the machine), later 20 pads it with NUL bytes (a name no Python copy or probe sees).
|
|
59
|
+
throw new ServiceError(`Node ${process.version} cannot hold the instance lock's abstract socket name on Linux; run the service on Node 22 or later.`);
|
|
60
|
+
}
|
|
61
|
+
this.server = net.createServer();
|
|
62
|
+
await new Promise((resolve, reject) => {
|
|
63
|
+
// Windows reports a held pipe as EADDRINUSE (access denied) or, when its holder capped the instances, EBUSY.
|
|
64
|
+
const held = (code) => code === 'EADDRINUSE' || (process.platform === 'win32' && code === 'EBUSY');
|
|
65
|
+
this.server.once('error', (error) => reject(held(error.code) ? new AlreadyRunning(readPid(this.path), this.path) : error));
|
|
66
|
+
this.server.listen(name, resolve);
|
|
67
|
+
});
|
|
68
|
+
this.server.unref();
|
|
69
|
+
atomicWrite(this.path, String(process.pid));
|
|
70
|
+
return this;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
release() {
|
|
74
|
+
if (this.fd !== null) fs.closeSync(this.fd);
|
|
75
|
+
if (this.server) this.server.close();
|
|
76
|
+
this.fd = null;
|
|
77
|
+
this.server = null;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
let abstractNamesSeen = null;
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* How this Node binds an abstract unix socket name on Linux, learned once by binding a random one and reading
|
|
85
|
+
* /proc/net/unix: 'exact' (Node 21 and later: the name the Python kit binds and fabric_lock.lock_name_held reads),
|
|
86
|
+
* 'padded' (Node 20.x: the name followed by NUL bytes to the whole sun_path) or 'none' (Node 18 and early 20: every
|
|
87
|
+
* name becomes 108 NUL bytes). 'unknown' when the table cannot be read.
|
|
88
|
+
*/
|
|
89
|
+
export async function abstractNames() {
|
|
90
|
+
if (abstractNamesSeen !== null) return abstractNamesSeen;
|
|
91
|
+
const probe = `fabric-probe-${crypto.randomBytes(8).toString('hex')}`;
|
|
92
|
+
const server = net.createServer();
|
|
93
|
+
try {
|
|
94
|
+
await new Promise((resolve, reject) => { server.once('error', reject); server.listen(`\0${probe}`, resolve); });
|
|
95
|
+
const rows = fs.readFileSync('/proc/net/unix', 'utf8').split('\n').map((row) => row.trimEnd().split(' ').pop());
|
|
96
|
+
if (rows.includes(`@${probe}`)) abstractNamesSeen = 'exact';
|
|
97
|
+
else if (rows.includes(`@${probe}`.padEnd(108, '@'))) abstractNamesSeen = 'padded';
|
|
98
|
+
else abstractNamesSeen = 'none';
|
|
99
|
+
} catch (error) {
|
|
100
|
+
// A random name already in use means every name collapses to one: another such Node holds it.
|
|
101
|
+
abstractNamesSeen = error?.code === 'EADDRINUSE' ? 'none' : 'unknown';
|
|
102
|
+
} finally {
|
|
103
|
+
server.close();
|
|
104
|
+
}
|
|
105
|
+
return abstractNamesSeen;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** The name the lock is held under on Linux (abstract socket) and Windows (named pipe); null on macOS. */
|
|
109
|
+
export function lockName(lockPath, platform = process.platform) {
|
|
110
|
+
const digest = (text) => crypto.createHash('sha256').update(text).digest('hex').slice(0, 32);
|
|
111
|
+
if (platform === 'linux') return `\0fabric-service-${digest(path.posix.resolve(lockPath))}`;
|
|
112
|
+
// Windows paths compare without case: C:\Users and c:\users are one lock.
|
|
113
|
+
if (platform === 'win32') return `\\\\.\\pipe\\fabric-service-${digest(path.win32.resolve(lockPath).toLowerCase())}`;
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function readPid(file) {
|
|
118
|
+
try {
|
|
119
|
+
const text = fs.readFileSync(file, 'utf8').trim();
|
|
120
|
+
return /^\d+$/.test(text) ? Number(text) : null;
|
|
121
|
+
} catch {
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** The managers a supervisor marker may name (DEC-0057): the three systems' supervisors and Project Observatory's node. */
|
|
127
|
+
export const SUPERVISORS = Object.freeze(['launchd', 'systemd', 'task-scheduler', 'node']);
|
|
128
|
+
|
|
129
|
+
/** The argument form of the marker, for a supervisor whose start carries no environment (a Scheduled Task's action). */
|
|
130
|
+
export const SUPERVISOR_ARG = '--fabric-supervisor';
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* The marker values an argument vector carries after the executable and before `--`. A bare `--fabric-supervisor`
|
|
134
|
+
* followed by nothing, by `--` or by another `--…` flag is a marker with an empty value and consumes nothing.
|
|
135
|
+
*/
|
|
136
|
+
export function markerArguments(argv) {
|
|
137
|
+
const values = [];
|
|
138
|
+
if (!Array.isArray(argv)) return values;
|
|
139
|
+
for (let index = 1; index < argv.length; index += 1) {
|
|
140
|
+
const item = argv[index];
|
|
141
|
+
if (item === '--') break;
|
|
142
|
+
if (item === SUPERVISOR_ARG) {
|
|
143
|
+
const next = argv[index + 1];
|
|
144
|
+
if (typeof next === 'string' && !next.startsWith('--')) {
|
|
145
|
+
values.push(next);
|
|
146
|
+
index += 1;
|
|
147
|
+
} else {
|
|
148
|
+
values.push('');
|
|
149
|
+
}
|
|
150
|
+
} else if (typeof item === 'string' && item.startsWith(`${SUPERVISOR_ARG}=`)) {
|
|
151
|
+
values.push(item.slice(SUPERVISOR_ARG.length + 1));
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
return values;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* How this copy was started (DEC-0057), from `FABRIC_SERVICE_SUPERVISOR=<manager>` and `--fabric-supervisor <manager>` /
|
|
159
|
+
* `--fabric-supervisor=<manager>` (`env` and `argv` default to this process's; `argv[0]` is the executable or script):
|
|
160
|
+
* `{kind: 'supervised', manager, forms}` when every marker present names the same manager of SUPERVISORS;
|
|
161
|
+
* `{kind: 'conflict', values, forms}` when they disagree (an empty or bare argument beside a variable disagrees with it):
|
|
162
|
+
* the copy exits 75 at once, before it takes the lock; `{kind: 'manual', forms, unreadable?}` with no marker (only an
|
|
163
|
+
* empty variable counts as none) or one that names no manager. Never inferred from the parent process. The contract's
|
|
164
|
+
* `supervisorMarker`.
|
|
165
|
+
*/
|
|
166
|
+
export function supervisorMarker({ env = process.env, argv = process.argv.slice(1) } = {}) {
|
|
167
|
+
const variable = env?.[SUPERVISOR_ENV];
|
|
168
|
+
const fromEnv = typeof variable === 'string' && variable !== '' ? [variable] : [];
|
|
169
|
+
const fromArgs = markerArguments(argv);
|
|
170
|
+
const forms = [...(fromEnv.length ? ['environment'] : []), ...(fromArgs.length ? ['argument'] : [])];
|
|
171
|
+
const values = [...new Set([...fromEnv, ...fromArgs])];
|
|
172
|
+
if (values.length > 1) return { kind: 'conflict', values, forms };
|
|
173
|
+
if (values.length === 0) return { kind: 'manual', forms };
|
|
174
|
+
return SUPERVISORS.includes(values[0]) ? { kind: 'supervised', manager: values[0], forms } : { kind: 'manual', unreadable: values[0], forms };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** How long a copy started so may wait for the lock, in seconds: 300 when supervised, else not at all. */
|
|
178
|
+
export const lockWaitBudgetSeconds = (marker) => (marker?.kind === 'supervised' ? LOCK_WAIT_SUPERVISED_SECONDS : 0);
|
|
179
|
+
|
|
180
|
+
/** The one sentence a copy whose markers disagree exits with, naming the markers present. */
|
|
181
|
+
export function markerConflictMessage(marker) {
|
|
182
|
+
const shown = marker.values.map((v) => JSON.stringify(v)).join(', ');
|
|
183
|
+
return marker.forms.length === 2
|
|
184
|
+
? `${SUPERVISOR_ENV} and ${SUPERVISOR_ARG} disagree (${shown}); not starting unsupervised.`
|
|
185
|
+
: `${SUPERVISOR_ARG} is given with different values (${shown}); not starting unsupervised.`;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** True when a supervisor that restarts on exit started this process and said so with the marker. */
|
|
189
|
+
export const supervised = (start) => supervisorMarker(start).kind === 'supervised';
|
|
190
|
+
|
|
191
|
+
/** True when launchd started this process from a plist that says so (FABRIC_SERVICE_SUPERVISOR=launchd). */
|
|
192
|
+
export const supervisedByLaunchd = (start) => supervisorMarker(start).manager === 'launchd';
|
|
193
|
+
|
|
194
|
+
const sleepSeconds = (s) => new Promise((resolve) => setTimeout(resolve, s * 1000));
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Take the lock or exit 75 with one sentence naming the holder (DEC-0057, FAC-SEM-078). Markers that
|
|
198
|
+
* disagree exit 75 before the lock. Started by hand, a held lock exits 75 at once. Under a
|
|
199
|
+
* supervisor that restarts on exit (launchd's KeepAlive) an immediate exit is a respawn every ThrottleInterval,
|
|
200
|
+
* forever; so a supervised copy first backs off in-process (0.5 s doubling to 30 s,
|
|
201
|
+
* LOCK_WAIT_SUPERVISED_SECONDS in all), takes over if the holder leaves, and exits 75 only when
|
|
202
|
+
* the wait runs out. `waitSeconds` overrides the choice (0 = never wait).
|
|
203
|
+
*/
|
|
204
|
+
export async function holdSingleInstance(dataDir, {
|
|
205
|
+
waitSeconds, sleep = sleepSeconds, clock = () => performance.now() / 1000, exit = (code) => process.exit(code), env, argv,
|
|
206
|
+
} = {}) {
|
|
207
|
+
// DEC-0057: markers that disagree exit 75 at once, before the lock, naming them.
|
|
208
|
+
const marker = supervisorMarker({ ...(env === undefined ? {} : { env }), ...(argv === undefined ? {} : { argv }) });
|
|
209
|
+
if (marker.kind === 'conflict') {
|
|
210
|
+
process.stderr.write(`${markerConflictMessage(marker)}\n`);
|
|
211
|
+
return exit(EXIT_ALREADY_RUNNING);
|
|
212
|
+
}
|
|
213
|
+
const wait = waitSeconds ?? lockWaitBudgetSeconds(marker);
|
|
214
|
+
const deadline = clock() + Math.max(0, wait);
|
|
215
|
+
let delay = LOCK_BACKOFF_FIRST_SECONDS;
|
|
216
|
+
let announced = false;
|
|
217
|
+
for (;;) {
|
|
218
|
+
try {
|
|
219
|
+
return await new InstanceLock(dataDir).acquire();
|
|
220
|
+
} catch (error) {
|
|
221
|
+
if (!(error instanceof AlreadyRunning)) throw error;
|
|
222
|
+
const remaining = deadline - clock();
|
|
223
|
+
if (remaining <= 0) {
|
|
224
|
+
process.stderr.write(`${error.message}\n`);
|
|
225
|
+
return exit(EXIT_ALREADY_RUNNING);
|
|
226
|
+
}
|
|
227
|
+
if (!announced) {
|
|
228
|
+
process.stderr.write(`${error.message} Waiting up to ${Math.round(remaining)} s for it to exit.\n`);
|
|
229
|
+
announced = true;
|
|
230
|
+
}
|
|
231
|
+
await sleep(Math.min(delay, remaining));
|
|
232
|
+
delay = Math.min(delay * 2, LOCK_BACKOFF_MAX_SECONDS);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
// Logs and stopping (LC-01, LC-12): a rotating log, a capped stdout log, the stop signal and the drain.
|
|
2
|
+
// Part of the Node service kit (FAA-65, layer L3); the twin of fabric_logs.py. Node 20+, no dependency.
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { DRAIN_SECONDS, ServiceError, nowIso } from './fabric-base.mjs';
|
|
6
|
+
import { ensurePrivateDir } from './fabric-paths.mjs';
|
|
7
|
+
|
|
8
|
+
export const DRAIN_GRACE_SECONDS = 2;
|
|
9
|
+
|
|
10
|
+
export const EXIT_HARD_STOP = 70;
|
|
11
|
+
|
|
12
|
+
// LC-12: logs rotate by size, 5 x 5 MB by default.
|
|
13
|
+
export const LOG_MAX_BYTES = 5 * 1024 * 1024;
|
|
14
|
+
|
|
15
|
+
export const LOG_BACKUPS = 5;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* One structured log: JSON lines rotated by size (5 x 5 MB by default), files 0600 in a 0700
|
|
19
|
+
* directory. `service.jsonl` -> `.1` ... `.<backups>`; the oldest falls off. Never pass a token,
|
|
20
|
+
* a cookie, a login code or a request body that may carry one.
|
|
21
|
+
*/
|
|
22
|
+
export class RotatingLog {
|
|
23
|
+
constructor(file, { maxBytes = LOG_MAX_BYTES, backups = LOG_BACKUPS } = {}) {
|
|
24
|
+
if (maxBytes < 256 || backups < 1) throw new ServiceError('A rotating log needs maxBytes >= 256 and at least one backup.');
|
|
25
|
+
this.file = file;
|
|
26
|
+
this.maxBytes = maxBytes;
|
|
27
|
+
this.backups = backups;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
line(record) {
|
|
31
|
+
let data = Buffer.from(`${JSON.stringify(record)}\n`);
|
|
32
|
+
if (data.length <= this.maxBytes) return data;
|
|
33
|
+
const cut = { ...record, truncated: true };
|
|
34
|
+
let message = String(record.message ?? '');
|
|
35
|
+
while (message) {
|
|
36
|
+
message = message.slice(0, Math.floor(message.length / 2));
|
|
37
|
+
cut.message = message;
|
|
38
|
+
data = Buffer.from(`${JSON.stringify(cut)}\n`);
|
|
39
|
+
if (data.length <= this.maxBytes) return data;
|
|
40
|
+
}
|
|
41
|
+
return Buffer.from(`${JSON.stringify({ at: record.at, level: record.level, message: '', truncated: true })}\n`);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
write(level, message, fields = {}) {
|
|
45
|
+
const data = this.line({ at: nowIso(), level: String(level), message: String(message), ...fields });
|
|
46
|
+
ensurePrivateDir(path.dirname(this.file));
|
|
47
|
+
let size = 0;
|
|
48
|
+
try { size = fs.statSync(this.file).size; } catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
49
|
+
if (size && size + data.length > this.maxBytes) this.rotate();
|
|
50
|
+
const fd = fs.openSync(this.file, 'a', 0o600);
|
|
51
|
+
try { fs.writeSync(fd, data); } finally { fs.closeSync(fd); }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
rotate() {
|
|
55
|
+
for (let i = this.backups - 1; i > 0; i -= 1) {
|
|
56
|
+
if (fs.existsSync(`${this.file}.${i}`)) fs.renameSync(`${this.file}.${i}`, `${this.file}.${i + 1}`);
|
|
57
|
+
}
|
|
58
|
+
fs.renameSync(this.file, `${this.file}.1`);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Call once at start: a launchd stdout file over `maxBytes` is copied to `<file>.1` and truncated
|
|
64
|
+
* in place (launchd holds it open for append, so a rename would leave it writing to the old name).
|
|
65
|
+
*/
|
|
66
|
+
export function capStdoutLog(file, maxBytes = LOG_MAX_BYTES) {
|
|
67
|
+
let size;
|
|
68
|
+
try { size = fs.statSync(file).size; } catch (error) { if (error.code === 'ENOENT') return false; throw error; }
|
|
69
|
+
if (size <= maxBytes) return false;
|
|
70
|
+
fs.copyFileSync(file, `${file}.1`);
|
|
71
|
+
fs.chmodSync(`${file}.1`, 0o600);
|
|
72
|
+
fs.truncateSync(file, 0);
|
|
73
|
+
return true;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export class Stopping extends ServiceError {}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* SIGTERM/SIGINT: stop taking new work, drain in-flight work until `deadline` seconds, then hand
|
|
80
|
+
* over with `onStop(drained)`. A hand-over that never settles is cut by a hard exit
|
|
81
|
+
* (EXIT_HARD_STOP) `grace` seconds later.
|
|
82
|
+
*
|
|
83
|
+
* const drain = new Drain().install((drained) => server.close(() => process.exit(0)),
|
|
84
|
+
* { onStopping: () => log.append('service.stopping', 'info', 'Stopping.') });
|
|
85
|
+
* await drain.work(async () => { ... }); // rejects with Stopping once a stop has begun
|
|
86
|
+
*/
|
|
87
|
+
export class Drain {
|
|
88
|
+
constructor({ deadline = DRAIN_SECONDS, grace = DRAIN_GRACE_SECONDS } = {}) {
|
|
89
|
+
this.deadline = deadline;
|
|
90
|
+
this.grace = grace;
|
|
91
|
+
this.inflight = 0;
|
|
92
|
+
this.stopping = false;
|
|
93
|
+
this.waiters = new Set();
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async work(fn) {
|
|
97
|
+
if (this.stopping) throw new Stopping('The service is stopping and takes no new work.');
|
|
98
|
+
this.inflight += 1;
|
|
99
|
+
try {
|
|
100
|
+
return await fn();
|
|
101
|
+
} finally {
|
|
102
|
+
this.inflight -= 1;
|
|
103
|
+
if (this.inflight === 0) for (const wake of this.waiters) wake();
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Refuse new work from now on; false when a stop had already begun. */
|
|
108
|
+
requestStop() {
|
|
109
|
+
if (this.stopping) return false;
|
|
110
|
+
this.stopping = true;
|
|
111
|
+
return true;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Resolves true when in-flight work finished inside `timeoutSeconds`, false otherwise. */
|
|
115
|
+
wait(timeoutSeconds) {
|
|
116
|
+
if (this.inflight === 0) return Promise.resolve(true);
|
|
117
|
+
return new Promise((resolve) => {
|
|
118
|
+
const wake = () => { clearTimeout(timer); this.waiters.delete(wake); resolve(true); };
|
|
119
|
+
const timer = setTimeout(() => { this.waiters.delete(wake); resolve(false); }, timeoutSeconds * 1000);
|
|
120
|
+
this.waiters.add(wake);
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
install(onStop, { onStopping, signals = ['SIGTERM', 'SIGINT'] } = {}) {
|
|
125
|
+
const handler = () => {
|
|
126
|
+
if (!this.requestStop()) return; // a second signal changes nothing: the hard exit holds the deadline
|
|
127
|
+
setTimeout(() => process.exit(EXIT_HARD_STOP), (this.deadline + this.grace) * 1000).unref();
|
|
128
|
+
(async () => {
|
|
129
|
+
try { await onStopping?.(); } catch (error) { process.stderr.write(`${error?.stack ?? error}\n`); }
|
|
130
|
+
await onStop(await this.wait(this.deadline));
|
|
131
|
+
})();
|
|
132
|
+
};
|
|
133
|
+
for (const signal of signals) process.on(signal, handler);
|
|
134
|
+
return this;
|
|
135
|
+
}
|
|
136
|
+
}
|