@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.
Files changed (76) hide show
  1. package/CHANGELOG.md +79 -0
  2. package/README.md +13 -2
  3. package/SECURITY.md +23 -8
  4. package/bin/fabric-agent-adapter.js +24 -1
  5. package/package.json +1 -1
  6. package/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
  7. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +6 -4
  8. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/dashboard-links.md +8 -3
  9. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/source-and-instance.md +44 -7
  10. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/adapt_project.py +107 -2
  11. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/check_dashboard_link.py +14 -2
  12. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_secrets.py +18 -3
  13. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_source_check.py +55 -75
  14. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_source_restart.py +56 -0
  15. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_source_tree.py +912 -0
  16. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/new_agent.py +308 -0
  17. package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +5 -3
  18. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/support-agent/README.md +1 -1
  19. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-links.md +8 -3
  20. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/delegation.md +5 -2
  21. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/interop.md +28 -0
  22. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/kit-modules.md +3 -2
  23. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/lifecycle.md +25 -9
  24. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/migrating-a-service.md +1 -0
  25. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/package.md +10 -0
  26. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +22 -5
  27. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_dashboard_layout.mjs +21 -0
  28. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_dashboard_link.py +14 -2
  29. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +17 -28
  30. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_channel_agent.py +2 -1
  31. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_rules.py +135 -3
  32. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-descriptor.mjs +78 -1
  33. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-holds.mjs +266 -0
  34. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-http.mjs +64 -5
  35. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-package.mjs +98 -1
  36. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-peer-owner.mjs +3 -3
  37. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-protocols.mjs +1 -1
  38. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-secrets.mjs +10 -3
  39. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +5 -0
  40. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-supervisors.mjs +76 -11
  41. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_descriptor.py +85 -1
  42. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_holds.py +316 -0
  43. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_http.py +31 -1
  44. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_package.py +103 -1
  45. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_peer_owner.py +13 -8
  46. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_protocols.py +1 -1
  47. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_secrets.py +18 -3
  48. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +3 -0
  49. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_supervisors.py +64 -3
  50. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/kit.json +141 -17
  51. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +16 -6
  52. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/agent-templates.md +44 -0
  53. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/communication-agent-template.md +19 -328
  54. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/dashboard-links.md +8 -3
  55. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/models-and-providers.md +1 -1
  56. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/new-agent.md +42 -0
  57. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/source-and-instance.md +44 -7
  58. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/schemas/template/access.schema.json +50 -0
  59. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/schemas/template/connections.schema.json +23 -0
  60. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/schemas/template/data-rooms.schema.json +74 -0
  61. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/schemas/template/manifest.schema.json +170 -0
  62. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/schemas/template/template-header.schema.json +25 -0
  63. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/scripts/check_dashboard_link.py +14 -2
  64. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/scripts/template_check.py +491 -0
  65. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/templates/communication-agent/CHANGELOG.md +7 -0
  66. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/templates/communication-agent/TEMPLATE.md +347 -0
  67. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/templates/communication-agent/access.json +24 -0
  68. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/templates/communication-agent/assembly.md +15 -0
  69. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/templates/communication-agent/checks/acceptance.md +17 -0
  70. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/templates/communication-agent/connections.json +29 -0
  71. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/templates/communication-agent/data-rooms.json +9 -0
  72. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/templates/communication-agent/manifest.json +32 -0
  73. package/plugins/fabric-agent-adapter/skills/observing-products/SKILL.md +1 -1
  74. package/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +1 -1
  75. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/project-board.md +1 -1
  76. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_env.py +1 -1
@@ -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
- if (capture) buffer += cdata !== undefined ? cdata : decodeXml(chars);
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 (process.platform !== 'darwin' || !Number.isInteger(pid) || pid <= 1) return false;
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, callback) {
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 (${USER_NAME.source}): launchd takes the account's 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}`;
@@ -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"]]