@passioncode-ai/fabric-agent-adapter 0.80.2 → 0.81.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 +79 -0
- package/README.md +13 -2
- package/SECURITY.md +23 -8
- package/bin/fabric-agent-adapter.js +24 -1
- package/package.json +1 -1
- package/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +6 -4
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/dashboard-links.md +8 -3
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/source-and-instance.md +44 -7
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/adapt_project.py +107 -2
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/check_dashboard_link.py +14 -2
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_secrets.py +18 -3
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_source_check.py +55 -75
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_source_restart.py +56 -0
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_source_tree.py +912 -0
- package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/new_agent.py +308 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +5 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/README.md +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-links.md +8 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/delegation.md +5 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/interop.md +28 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/kit-modules.md +3 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/lifecycle.md +25 -9
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/migrating-a-service.md +1 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/package.md +10 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +22 -5
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_dashboard_layout.mjs +21 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_dashboard_link.py +14 -2
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +17 -28
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_channel_agent.py +2 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_rules.py +135 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-descriptor.mjs +78 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-holds.mjs +266 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-http.mjs +64 -5
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-package.mjs +98 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-peer-owner.mjs +3 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-protocols.mjs +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-secrets.mjs +10 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +5 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-supervisors.mjs +76 -11
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_descriptor.py +85 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_holds.py +316 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_http.py +31 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_package.py +103 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_peer_owner.py +13 -8
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_protocols.py +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_secrets.py +18 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +3 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_supervisors.py +64 -3
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/kit.json +141 -17
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +16 -6
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/agent-templates.md +44 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/communication-agent-template.md +19 -328
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/dashboard-links.md +8 -3
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/models-and-providers.md +1 -1
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/new-agent.md +42 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/source-and-instance.md +44 -7
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/schemas/template/access.schema.json +50 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/schemas/template/connections.schema.json +23 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/schemas/template/data-rooms.schema.json +74 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/schemas/template/manifest.schema.json +170 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/schemas/template/template-header.schema.json +25 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/scripts/check_dashboard_link.py +14 -2
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/scripts/template_check.py +491 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/templates/communication-agent/CHANGELOG.md +7 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/templates/communication-agent/TEMPLATE.md +347 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/templates/communication-agent/access.json +24 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/templates/communication-agent/assembly.md +15 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/templates/communication-agent/checks/acceptance.md +17 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/templates/communication-agent/connections.json +29 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/templates/communication-agent/data-rooms.json +9 -0
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/templates/communication-agent/manifest.json +32 -0
- package/plugins/fabric-agent-adapter/skills/observing-products/SKILL.md +1 -1
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +1 -1
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/project-board.md +1 -1
- package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_env.py +1 -1
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-supervisors.mjs
CHANGED
|
@@ -343,6 +343,8 @@ function guiDomain(platform, uid) {
|
|
|
343
343
|
/** Where a LaunchDaemon's plist lives: a job in launchd's system domain, started at boot with no GUI login (FAA-278). */
|
|
344
344
|
export const SYSTEM_DAEMONS = '/Library/LaunchDaemons';
|
|
345
345
|
const USER_NAME = /^[a-z_][a-z0-9_.-]{0,31}$/;
|
|
346
|
+
/** The short-name rule in words, printed alike by both twins (FAA-286 item 13). */
|
|
347
|
+
export const USER_NAME_RULE = 'a lower-case letter or _, then up to 31 lower-case letters, digits, _, . or -';
|
|
346
348
|
|
|
347
349
|
/**
|
|
348
350
|
* The launchd domain a job lives in: `system` for a LaunchDaemon (`domain: 'system'`, or a plist in
|
|
@@ -351,6 +353,9 @@ const USER_NAME = /^[a-z_][a-z0-9_.-]{0,31}$/;
|
|
|
351
353
|
*/
|
|
352
354
|
export function launchdDomain(plistPath = null, { domain = null, platform = process.platform, uid } = {}) {
|
|
353
355
|
if (![null, undefined, 'gui', 'system'].includes(domain)) throw new ServiceError('domain must be gui or system');
|
|
356
|
+
if (plistPath !== null && plistPath !== undefined && /^(?:system|gui)(?:\/[0-9]*)?$/.test(String(plistPath))) {
|
|
357
|
+
throw new ServiceError(`${plistPath} is a launchd domain, not a plist path: pass { domain: '${String(plistPath) === 'system' ? 'system' : 'gui'}' } (FAA-286)`);
|
|
358
|
+
}
|
|
354
359
|
let which = domain;
|
|
355
360
|
if ((which === null || which === undefined) && plistPath !== null && plistPath !== undefined) {
|
|
356
361
|
const paths = platform === 'darwin' ? path.posix : path; // a macOS path, whatever host reads it
|
|
@@ -363,6 +368,34 @@ export function launchdDomain(plistPath = null, { domain = null, platform = proc
|
|
|
363
368
|
return guiDomain(platform, uid);
|
|
364
369
|
}
|
|
365
370
|
|
|
371
|
+
// A domain the plist's folder contradicts (FAA-286 items 14 and 21): a system job in a LaunchAgents folder (launchd also
|
|
372
|
+
// loads it into gui/<uid> at login), or a gui job in /Library/LaunchDaemons (launchd loads it at boot as root). Another
|
|
373
|
+
// folder is allowed for a system job, as the tests' and a staging install's are.
|
|
374
|
+
function pairProblem(plistPath, domain, platform) {
|
|
375
|
+
const paths = platform === 'darwin' ? path.posix : path;
|
|
376
|
+
const folder = paths.dirname(paths.resolve(String(plistPath)));
|
|
377
|
+
const parts = String(plistPath).split(/[\\/]+/); // the folder's own name, whatever separator a test's host path uses
|
|
378
|
+
if (domain === 'system' && String(parts[parts.length - 2] ?? '').toLowerCase() === 'launchagents') {
|
|
379
|
+
return `A LaunchDaemon's plist lives in ${SYSTEM_DAEMONS}: ${plistPath} is in a LaunchAgents folder, which launchd also loads into gui/<uid> at login, so the job would run twice.`;
|
|
380
|
+
}
|
|
381
|
+
if (domain === 'gui' && folder.toLowerCase() === SYSTEM_DAEMONS.toLowerCase()) {
|
|
382
|
+
return `${plistPath} is in ${SYSTEM_DAEMONS}, which launchd loads at boot as a LaunchDaemon: a gui/<uid> job's plist goes in ~/Library/LaunchAgents.`;
|
|
383
|
+
}
|
|
384
|
+
return null;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// A LaunchAgent of `label` in `userName`'s ~/Library/LaunchAgents, which would run a second copy at its login (FAA-286
|
|
388
|
+
// item 6); null when there is none or the home cannot be read.
|
|
389
|
+
function leftoverAgent(label, userName, run) {
|
|
390
|
+
const read = run(['dscl', '.', '-read', `/Users/${userName}`, 'NFSHomeDirectory']);
|
|
391
|
+
const home = read?.status === 0 ? /NFSHomeDirectory:\s*(\S.*?)\s*$/m.exec(String(read.stdout ?? ''))?.[1] : null;
|
|
392
|
+
if (!home) return null;
|
|
393
|
+
const folder = path.join(home, 'Library', 'LaunchAgents');
|
|
394
|
+
let names = [];
|
|
395
|
+
try { names = fs.readdirSync(folder).filter((n) => n.endsWith('.plist')).sort(); } catch { return null; }
|
|
396
|
+
return names.map((n) => path.join(folder, n)).find((file) => topKey(plistXml(file, run), 'Label') === label) ?? null;
|
|
397
|
+
}
|
|
398
|
+
|
|
366
399
|
// A system-domain job is installed and removed by root: refuse before anything is written (FAA-278).
|
|
367
400
|
function needRoot(domain, label, plistPath, install, euid) {
|
|
368
401
|
if (domain !== 'system' || euid === 0) return;
|
|
@@ -390,6 +423,9 @@ function decodeXml(text) {
|
|
|
390
423
|
function topStrings(text) {
|
|
391
424
|
const token = /<!--[\s\S]*?-->|<\?[\s\S]*?\?>|<!DOCTYPE[^>]*>|<!\[CDATA\[([\s\S]*?)\]\]>|<(\/?)([A-Za-z][\w.:-]*)\s*[^>]*?(\/?)>|([^<]+)/g;
|
|
392
425
|
const values = Object.create(null);
|
|
426
|
+
// The open containers: only the root dict's own keys are read (FAA-286 item 10: a root <array> holding a
|
|
427
|
+
// <key>UserName</key> is no dict, as plistlib reads it).
|
|
428
|
+
const open = [];
|
|
393
429
|
let depth = 0;
|
|
394
430
|
let key = null;
|
|
395
431
|
let capture = null;
|
|
@@ -398,17 +434,21 @@ function topStrings(text) {
|
|
|
398
434
|
for (let m = token.exec(String(text ?? '')); m; m = token.exec(String(text ?? ''))) {
|
|
399
435
|
const [, cdata, closing, name, selfClosing, chars] = m;
|
|
400
436
|
if (cdata !== undefined || chars !== undefined) {
|
|
401
|
-
|
|
437
|
+
// every character reference, at any depth and in any value, is checked as expat checks it (FAA-286 item 10)
|
|
438
|
+
const decoded = chars !== undefined ? decodeXml(chars) : cdata;
|
|
439
|
+
if (capture) buffer += decoded;
|
|
402
440
|
} else if (name === 'dict' || name === 'array') {
|
|
403
441
|
if (selfClosing) {
|
|
404
442
|
if (depth === 1 && key !== null) { delete values[key]; key = null; }
|
|
405
443
|
} else if (closing) {
|
|
406
444
|
depth -= 1;
|
|
445
|
+
open.pop();
|
|
407
446
|
} else {
|
|
408
447
|
if (depth === 1 && key !== null) { delete values[key]; key = null; } // a container is this key's value
|
|
409
448
|
depth += 1;
|
|
449
|
+
open.push(name);
|
|
410
450
|
}
|
|
411
|
-
} else if (name && depth === 1) {
|
|
451
|
+
} else if (name && depth === 1 && open[0] === 'dict') {
|
|
412
452
|
if (selfClosing) {
|
|
413
453
|
if (key !== null) {
|
|
414
454
|
if (name === 'string') values[key] = '';
|
|
@@ -431,6 +471,7 @@ function topStrings(text) {
|
|
|
431
471
|
} catch {
|
|
432
472
|
return null;
|
|
433
473
|
}
|
|
474
|
+
if (key !== null || capture !== null || depth !== 0) return null; // a dangling <key> or an open element: unreadable
|
|
434
475
|
return values;
|
|
435
476
|
}
|
|
436
477
|
|
|
@@ -569,8 +610,8 @@ function loopbackWellKnown(port, agent, host = '127.0.0.1') {
|
|
|
569
610
|
|
|
570
611
|
// Whether launchd's job `pid` listens on exactly 127.0.0.1:<port>, never the wildcard, asked of the process itself (`lsof -p`),
|
|
571
612
|
// which a restricted socket table does not hide. Only for a LaunchDaemon root bootstrapped, whose pid launchd named.
|
|
572
|
-
async function jobListens(pid, port, run = runProgram) {
|
|
573
|
-
if (
|
|
613
|
+
async function jobListens(pid, port, run = runProgram, platform = process.platform) {
|
|
614
|
+
if (platform !== 'darwin' || !Number.isInteger(pid) || pid <= 1) return false;
|
|
574
615
|
try {
|
|
575
616
|
const out = await run(LSOF, ['-nP', '-a', '-p', String(pid), '-i4TCP', '-sTCP:LISTEN', '-Fn']);
|
|
576
617
|
return parseLsofListeners(out).some(([host, p]) => host === '127.0.0.1' && p === port); // exact bind, as RT-3
|
|
@@ -600,7 +641,11 @@ class JobAgent extends http.Agent {
|
|
|
600
641
|
this.ask = ask;
|
|
601
642
|
}
|
|
602
643
|
|
|
603
|
-
createConnection(options,
|
|
644
|
+
createConnection(options, given) {
|
|
645
|
+
// One answer per connection (FAA-286 item 12): an error during the async owner, lsof and ps checks would reach the
|
|
646
|
+
// callback a second time, as CheckedAgent's checkedSocket guards it.
|
|
647
|
+
let answered = false;
|
|
648
|
+
const callback = (err, socket) => { if (!answered) { answered = true; given(err, socket); } };
|
|
604
649
|
const port = Number(options.port);
|
|
605
650
|
const socket = net.connect({ host: '127.0.0.1', port, noDelay: true });
|
|
606
651
|
const timer = setTimeout(() => socket.destroy(Object.assign(new Error('connect timeout'), { code: 'ETIMEDOUT' })), options.timeout ?? 10000);
|
|
@@ -614,14 +659,14 @@ class JobAgent extends http.Agent {
|
|
|
614
659
|
} catch (err) {
|
|
615
660
|
who = { kind: UNKNOWN, uid: null, pid: null, reason: `check: ${err?.name ?? 'Error'}` };
|
|
616
661
|
}
|
|
617
|
-
if (who.pid === this.jobPid && !await jobListens(this.jobPid, port, this.ask.run)) {
|
|
662
|
+
if (who.pid === this.jobPid && !await jobListens(this.jobPid, port, this.ask.run, this.ask.platform)) {
|
|
618
663
|
socket.destroy();
|
|
619
664
|
// The job's pid, but lsof did not show it bound to exactly 127.0.0.1:<port>: a wildcard is never vouched for.
|
|
620
665
|
callback(new ListenerRefused(SERVER_NOT_OWN, `the LaunchDaemon's pid ${this.jobPid} is not shown listening on exactly 127.0.0.1:${port} by lsof (a wildcard is never vouched for); nothing was sent`));
|
|
621
666
|
return;
|
|
622
667
|
}
|
|
623
668
|
const proved = who.pid === this.jobPid || (who.kind === UNKNOWN && String(who.reason ?? '').startsWith(TABLE_RESTRICTED)
|
|
624
|
-
&& await jobListens(this.jobPid, port, this.ask.run));
|
|
669
|
+
&& await jobListens(this.jobPid, port, this.ask.run, this.ask.platform));
|
|
625
670
|
if (!proved) {
|
|
626
671
|
socket.destroy();
|
|
627
672
|
callback(new ListenerRefused(SERVER_NOT_OWN, `the server on port ${port} is not the LaunchDaemon's pid ${this.jobPid} (${who.reason ?? who.kind}): another process holds the port, or the job's program starts the server as a child (start it with exec); nothing was sent`, who));
|
|
@@ -649,11 +694,11 @@ function loopbackPort(origin) {
|
|
|
649
694
|
}
|
|
650
695
|
|
|
651
696
|
// As wellKnownOf, for a LaunchDaemon: a loopback origin is read only when launchd's job `jobPid`, running as `jobUid`,
|
|
652
|
-
// accepted the connection.
|
|
653
|
-
export async function jobWellKnownOf(origin, { jobPid, jobUid } = {}) {
|
|
697
|
+
// accepted the connection. `ask` reaches the owner, lsof and ps readers (a test's fakes; `platform` for lsof's).
|
|
698
|
+
export async function jobWellKnownOf(origin, { jobPid, jobUid, ask = {} } = {}) {
|
|
654
699
|
const port = loopbackPort(origin);
|
|
655
700
|
if (port === null) throw new ServiceError(`A LaunchDaemon's answer is read only at http://127.0.0.1:<port>, where its job's pid and uid prove it; ${origin} is not.`);
|
|
656
|
-
return loopbackWellKnown(port, new JobAgent({ keepAlive: false, jobPid, jobUid }));
|
|
701
|
+
return loopbackWellKnown(port, new JobAgent({ keepAlive: false, jobPid, jobUid, ask }));
|
|
657
702
|
}
|
|
658
703
|
|
|
659
704
|
/**
|
|
@@ -672,9 +717,15 @@ export async function jobWellKnownOf(origin, { jobPid, jobUid } = {}) {
|
|
|
672
717
|
export async function launchdInstall(label, plistPath, plistText, { origin, serviceId, instance = 'default', timeout = 40, forceEnable = false,
|
|
673
718
|
run = defaultRun, platform = process.platform, uid, domain = null, euid = ownEuid(), fetchWellKnown = wellKnownOf,
|
|
674
719
|
fetchJobWellKnown = jobWellKnownOf, sleep = pause } = {}) {
|
|
720
|
+
const pair = pairProblem(plistPath, domain, platform);
|
|
721
|
+
if (pair) throw new ServiceError(pair);
|
|
675
722
|
const dom = launchdDomain(plistPath, { domain, platform, uid });
|
|
676
723
|
launchdLabel(label);
|
|
677
724
|
if (!origin || !serviceId) throw new ServiceError('launchdInstall needs the origin and the service id it waits to see answer.');
|
|
725
|
+
if (/^bplist/.test(Buffer.isBuffer(plistText) ? plistText.toString('latin1', 0, 6) : String(plistText ?? '').slice(0, 6))) {
|
|
726
|
+
// named as what it is, never as a missing UserName (FAA-286 item 7): the text launchdPlist writes is XML
|
|
727
|
+
throw new ServiceError('The plist is a binary plist: pass the XML text launchdPlist writes (plutil -convert xml1 converts one).');
|
|
728
|
+
}
|
|
678
729
|
needRoot(dom, label, plistPath, true, euid);
|
|
679
730
|
if (dom === 'system' && loopbackPort(origin) === null) {
|
|
680
731
|
throw new ServiceError(`A LaunchDaemon's origin is http://127.0.0.1:<port>: only there is its answer proved by the job's pid and uid (FAA-278); ${origin} is not.`);
|
|
@@ -693,7 +744,7 @@ export async function launchdInstall(label, plistPath, plistText, { origin, serv
|
|
|
693
744
|
}
|
|
694
745
|
}
|
|
695
746
|
if (dom === 'system' && named && !USER_NAME.test(named)) {
|
|
696
|
-
throw new ServiceError(`UserName ${named} is not a macOS short name (${
|
|
747
|
+
throw new ServiceError(`UserName ${named} is not a macOS short name (${USER_NAME_RULE}): launchd takes the account's short name.`);
|
|
697
748
|
}
|
|
698
749
|
const jobUid = named ? accountUid(named, run) : null;
|
|
699
750
|
if (dom === 'system' && named && jobUid === null) {
|
|
@@ -705,7 +756,12 @@ export async function launchdInstall(label, plistPath, plistText, { origin, serv
|
|
|
705
756
|
if (dom === 'system' && !named) {
|
|
706
757
|
throw new ServiceError("A LaunchDaemon needs UserName: without it the job runs as root, keeps its data in root's home, and every client refuses root's listener (DEC-0154 HA-2). Write the plist with launchdPlist(..., { userName: <the account whose Fabric and agents call it> }).");
|
|
707
758
|
}
|
|
759
|
+
if (dom === 'system') {
|
|
760
|
+
const leftover = leftoverAgent(label, named, run);
|
|
761
|
+
if (leftover) throw new ServiceError(`${leftover} is a LaunchAgent of label ${label}: it would start a second copy at ${named}'s login beside this LaunchDaemon. Remove it first (launchdUninstall as ${named}; lifecycle reference, The system domain).`);
|
|
762
|
+
}
|
|
708
763
|
const opts = { run, platform, uid, domain: dom === 'system' ? 'system' : null };
|
|
764
|
+
const existed = fs.existsSync(plistPath);
|
|
709
765
|
atomicWrite(plistPath, plistText, 0o644);
|
|
710
766
|
const lint = run(['plutil', '-lint', String(plistPath)]);
|
|
711
767
|
if (lint?.status !== 0) throw new ServiceError(`plist failed plutil -lint: ${String(lint?.stdout || lint?.stderr || '').trim().slice(0, 200)}`);
|
|
@@ -725,6 +781,11 @@ export async function launchdInstall(label, plistPath, plistText, { origin, serv
|
|
|
725
781
|
if (dom !== 'system' && /:\s*125:|Domain does not support|Could not find domain/.test(last)) {
|
|
726
782
|
throw new ServiceError(`launchctl bootstrap failed: ${last.slice(0, 200)}. This user has no GUI login session (an SSH session, or a headless Mac after a FileVault restart), so ${dom} does not run jobs: remove the LaunchAgent this attempt wrote (launchdUninstall), then install it as a LaunchDaemon in the system domain (a plist in ${SYSTEM_DAEMONS} with UserName, as root; FAA-278).`);
|
|
727
783
|
}
|
|
784
|
+
if (dom === 'system') {
|
|
785
|
+
// a plist left in /Library/LaunchDaemons loads at the next boot (FAA-286 item 15): one this call wrote goes
|
|
786
|
+
if (!existed) { try { fs.unlinkSync(plistPath); } catch { /* named below */ } }
|
|
787
|
+
throw new ServiceError(`launchctl bootstrap failed: ${last.slice(0, 200)}; ${fs.existsSync(plistPath) ? `${plistPath} stays and loads at the next boot: remove it as root` : 'the plist this call wrote was removed'}`);
|
|
788
|
+
}
|
|
728
789
|
throw new ServiceError(`launchctl bootstrap failed: ${last.slice(0, 200)}`);
|
|
729
790
|
}
|
|
730
791
|
await sleep(1000);
|
|
@@ -780,6 +841,10 @@ export async function launchdUninstall(label, plistPath, { timeout = 30, purge =
|
|
|
780
841
|
needRoot(dom, label, plistPath, false, euid);
|
|
781
842
|
if (purge && dom === 'system') throw new ServiceError("A LaunchDaemon's data belongs to its UserName, not to root: uninstall without purge, then remove the data as that user.");
|
|
782
843
|
if (dom === 'system') refuseHeldLabel(label, plistPath, run); // a bootout would stop another plist's job (FAA-278)
|
|
844
|
+
const holds = fs.existsSync(plistPath) ? topKey(plistXml(plistPath, run), 'Label') : null;
|
|
845
|
+
if (holds !== null && holds !== label) { // FAA-286 item 9: booting out one job and unlinking another's plist
|
|
846
|
+
throw new ServiceError(`${plistPath} holds launchd label ${holds}, not ${label}: nothing was removed. Name the plist of ${label}, or uninstall ${holds} with its own label.`);
|
|
847
|
+
}
|
|
783
848
|
const dirs = purge ? instanceDirs(serviceId, instance, platform) : {}; // one instance's folders only (FAA-125)
|
|
784
849
|
const opts = { run, platform, uid, domain: dom === 'system' ? 'system' : null };
|
|
785
850
|
const target = `${dom}/${label}`;
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_descriptor.py
CHANGED
|
@@ -5,12 +5,14 @@ from __future__ import annotations
|
|
|
5
5
|
|
|
6
6
|
import hashlib
|
|
7
7
|
import http.server
|
|
8
|
+
import ipaddress
|
|
8
9
|
import json
|
|
9
10
|
import os
|
|
10
11
|
import re
|
|
11
12
|
import socket
|
|
12
13
|
import socketserver
|
|
13
14
|
import subprocess
|
|
15
|
+
import threading
|
|
14
16
|
import warnings
|
|
15
17
|
from pathlib import Path
|
|
16
18
|
from typing import Any, Dict, List, Optional, Sequence, Tuple
|
|
@@ -88,19 +90,101 @@ _NOT_LAUNCHD = ("systemd", "task-scheduler", "node", "person")
|
|
|
88
90
|
_AUTH_HEADER = re.compile(r"[A-Za-z][A-Za-z0-9-]{0,63}")
|
|
89
91
|
|
|
90
92
|
|
|
93
|
+
#: How a loopback server treats the OS account of the process that connected (DEC-0154 HA-2's server side, HA-3;
|
|
94
|
+
#: FAA-227): `refuse` (the default) closes a connection whose peer is another OS account's before reading a byte of
|
|
95
|
+
#: it and serves the rest, labelled; `strict` also refuses a peer whose owner cannot be told and a system account
|
|
96
|
+
#: other than root (uid 0, which can read every account's files and installs a LaunchDaemon); `off` reads no table.
|
|
97
|
+
PEER_CHECKS = ("refuse", "strict", "off")
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def connection_refusal(owner: Any, mode: str = "refuse") -> Optional[str]:
|
|
101
|
+
"""Why a server refuses the peer `owner` (a fabric_peer_owner.Owner) under `mode`, or None to serve it. The reason
|
|
102
|
+
is the owner's kind, or for `unknown` its reason; it names no address and nothing the peer sent."""
|
|
103
|
+
if mode not in PEER_CHECKS:
|
|
104
|
+
raise ValueError("peer_check must be one of %s" % ", ".join(PEER_CHECKS))
|
|
105
|
+
if mode == "off" or owner is None:
|
|
106
|
+
return None
|
|
107
|
+
kind = getattr(owner, "kind", fabric_peer_owner.UNKNOWN)
|
|
108
|
+
if kind == fabric_peer_owner.ANOTHER_ACCOUNT:
|
|
109
|
+
return kind
|
|
110
|
+
if mode == "strict" and kind != fabric_peer_owner.THIS_ACCOUNT:
|
|
111
|
+
if kind == fabric_peer_owner.SYSTEM and getattr(owner, "uid", None) == 0:
|
|
112
|
+
return None
|
|
113
|
+
return getattr(owner, "reason", None) or kind
|
|
114
|
+
return None
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def is_loopback_peer(client_address: Any) -> bool:
|
|
118
|
+
"""Whether an accepted connection's peer address is a loopback one (127.0.0.0/8, ::1, or either IPv4-mapped): the
|
|
119
|
+
only peers whose owner can be another account of this computer (DEC-0154 HA-2). An address that cannot be read
|
|
120
|
+
counts as loopback, so its owner is still read from the table."""
|
|
121
|
+
try:
|
|
122
|
+
return ipaddress.ip_address(fabric_peer_owner.norm_host(str(client_address[0]))).is_loopback
|
|
123
|
+
except Exception:
|
|
124
|
+
return True
|
|
125
|
+
|
|
126
|
+
|
|
91
127
|
class LoopbackHTTPServer(http.server.ThreadingHTTPServer):
|
|
92
128
|
"""ThreadingHTTPServer for a loopback service: threads are daemons, and the bind asks
|
|
93
129
|
no resolver. HTTPServer.server_bind() calls socket.getfqdn() between bind() and listen();
|
|
94
130
|
on a Mac with a slow resolver the port then stays bound but silent, so a host sees
|
|
95
|
-
neither an answer nor a refusal.
|
|
131
|
+
neither an answer nor a refusal.
|
|
132
|
+
|
|
133
|
+
Each accepted connection's peer is told from the computer's socket table (`fabric_peer_owner.peer`) in the
|
|
134
|
+
connection's own thread, before the handler reads a byte (DEC-0154 HA-2's server side, FAA-227). `peer_check`
|
|
135
|
+
(`PEER_CHECKS`) decides which owners are closed unread; a served handler finds its peer's owner with
|
|
136
|
+
`self.server.peer_owner()`. A refusal writes one line to stderr with the owner's kind, uid and pid and nothing the
|
|
137
|
+
peer sent (`peer_refused`), and counts in `peer_refusals`. The check applies to loopback peers only: a peer at
|
|
138
|
+
another address (a service bound to 0.0.0.0) is no local account, so its table is not read and it is served under
|
|
139
|
+
`refuse` and `strict` alike, with `peer_owner()` None."""
|
|
96
140
|
|
|
97
141
|
daemon_threads = True
|
|
142
|
+
peer_check = "refuse"
|
|
143
|
+
#: The reader of an accepted socket's peer; a test replaces it.
|
|
144
|
+
peer_reader = staticmethod(lambda sock: fabric_peer_owner.peer(sock))
|
|
145
|
+
|
|
146
|
+
def __init__(self, server_address: Any, handler: Any, bind_and_activate: bool = True, *,
|
|
147
|
+
peer_check: Optional[str] = None) -> None:
|
|
148
|
+
mode = self.peer_check if peer_check is None else peer_check
|
|
149
|
+
connection_refusal(None, mode) # refuses an unknown mode before the port is bound
|
|
150
|
+
self.peer_check = mode
|
|
151
|
+
self.peer_refusals = 0
|
|
152
|
+
self._peer = threading.local()
|
|
153
|
+
super().__init__(server_address, handler, bind_and_activate)
|
|
98
154
|
|
|
99
155
|
def server_bind(self) -> None:
|
|
100
156
|
socketserver.TCPServer.server_bind(self)
|
|
101
157
|
host, port = self.server_address[:2]
|
|
102
158
|
self.server_name, self.server_port = host, port
|
|
103
159
|
|
|
160
|
+
def peer_owner(self) -> Any:
|
|
161
|
+
"""The owner of the peer this thread's handler serves (None under `peer_check="off"`)."""
|
|
162
|
+
return getattr(self._peer, "owner", None)
|
|
163
|
+
|
|
164
|
+
def finish_request(self, request: Any, client_address: Any) -> None:
|
|
165
|
+
owner = None
|
|
166
|
+
if self.peer_check != "off" and is_loopback_peer(client_address):
|
|
167
|
+
try:
|
|
168
|
+
owner = self.peer_reader(request)
|
|
169
|
+
except Exception as exc: # a reader that fails is an owner nobody can tell, never a crash of the server
|
|
170
|
+
owner = fabric_peer_owner.Owner(fabric_peer_owner.UNKNOWN, None, None, "none",
|
|
171
|
+
"reader: %s" % type(exc).__name__)
|
|
172
|
+
reason = connection_refusal(owner, self.peer_check)
|
|
173
|
+
if reason is not None:
|
|
174
|
+
self.peer_refusals += 1
|
|
175
|
+
self.peer_refused(owner, reason)
|
|
176
|
+
return # process_request_thread closes the socket unread (shutdown_request)
|
|
177
|
+
self._peer.owner = owner
|
|
178
|
+
try:
|
|
179
|
+
super().finish_request(request, client_address)
|
|
180
|
+
finally:
|
|
181
|
+
self._peer.owner = None
|
|
182
|
+
|
|
183
|
+
def peer_refused(self, owner: Any, reason: str) -> None:
|
|
184
|
+
"""One line on stderr per refused connection; override to log it elsewhere. Never the peer's bytes."""
|
|
185
|
+
_sys.stderr.write("fabric: refused a loopback peer (%s, uid %s, pid %s) before reading it (DEC-0154)\n"
|
|
186
|
+
% (reason, getattr(owner, "uid", None), getattr(owner, "pid", None)))
|
|
187
|
+
|
|
104
188
|
|
|
105
189
|
def descriptor_path(service_id: str, instance: str = "default", directory: Optional[Path] = None) -> Path:
|
|
106
190
|
return (directory or services_dir()) / ("%s.%s.json" % (service_id, instance))
|
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
"""Work a service holds by its own limits, said to its caller and its operator (FAA-298). Part of the service kit
|
|
2
|
+
(layer L3, after fabric_interop).
|
|
3
|
+
|
|
4
|
+
A service that holds work by a limit of its own (a daily quota, a per-caller share, a concurrency cap, a money cap)
|
|
5
|
+
says so three ways:
|
|
6
|
+
|
|
7
|
+
- to the caller, on the job: `waiting_message(reason, since, until, message)` is the job's `statusMessage`. The pinned
|
|
8
|
+
`interop-job` schema is closed (`additionalProperties: false`), so `waiting {reason, since, until, message}` cannot
|
|
9
|
+
be a field of the job; it rides the contract's pause grammar (DEC-0087) as its text:
|
|
10
|
+
`quota-exhausted[ until <until>]: waiting <reason> since <since>[: <message>]`. A caller that reads only the pause
|
|
11
|
+
grammar waits (and until `until`, when given); `read_waiting(answer)` gives the four parts back. `JobStore.pause`
|
|
12
|
+
takes the same statusMessage through `hold_job`.
|
|
13
|
+
- to the operator, once per hold: `HoldBook.hold` opens a hold per (reason, caller), or joins the open one, and returns
|
|
14
|
+
the push only when it opens: one `notify` event naming the caller, the work, the reason and when the hold frees by
|
|
15
|
+
itself, with the question whether to raise the limit. Further jobs held by the same limit for the same caller join
|
|
16
|
+
the hold silently; a hold is closed by `release`, by its `until` passing, or by a lift that covers it.
|
|
17
|
+
- the answer, as a scoped lift: `HoldBook.lift` records the operator's words (a DEC-0121 decision, action
|
|
18
|
+
`limit.lift`) with its scope (the reason, optionally one caller and one job) and its size (a number of jobs, an
|
|
19
|
+
amount of money, or a raised limit for a time window such as 100 a day for today). `admit` spends a jobs or money
|
|
20
|
+
lift; `limit` gives the limit in force. A lift falls back by itself when its window ends or its amount is spent.
|
|
21
|
+
|
|
22
|
+
`HoldBook` keeps one JSON file (0600, written atomically); a ThreadingHTTPServer's handlers share it under the store
|
|
23
|
+
lock. It holds caller names, work labels, reasons and the operator's words, never a credential: `hold` refuses a text
|
|
24
|
+
the contract's detector reads as one.
|
|
25
|
+
"""
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import json
|
|
29
|
+
import re
|
|
30
|
+
import uuid
|
|
31
|
+
from pathlib import Path
|
|
32
|
+
from typing import Any, Callable, Dict, List, Optional
|
|
33
|
+
|
|
34
|
+
import sys as _sys # noqa: E402
|
|
35
|
+
from pathlib import Path as _Path # noqa: E402
|
|
36
|
+
if str(_Path(__file__).resolve().parent) not in _sys.path: # the kit loads from any working directory (FAA-65)
|
|
37
|
+
_sys.path.insert(0, str(_Path(__file__).resolve().parent))
|
|
38
|
+
from fabric_base import ServiceError, now_iso, store_lock # noqa: E402
|
|
39
|
+
from fabric_paths import atomic_write # noqa: E402
|
|
40
|
+
from fabric_actions import decision_shape # noqa: E402
|
|
41
|
+
from fabric_interop_rules import pause_message, read_job_pause, utc_time, QUOTA_EXHAUSTED # noqa: E402
|
|
42
|
+
import fabric_secrets # noqa: E402
|
|
43
|
+
|
|
44
|
+
#: The limits a hold names: a daily quota, a per-caller share, a concurrency cap, a money cap.
|
|
45
|
+
HOLD_REASONS = ("daily-quota", "caller-share", "concurrency", "money")
|
|
46
|
+
#: How the push names each reason to a person.
|
|
47
|
+
REASON_WORDS = {"daily-quota": "the daily quota", "caller-share": "its per-caller share",
|
|
48
|
+
"concurrency": "the concurrency cap", "money": "the money cap"}
|
|
49
|
+
#: The push's kind: a decision for the operator (level notice, notify true; events-and-notifications.md).
|
|
50
|
+
HOLD_EVENT_KIND = "limit.held"
|
|
51
|
+
#: The record of a lift applied (level info, no notify).
|
|
52
|
+
LIFT_EVENT_KIND = "limit.lifted"
|
|
53
|
+
#: The decision action a lift carries (DEC-0121 AP-2 shape).
|
|
54
|
+
LIFT_ACTION = "limit.lift"
|
|
55
|
+
_MAX_STATUS_MESSAGE = 500
|
|
56
|
+
_WAITING = re.compile(r"^waiting (daily-quota|caller-share|concurrency|money) since ([0-9][0-9TZ:.-]*?)(?:: ([\s\S]+))?\Z")
|
|
57
|
+
_SIZES = ("jobs", "usd", "limit")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _instant(value: Any, name: str) -> str:
|
|
61
|
+
if utc_time(value) is None:
|
|
62
|
+
raise ServiceError("%s is a UTC instant (Z, at most 3 fraction digits)." % name)
|
|
63
|
+
return value
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def waiting_message(reason: str, since: str, until: Optional[str] = None, message: Optional[str] = None) -> str:
|
|
67
|
+
"""A held job's statusMessage: the pause grammar, its text `waiting <reason> since <since>[: <message>]`. The
|
|
68
|
+
message is shortened, never refused, to fit the contract's 500 characters; a reason or an instant outside the
|
|
69
|
+
grammar raises ServiceError."""
|
|
70
|
+
if reason not in HOLD_REASONS:
|
|
71
|
+
raise ServiceError("A hold's reason is one of %s." % ", ".join(HOLD_REASONS))
|
|
72
|
+
_instant(since, "since")
|
|
73
|
+
if until is not None:
|
|
74
|
+
_instant(until, "until")
|
|
75
|
+
text = "waiting %s since %s" % (reason, since)
|
|
76
|
+
head = len(QUOTA_EXHAUSTED) + (0 if until is None else len(" until ") + len(until)) + len(": ")
|
|
77
|
+
if message is not None:
|
|
78
|
+
message = " ".join(str(message).split())
|
|
79
|
+
room = _MAX_STATUS_MESSAGE - head - len(text) - len(": ")
|
|
80
|
+
if message and room > 0:
|
|
81
|
+
text += ": " + (message if len(message) <= room else message[:room - 3].rstrip() + "...")
|
|
82
|
+
return pause_message(until, text)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def read_waiting(answer: Any) -> Optional[Dict[str, Any]]:
|
|
86
|
+
"""`{reason, since, until?, message?}` of a fabric.job.get answer whose job a limit holds (`waiting_message`), else
|
|
87
|
+
None: a plain pause, a queue position or a finished job is not a hold."""
|
|
88
|
+
pause = read_job_pause(answer)
|
|
89
|
+
if not pause.get("paused"):
|
|
90
|
+
return None
|
|
91
|
+
status = answer["job"]["statusMessage"]
|
|
92
|
+
rest = status[len(QUOTA_EXHAUSTED):]
|
|
93
|
+
if "resetsAt" in pause:
|
|
94
|
+
rest = rest[len(" until ") + len(pause["resetsAt"]):]
|
|
95
|
+
if not rest.startswith(": "):
|
|
96
|
+
return None
|
|
97
|
+
match = _WAITING.match(rest[2:])
|
|
98
|
+
if not match or utc_time(match.group(2)) is None:
|
|
99
|
+
return None
|
|
100
|
+
waiting: Dict[str, Any] = {"reason": match.group(1), "since": match.group(2)}
|
|
101
|
+
if "resetsAt" in pause:
|
|
102
|
+
waiting["until"] = pause["resetsAt"]
|
|
103
|
+
if match.group(3) is not None:
|
|
104
|
+
waiting["message"] = match.group(3)
|
|
105
|
+
return waiting
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def hold_job(jobs: Any, job_id: str, reason: str, since: str, until: Optional[str] = None,
|
|
109
|
+
message: Optional[str] = None) -> Dict[str, Any]:
|
|
110
|
+
"""Put one job of a fabric_interop JobStore on hold: working, its statusMessage `waiting_message(...)`, its
|
|
111
|
+
pollIntervalMs reaching `until` (JobStore.pause). Returns the job."""
|
|
112
|
+
text = waiting_message(reason, since, until, message)[len(QUOTA_EXHAUSTED):]
|
|
113
|
+
if until is not None:
|
|
114
|
+
text = text[len(" until ") + len(until):]
|
|
115
|
+
return jobs.pause(job_id, resets_at=until, text=text[2:])
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def hold_sentence(caller: str, work: str, reason: str, until: Optional[str]) -> str:
|
|
119
|
+
"""The push's one sentence: who, what, why, when it frees by itself, and the question."""
|
|
120
|
+
frees = ("it frees by itself at %s" % until) if until is not None else (
|
|
121
|
+
"it frees by itself when a running job ends" if reason in ("concurrency", "caller-share")
|
|
122
|
+
else "it does not free by itself")
|
|
123
|
+
return "%s for %s is held by %s; %s. Raise the limit for it?" % (work, caller, REASON_WORDS[reason], frees)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _text(value: Any, name: str, high: int = 200) -> str:
|
|
127
|
+
if not isinstance(value, str) or not value.strip() or len(value) > high:
|
|
128
|
+
raise ServiceError("%s is a text of 1 to %d characters." % (name, high))
|
|
129
|
+
if fabric_secrets.secret_value(value):
|
|
130
|
+
raise ServiceError("%s looks like a credential; a hold names callers and work, never a secret." % name)
|
|
131
|
+
return " ".join(value.split())
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
class HoldBook:
|
|
135
|
+
"""The holds a service's limits put on work and the operator's lifts, in one file (FAA-298).
|
|
136
|
+
|
|
137
|
+
`events`, when given, is the service's event log (an object with `append(kind, level, text, subject=..., **extra)`,
|
|
138
|
+
such as fabric_events.JsonlEventLog): the push and the lift record are written there. Without it they are returned
|
|
139
|
+
for the service to write."""
|
|
140
|
+
|
|
141
|
+
def __init__(self, path: Path, events: Any = None, clock: Callable[[], str] = now_iso):
|
|
142
|
+
self.path = Path(path)
|
|
143
|
+
self.events = events
|
|
144
|
+
self.clock = clock
|
|
145
|
+
self._lock = store_lock(self.path)
|
|
146
|
+
|
|
147
|
+
# --- the file -------------------------------------------------------------------------
|
|
148
|
+
def _load(self) -> Dict[str, Any]:
|
|
149
|
+
if not self.path.exists():
|
|
150
|
+
return {"holds": [], "lifts": []}
|
|
151
|
+
try:
|
|
152
|
+
data = json.loads(self.path.read_text(encoding="utf-8"))
|
|
153
|
+
except ValueError:
|
|
154
|
+
raise ServiceError("The hold book %s is not JSON; it is kept as it is." % self.path.name) from None
|
|
155
|
+
if not isinstance(data, dict) or not isinstance(data.get("holds"), list) or not isinstance(data.get("lifts"), list):
|
|
156
|
+
raise ServiceError("The hold book %s is not {holds, lifts}; it is kept as it is." % self.path.name)
|
|
157
|
+
return data
|
|
158
|
+
|
|
159
|
+
def _save(self, data: Dict[str, Any]) -> None:
|
|
160
|
+
atomic_write(self.path, (json.dumps(data, ensure_ascii=False, indent=1) + "\n").encode(), 0o600)
|
|
161
|
+
|
|
162
|
+
def _now(self, now: Optional[str]) -> str:
|
|
163
|
+
return _instant(now if now is not None else self.clock(), "now")
|
|
164
|
+
|
|
165
|
+
def _emit(self, kind: str, level: str, text: str, subject: Dict[str, str], notify: bool) -> Dict[str, Any]:
|
|
166
|
+
if self.events is not None:
|
|
167
|
+
return self.events.append(kind, level, text, subject=subject, notify=notify)
|
|
168
|
+
out: Dict[str, Any] = {"kind": kind, "level": level, "text": text, "subject": subject}
|
|
169
|
+
if notify:
|
|
170
|
+
out["notify"] = True
|
|
171
|
+
return out
|
|
172
|
+
|
|
173
|
+
@staticmethod
|
|
174
|
+
def _expire(data: Dict[str, Any], at: int) -> None:
|
|
175
|
+
for hold in data["holds"]:
|
|
176
|
+
if hold.get("closed") is None and hold.get("until") is not None and utc_time(hold["until"]) <= at:
|
|
177
|
+
hold["closed"], hold["closedAt"] = "expired", hold["until"]
|
|
178
|
+
|
|
179
|
+
@staticmethod
|
|
180
|
+
def _live(lift: Dict[str, Any], at: int) -> bool:
|
|
181
|
+
if lift.get("from") is not None and utc_time(lift["from"]) > at:
|
|
182
|
+
return False
|
|
183
|
+
if lift.get("until") is not None and utc_time(lift["until"]) <= at:
|
|
184
|
+
return False
|
|
185
|
+
return lift.get("left", 1) > 0
|
|
186
|
+
|
|
187
|
+
# --- holds ----------------------------------------------------------------------------
|
|
188
|
+
def hold(self, *, caller: str, work: str, reason: str, until: Optional[str] = None, message: Optional[str] = None,
|
|
189
|
+
job_id: Optional[str] = None, now: Optional[str] = None) -> Dict[str, Any]:
|
|
190
|
+
"""Hold `work` for `caller` by `reason`. Returns {hold, statusMessage, push}: `push` is the notify event when this
|
|
191
|
+
call opened the hold, None when the work joined an open one (one push per hold)."""
|
|
192
|
+
caller, work = _text(caller, "caller"), _text(work, "work")
|
|
193
|
+
if reason not in HOLD_REASONS:
|
|
194
|
+
raise ServiceError("A hold's reason is one of %s." % ", ".join(HOLD_REASONS))
|
|
195
|
+
at = self._now(now)
|
|
196
|
+
if until is not None and utc_time(_instant(until, "until")) <= utc_time(at):
|
|
197
|
+
raise ServiceError("until %s has passed: the limit no longer holds the work." % until)
|
|
198
|
+
with self._lock:
|
|
199
|
+
data = self._load()
|
|
200
|
+
self._expire(data, utc_time(at))
|
|
201
|
+
hold = next((h for h in data["holds"] if h.get("closed") is None and h["reason"] == reason
|
|
202
|
+
and h["caller"] == caller), None)
|
|
203
|
+
opened = hold is None
|
|
204
|
+
if opened:
|
|
205
|
+
hold = {"id": uuid.uuid4().hex, "reason": reason, "caller": caller, "work": work, "since": at,
|
|
206
|
+
"until": until, "jobs": [], "closed": None}
|
|
207
|
+
data["holds"].append(hold)
|
|
208
|
+
if job_id is not None and job_id not in hold["jobs"]:
|
|
209
|
+
hold["jobs"].append(str(job_id))
|
|
210
|
+
self._save(data)
|
|
211
|
+
status = waiting_message(reason, hold["since"], hold["until"], message)
|
|
212
|
+
push = None
|
|
213
|
+
if opened:
|
|
214
|
+
push = self._emit(HOLD_EVENT_KIND, "notice", hold_sentence(caller, work, reason, until),
|
|
215
|
+
{"type": "limit", "id": "%s:%s" % (reason, caller)[:200], "label": REASON_WORDS[reason]}, True)
|
|
216
|
+
return {"hold": dict(hold), "statusMessage": status, "push": push}
|
|
217
|
+
|
|
218
|
+
def release(self, *, reason: str, caller: str, now: Optional[str] = None) -> Optional[Dict[str, Any]]:
|
|
219
|
+
"""Close the open hold of (reason, caller): the limit freed the work. The next hold pushes again."""
|
|
220
|
+
at = self._now(now)
|
|
221
|
+
with self._lock:
|
|
222
|
+
data = self._load()
|
|
223
|
+
hold = next((h for h in data["holds"] if h.get("closed") is None and h["reason"] == reason
|
|
224
|
+
and h["caller"] == caller), None)
|
|
225
|
+
if hold is None:
|
|
226
|
+
return None
|
|
227
|
+
hold["closed"], hold["closedAt"] = "released", at
|
|
228
|
+
self._save(data)
|
|
229
|
+
return dict(hold)
|
|
230
|
+
|
|
231
|
+
def open_holds(self, now: Optional[str] = None) -> List[Dict[str, Any]]:
|
|
232
|
+
data = self._load()
|
|
233
|
+
self._expire(data, utc_time(self._now(now)))
|
|
234
|
+
return [dict(h) for h in data["holds"] if h.get("closed") is None]
|
|
235
|
+
|
|
236
|
+
# --- lifts ----------------------------------------------------------------------------
|
|
237
|
+
def lift(self, *, reason: str, decision: Dict[str, Any], caller: Optional[str] = None, job_id: Optional[str] = None,
|
|
238
|
+
jobs: Optional[int] = None, usd: Optional[float] = None, limit: Optional[int] = None,
|
|
239
|
+
until: Optional[str] = None, now: Optional[str] = None) -> Dict[str, Any]:
|
|
240
|
+
"""Record the operator's answer as a scoped lift: exactly one of `jobs` (that many more jobs), `usd` (that much
|
|
241
|
+
more money) or `limit` (the limit raised to that value, which needs `until`, the window's end). `decision` is
|
|
242
|
+
the operator's words in decision.schema.json's shape with action `limit.lift`. The open hold it covers closes
|
|
243
|
+
as lifted. Returns the lift."""
|
|
244
|
+
if reason not in HOLD_REASONS:
|
|
245
|
+
raise ServiceError("A lift's reason is one of %s." % ", ".join(HOLD_REASONS))
|
|
246
|
+
if not decision_shape(decision) or decision.get("action") != LIFT_ACTION:
|
|
247
|
+
raise ServiceError("A lift carries the operator's decision (decision.schema.json, action limit.lift, their "
|
|
248
|
+
"words verbatim): fabric_actions.operator_decision('limit.lift', words).")
|
|
249
|
+
sizes = {"jobs": jobs, "usd": usd, "limit": limit}
|
|
250
|
+
given = [name for name in _SIZES if sizes[name] is not None]
|
|
251
|
+
if len(given) != 1:
|
|
252
|
+
raise ServiceError("A lift is one of jobs, usd or limit.")
|
|
253
|
+
size = sizes[given[0]]
|
|
254
|
+
if isinstance(size, bool) or not isinstance(size, (int, float)) or size <= 0 or (
|
|
255
|
+
given[0] != "usd" and int(size) != size):
|
|
256
|
+
raise ServiceError("A lift's %s is a positive %s." % (given[0], "number" if given[0] == "usd" else "integer"))
|
|
257
|
+
at = self._now(now)
|
|
258
|
+
if until is not None and utc_time(_instant(until, "until")) <= utc_time(at):
|
|
259
|
+
raise ServiceError("A lift's window ends after now.")
|
|
260
|
+
if given[0] == "limit" and until is None:
|
|
261
|
+
raise ServiceError("A raised limit needs its window's end (until): it falls back when the window ends.")
|
|
262
|
+
record: Dict[str, Any] = {"id": uuid.uuid4().hex, "reason": reason, "caller": None if caller is None else _text(caller, "caller"),
|
|
263
|
+
"job": None if job_id is None else str(job_id), "from": at, "until": until,
|
|
264
|
+
given[0]: size, "decision": dict(decision), "at": at}
|
|
265
|
+
if given[0] != "limit":
|
|
266
|
+
record["left"] = size
|
|
267
|
+
with self._lock:
|
|
268
|
+
data = self._load()
|
|
269
|
+
self._expire(data, utc_time(at))
|
|
270
|
+
for hold in data["holds"]:
|
|
271
|
+
if hold.get("closed") is None and hold["reason"] == reason and record["caller"] in (None, hold["caller"]) and (
|
|
272
|
+
job_id is None or str(job_id) in hold["jobs"]):
|
|
273
|
+
hold["closed"], hold["closedAt"], hold["lift"] = "lifted", at, record["id"]
|
|
274
|
+
data["lifts"].append(record)
|
|
275
|
+
self._save(data)
|
|
276
|
+
scope = ("for %s" % record["caller"]) if record["caller"] else "for every caller"
|
|
277
|
+
amount = {"jobs": "%s more job(s)", "usd": "USD %s more", "limit": "the limit at %s"}[given[0]] % size
|
|
278
|
+
self._emit(LIFT_EVENT_KIND, "info", "The operator lifted %s %s: %s%s." % (
|
|
279
|
+
REASON_WORDS[reason], scope, amount, (" until %s" % until) if until else ""),
|
|
280
|
+
{"type": "limit", "id": "%s:%s" % (reason, record["caller"] or "*"), "label": REASON_WORDS[reason]}, False)
|
|
281
|
+
return dict(record)
|
|
282
|
+
|
|
283
|
+
def _matching(self, data: Dict[str, Any], reason: str, caller: Optional[str], job_id: Optional[str], at: int,
|
|
284
|
+
kind: str) -> List[Dict[str, Any]]:
|
|
285
|
+
return [l for l in data["lifts"] if l["reason"] == reason and kind in l and self._live(l, at)
|
|
286
|
+
and l.get("caller") in (None, caller) and l.get("job") in (None, job_id)]
|
|
287
|
+
|
|
288
|
+
def admit(self, *, reason: str, caller: str, job_id: Optional[str] = None, cost_usd: Optional[float] = None,
|
|
289
|
+
now: Optional[str] = None) -> Dict[str, Any]:
|
|
290
|
+
"""Whether a lift lets work past the limit that would hold it: {admitted: True, lift} spends one job of a jobs
|
|
291
|
+
lift, or `cost_usd` of a money lift (the narrowest scope first: a job's, a caller's, every caller's), else
|
|
292
|
+
{admitted: False}. A raised limit is read with `limit`, not spent here."""
|
|
293
|
+
at = utc_time(self._now(now))
|
|
294
|
+
with self._lock:
|
|
295
|
+
data = self._load()
|
|
296
|
+
kind = "usd" if cost_usd is not None else "jobs"
|
|
297
|
+
for lift in sorted(self._matching(data, reason, caller, None if job_id is None else str(job_id), at, kind),
|
|
298
|
+
key=lambda l: (l.get("job") is None, l.get("caller") is None)):
|
|
299
|
+
need = cost_usd if kind == "usd" else 1
|
|
300
|
+
if lift["left"] >= need:
|
|
301
|
+
lift["left"] = round(lift["left"] - need, 6) if kind == "usd" else lift["left"] - 1
|
|
302
|
+
self._save(data)
|
|
303
|
+
return {"admitted": True, "lift": lift["id"], "left": lift["left"]}
|
|
304
|
+
return {"admitted": False}
|
|
305
|
+
|
|
306
|
+
def limit(self, *, reason: str, base: float, caller: Optional[str] = None, now: Optional[str] = None) -> float:
|
|
307
|
+
"""The limit in force: `base`, or the highest live raised limit for (reason, caller); `base` again once the
|
|
308
|
+
window ends."""
|
|
309
|
+
at = utc_time(self._now(now))
|
|
310
|
+
raised = [l["limit"] for l in self._matching(self._load(), reason, caller, None, at, "limit")]
|
|
311
|
+
return max([base] + raised)
|
|
312
|
+
|
|
313
|
+
def lifts(self, now: Optional[str] = None) -> List[Dict[str, Any]]:
|
|
314
|
+
"""Every lift with `live`: whether it still applies now."""
|
|
315
|
+
at = utc_time(self._now(now))
|
|
316
|
+
return [dict(l, live=self._live(l, at)) for l in self._load()["lifts"]]
|