@passioncode-ai/fabric-agent-adapter 0.79.1 → 0.80.2

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 (83) hide show
  1. package/CHANGELOG.md +134 -0
  2. package/bin/fabric-agent-adapter.js +26 -4
  3. package/package.json +1 -1
  4. package/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
  5. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +1 -1
  6. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/agents-database.md +4 -4
  7. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/dashboard-links.md +5 -2
  8. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/references/provider-entry.md +1 -1
  9. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/check_dashboard_link.py +12 -4
  10. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_provider.py +9 -1
  11. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_secrets.py +6 -2
  12. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/scripts/fabric_source_check.py +2 -1
  13. package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +4 -2
  14. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/dashboard-only/README.md +67 -0
  15. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/dashboard-only/dashboard_service.py +252 -0
  16. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/dashboard-only/install.mjs +168 -0
  17. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/dashboard-only/install.py +179 -0
  18. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/key-delivery-door/README.md +57 -0
  19. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/key-delivery-door/door.mjs +181 -0
  20. package/plugins/fabric-agent-adapter/skills/building-fabric-services/recipes/key-delivery-door/door.py +237 -0
  21. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/agents-database.md +4 -4
  22. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/board.md +4 -2
  23. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/dashboard-links.md +5 -2
  24. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/delegation.md +2 -1
  25. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/estate-record.md +12 -1
  26. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/interop.md +26 -3
  27. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/kit-modules.md +4 -2
  28. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/lifecycle.md +8 -3
  29. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +3 -2
  30. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/remote-placement.md +44 -3
  31. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_dashboard_link.py +12 -4
  32. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +7 -5
  33. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_channel_agent.py +10 -5
  34. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service_rules.py +1 -0
  35. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-session.mjs +3 -0
  36. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-board-slots.mjs +1 -0
  37. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-context.mjs +37 -5
  38. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-descriptor.mjs +41 -3
  39. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-http.mjs +9 -3
  40. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-hub.mjs +16 -2
  41. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-load.mjs +344 -0
  42. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-peer-owner.mjs +5 -1
  43. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +6 -0
  44. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-supervisors.mjs +38 -9
  45. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-usage.mjs +8 -1
  46. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_session.py +3 -2
  47. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_board_slots.py +2 -0
  48. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_connections.py +97 -3
  49. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_context.py +50 -8
  50. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_descriptor.py +35 -2
  51. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_http.py +9 -2
  52. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_hub.py +15 -2
  53. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop.py +2 -2
  54. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_load.py +358 -0
  55. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_peer_owner.py +17 -7
  56. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_secrets.py +6 -2
  57. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +20 -0
  58. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_supervisors.py +46 -6
  59. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_usage.py +12 -1
  60. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/kit.json +340 -24
  61. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/sample_service.py +4 -1
  62. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +3 -1
  63. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/agents-database.md +4 -4
  64. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/dashboard-links.md +5 -2
  65. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/resilience-and-autonomy.md +7 -1
  66. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/where-agents-run.md +28 -0
  67. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/references/working-rules.md +54 -0
  68. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/scripts/check_dashboard_link.py +12 -4
  69. package/plugins/fabric-agent-adapter/skills/observing-products/SKILL.md +1 -1
  70. package/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +21 -21
  71. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/agents-database.md +4 -4
  72. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/cleanup.md +30 -0
  73. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/on-demand-session.md +2 -1
  74. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/project-board.md +2 -1
  75. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/remote-sessions.md +75 -0
  76. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/session-hooks.md +28 -13
  77. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/task-context.md +58 -0
  78. package/plugins/fabric-agent-adapter/skills/working-with-fabric/references/waits.md +36 -0
  79. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session.py +14 -0
  80. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_cli.py +143 -36
  81. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_env.py +136 -15
  82. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_mcp.py +78 -3
  83. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_paths.py +9 -2
@@ -1,6 +1,6 @@
1
1
  """peer_owner: which OS account owns each end of a loopback TCP connection.
2
2
 
3
- peer_owner 1.1.0+kit.3: Observatory's peer_owner 1.1.0, vendored into the Fabric service kit as `fabric_peer_owner` (layer L0;
3
+ peer_owner 1.1.0+kit.4: Observatory's peer_owner 1.1.0, vendored into the Fabric service kit as `fabric_peer_owner` (layer L0;
4
4
  DEC-0154 HA-2, FAA-226) from Project Observatory's `observatory/engine/peer_owner.py` at
5
5
  commit f86f0622 (passioncode-ai/project-observatory-dashboard, the same licence). It differs
6
6
  from that file in this paragraph, in `Owner` and `Row` (NamedTuples here where it has
@@ -19,7 +19,8 @@
19
19
  macOS: the sysctl calibration also requires a child process's listener, an empty netstat reads as
20
20
  `TABLE_RESTRICTED`, and a caller may vouch for a listener with a pid it started (`pid_listens`,
21
21
  `parse_lsof_listeners`, `LSOF`, `CheckedHTTPConnection(vouch_pid=...)`, `own_connection(vouch_pid=...)`); since kit.3
22
- the child's port is waited on with `selectors` and read under one deadline (FAA-273).
22
+ the child's port is waited on with `selectors` and read under one deadline (FAA-273); since kit.4 a number is ASCII
23
+ digits only and a netstat with only its header reads as `TABLE_RESTRICTED` (FAA-273 items 3 and 4).
23
24
  A limit /proc sets: it carries a socket's creator and no pid, so a listening socket that root
24
25
  created and handed to another account (systemd socket activation with User=) reads as root's. Standard library only, no import of another kit module.
25
26
  The kit's tests are `test/test_fabric_peer_owner.py`, over `test/fixtures/peer-owner/`;
@@ -110,7 +111,7 @@ import time
110
111
  from typing import NamedTuple, Optional, Tuple
111
112
 
112
113
  # This file's own version, for projects that copy it; not the engine's (that is configuration.VERSION).
113
- PEER_OWNER_VERSION = "1.1.0+kit.3" # Observatory's 1.1.0 with the kit's differences above
114
+ PEER_OWNER_VERSION = "1.1.0+kit.4" # Observatory's 1.1.0 with the kit's differences above
114
115
 
115
116
  THIS_ACCOUNT = "this-account"
116
117
  ANOTHER_ACCOUNT = "another-account"
@@ -231,6 +232,11 @@ def classify(uid: Optional[int], pid: Optional[int], *, method: str, own_uid: Op
231
232
 
232
233
  # -- parsers: pure functions over captured text or bytes (the tests' fixtures) ---------------------------------------
233
234
 
235
+ def _ascii_digits(text: str) -> bool:
236
+ """`[0-9]+` as the Node twin reads a number; `str.isdigit` also takes `٤` and `²` (FAA-273 item 4)."""
237
+ return text != "" and text.isascii() and text.isdigit()
238
+
239
+
234
240
  def parse_netstat(text: str) -> list[Row]:
235
241
  """`netstat -anv -p tcp` on macOS. Process names may hold spaces ("Some App:123"), so the
236
242
  `process:pid` column is what is left between the leading and the trailing columns, both
@@ -246,6 +252,10 @@ def parse_netstat(text: str) -> list[Row]:
246
252
  # "Local Address" and "Foreign Address" are two header words each and one data column each.
247
253
  leading = at - sum(1 for w in names[:at] if w == "Address")
248
254
  trailing = len(names) - at - 1
255
+ if not any(line.split()[:1] and line.split()[0].startswith("tcp") for line in lines[header + 1:]):
256
+ # The caller's own connection is in any table it can see: a header and no tcp row is the restricted table
257
+ # (FAA-273 item 3), not `no-row`.
258
+ raise TableUnreadable(f"{TABLE_RESTRICTED}: netstat printed only its header to this process")
249
259
  rows: list[Row] = []
250
260
  for line in lines[header + 1:]:
251
261
  tok = line.split()
@@ -259,7 +269,7 @@ def parse_netstat(text: str) -> list[Row]:
259
269
  pid = None
260
270
  if ":" in process:
261
271
  tail = process.rsplit(":", 1)[1]
262
- pid = int(tail) if tail.isdigit() else None
272
+ pid = int(tail) if _ascii_digits(tail) else None
263
273
  rows.append(Row(local, remote, tok[5] == "LISTEN", None, pid, tok[5] == "TIME_WAIT"))
264
274
  return rows
265
275
 
@@ -283,7 +293,7 @@ def parse_lsof_listeners(text: str) -> list[tuple[str, int]]:
283
293
  if not line.startswith("n") or ":" not in line:
284
294
  continue
285
295
  host, port = line[1:].rsplit(":", 1)
286
- if port.isdigit():
296
+ if _ascii_digits(port):
287
297
  found.append((norm_host(host.strip("[]")), int(port)))
288
298
  return found
289
299
 
@@ -291,7 +301,7 @@ def parse_lsof_listeners(text: str) -> list[tuple[str, int]]:
291
301
  def parse_ps_uid(text: str) -> Optional[int]:
292
302
  """`ps -o uid= -p PID`: one number, or nothing when the process is gone."""
293
303
  text = text.strip()
294
- return int(text) if text.isdigit() else None
304
+ return int(text) if _ascii_digits(text) else None
295
305
 
296
306
 
297
307
  def parse_proc_net_tcp(text: str, *, family: int, byteorder: Optional[str] = None) -> list[Row]:
@@ -540,7 +550,7 @@ def _child_listener_seen(rows_of) -> Optional[bool]:
540
550
  port = None
541
551
  try:
542
552
  line = _child_line(child, deadline)
543
- if line is None or not line.strip().isdigit():
553
+ if line is None or not _ascii_digits(line.strip()):
544
554
  return None
545
555
  port = int(line.strip())
546
556
  try:
@@ -6,6 +6,7 @@ only. The dashboard kit's fabric-button.js carries its own copy of the name and
6
6
 
7
7
  It reads as the contract's ECMAScript does (FAA-136): ECMAScript's whitespace (Python's `\\s` also takes U+0085 and
8
8
  U+001C to U+001F, and not U+FEFF), `.` stopping at every line terminator, ASCII case folding, an ASCII word boundary."""
9
+ # Example URLs write their scheme in angle brackets: read `<https>` as `https` (FAA-212).
9
10
 
10
11
  from __future__ import annotations
11
12
 
@@ -142,7 +143,7 @@ def _percent_encode(text: str, also: str) -> str:
142
143
  return "".join(out)
143
144
 
144
145
 
145
- def _url_parts(value: str) -> Tuple[str, str, str]:
146
+ def url_parts(value: str) -> Tuple[str, str, str]:
146
147
  """The password, query and fragment of an absolute URL as WHATWG URL parsing splits them, read by hand: urlsplit
147
148
  refuses a `[` or `]` outside an IPv6 host (`<https>://u:p[w@h/`), where `new URL` reads the password (0.56.0 unit
148
149
  r7). The fragment starts at the first `#`, the query at the first `?` before it. A special scheme's authority starts
@@ -166,6 +167,9 @@ def _url_parts(value: str) -> Tuple[str, str, str]:
166
167
  _percent_encode(fragment, "`"))
167
168
 
168
169
 
170
+ _url_parts = url_parts # the name before FAA-250, kept for code that reached it
171
+
172
+
169
173
  def secret_url(value: str) -> bool:
170
174
  """Whether a URL an MCP entry or a launch names carries a credential (LC-21): anything secret_argument finds, a
171
175
  password in its user information, or a query or fragment parameter whose name can only carry a credential and has
@@ -177,7 +181,7 @@ def secret_url(value: str) -> bool:
177
181
  value = re.sub(r"[\t\n\r]", "", value.strip(_C0_OR_SPACE))
178
182
  if not _SCHEME.match(value):
179
183
  return False # not an absolute URL (WHATWG URL parsing refuses it)
180
- password, query, fragment = _url_parts(value)
184
+ password, query, fragment = url_parts(value)
181
185
  if password:
182
186
  return True
183
187
  for part in (query, fragment):
@@ -197,6 +197,24 @@ from fabric_connections import ( # noqa: F401
197
197
  connections_rules,
198
198
  connections_view,
199
199
  )
200
+ from fabric_load import ( # noqa: F401 - the call queue and the load report (DEC-0104, FAA-289)
201
+ LOAD_EXTENSION_KEY,
202
+ LOAD_REPORT_TYPE,
203
+ QUEUE_FULL_MESSAGE,
204
+ QUEUE_LENGTH_MAX,
205
+ DEFAULT_RETRY_AFTER_SECONDS,
206
+ job_caller_key,
207
+ next_queued_job,
208
+ admit_job,
209
+ queue_status_message,
210
+ read_queue_position,
211
+ job_queue_rules,
212
+ load_report_rules,
213
+ load_report_problems,
214
+ load_view,
215
+ load_extension_rules,
216
+ load_extension,
217
+ )
200
218
  from fabric_descriptor import ( # noqa: F401
201
219
  DASHBOARD_EXTENSION_KEY,
202
220
  DASHBOARD_KIT,
@@ -231,7 +249,9 @@ from fabric_descriptor import ( # noqa: F401
231
249
  fabric_button_presence,
232
250
  placement_of,
233
251
  remote_origin_problems,
252
+ port_listener_refusal,
234
253
  port_of,
254
+ port_owner,
235
255
  NO_SOURCE_REPOSITORY,
236
256
  NoSourceRepositoryWarning,
237
257
  names_repository,
@@ -141,12 +141,40 @@ def _check(result: Any, what: str) -> None:
141
141
  raise ServiceError("%s failed: %s" % (what, (getattr(result, "stderr", "") or getattr(result, "stdout", "") or "").strip()[:200]))
142
142
 
143
143
 
144
+ def _poll_installed(origin: str, service_id: Optional[str], instance: str, timeout: float,
145
+ read: Optional[Callable[[str], Any]], sleep: Callable[[float], None], clock: Callable[[], float]) -> Any:
146
+ """Poll the well-known answer at `origin` until this OS account's listener answers as `service_id`.`instance`, as
147
+ launchd_install does (the pinned contract's Install row: every supervisor polls, FAA-253). Another account's listener,
148
+ or one whose owner cannot be told, is never a successful install (DEC-0154 HA-2)."""
149
+ if not service_id:
150
+ raise ServiceError("polling the install at %s needs the service_id it must answer as" % origin)
151
+ read = read or well_known_of
152
+ deadline = clock() + max(0.0, timeout)
153
+ while True:
154
+ doc = read(origin)
155
+ foreign = doc.reason if isinstance(doc, ForeignUserAnswer) else None
156
+ if not foreign and _answers(doc, service_id, instance):
157
+ return doc
158
+ if clock() >= deadline:
159
+ break
160
+ sleep(0.5)
161
+ if foreign:
162
+ raise ServiceError("%s is not this service's: %s (DEC-0154 HA-2); the port is another OS account's or its owner "
163
+ "cannot be told." % (origin, foreign))
164
+ raise ServiceError("%s did not answer as %s.%s within %.0f s; read its log." % (origin, service_id, instance, timeout))
165
+
166
+
144
167
  def systemd_install(unit: str, text: str, *, unit_dir: Optional[Path] = None, run: Callable[..., Any] = subprocess.run,
145
- system: str = sys.platform, force_enable: bool = False) -> Path:
168
+ system: str = sys.platform, force_enable: bool = False, origin: Optional[str] = None,
169
+ service_id: Optional[str] = None, instance: str = "default", timeout: float = 40.0,
170
+ fetch_well_known: Optional[Callable[[str], Any]] = None, sleep: Callable[[float], None] = time.sleep,
171
+ clock: Callable[[], float] = time.monotonic) -> Path:
146
172
  """Write the user unit, enable it so it comes back after a login, and (re)start it so the new unit is what runs.
147
173
 
148
174
  The operator's intent wins (LC-14): a unit already installed and disabled stays off — the file is rewritten, so
149
- the new version is what starts once they enable it — unless ``force_enable`` (the operator asked for it on)."""
175
+ the new version is what starts once they enable it — unless ``force_enable`` (the operator asked for it on).
176
+ With ``origin`` (and ``service_id``), it then polls the well-known answer as ``launchd_install`` does, until this
177
+ account's listener answers within ``timeout`` (FAA-253); ``fetch_well_known(origin)`` reads it as ``well_known_of``."""
150
178
  if not system.startswith("linux"):
151
179
  raise ServiceError("systemd is the supervisor on Linux only")
152
180
  if not _UNIT.match(unit):
@@ -163,6 +191,8 @@ def systemd_install(unit: str, text: str, *, unit_dir: Optional[Path] = None, ru
163
191
  return path
164
192
  _check(run(["systemctl", "--user", "enable", unit], capture_output=True, text=True), "systemctl --user enable")
165
193
  _check(run(["systemctl", "--user", "restart", unit], capture_output=True, text=True), "systemctl --user restart")
194
+ if origin is not None:
195
+ _poll_installed(origin, service_id, instance, timeout, fetch_well_known, sleep, clock)
166
196
  return path
167
197
 
168
198
 
@@ -227,9 +257,13 @@ _TASK_DISABLED = re.compile(r"<Settings>(?:(?!</Settings>).)*<Enabled>false</Ena
227
257
 
228
258
 
229
259
  def task_install(task: str, xml_text: str, *, run: Callable[..., Any] = subprocess.run, system: str = sys.platform,
230
- force_enable: bool = False) -> None:
260
+ force_enable: bool = False, origin: Optional[str] = None, service_id: Optional[str] = None,
261
+ instance: str = "default", timeout: float = 40.0, fetch_well_known: Optional[Callable[[str], Any]] = None,
262
+ sleep: Callable[[float], None] = time.sleep, clock: Callable[[], float] = time.monotonic) -> Any:
231
263
  """Create (or replace) the task from its XML without administrator rights, then (re)start it so the new
232
- definition is what runs. A task already installed and disabled stays off (LC-14) unless ``force_enable``."""
264
+ definition is what runs. A task already installed and disabled stays off (LC-14) unless ``force_enable``.
265
+ With ``origin`` (and ``service_id``), it then polls the well-known answer as ``launchd_install`` does and returns it
266
+ (FAA-253); without, it returns None as before."""
233
267
  if system != "win32":
234
268
  raise ServiceError("Task Scheduler is the supervisor on Windows only")
235
269
  query = run(["schtasks", "/Query", "/TN", task, "/XML"], capture_output=True, text=True)
@@ -248,6 +282,9 @@ def task_install(task: str, xml_text: str, *, run: Callable[..., Any] = subproce
248
282
  if existed:
249
283
  run(["schtasks", "/End", "/TN", task], capture_output=True, text=True)
250
284
  _check(run(["schtasks", "/Run", "/TN", task], capture_output=True, text=True), "schtasks /Run")
285
+ if origin is not None:
286
+ return _poll_installed(origin, service_id, instance, timeout, fetch_well_known, sleep, clock)
287
+ return None
251
288
 
252
289
 
253
290
  def task_uninstall(task: str, *, run: Callable[..., Any] = subprocess.run, system: str = sys.platform) -> None:
@@ -368,13 +405,16 @@ def launchd_loaded(label: str, domain: Optional[str] = None) -> bool:
368
405
  return _launchctl("print", "%s/%s" % (launchd_domain(domain=domain), label)).returncode == 0
369
406
 
370
407
 
371
- _PID_ROW = re.compile(r"^\s*pid\s*=\s*(\d+)\s*\Z")
408
+ _PID_ROW = re.compile(r"^\s*pid\s*=\s*([0-9]+)\s*\Z") # ASCII digits, as Node reads them (FAA-273)
372
409
 
373
410
 
374
411
  def launchd_pid(label: str, domain: Optional[str] = None) -> Optional[int]:
375
412
  """The running pid of this account's launchd job ``label`` (with ``domain="system"``, of the LaunchDaemon), or
376
413
  None (not running, or not readable). Reading needs no root."""
377
- result = _launchctl("print", "%s/%s" % (launchd_domain(domain=domain), label))
414
+ try:
415
+ result = _launchctl("print", "%s/%s" % (launchd_domain(domain=domain), label))
416
+ except subprocess.TimeoutExpired:
417
+ return None # a launchctl that hangs is a pid not read, never an error out of an install's poll (FAA-273)
378
418
  if result.returncode != 0:
379
419
  return None
380
420
  for line in (result.stdout or "").splitlines():
@@ -137,7 +137,7 @@ def _by_project(receipts: List[Dict[str, Any]], first: str, last: str) -> List[D
137
137
  out = []
138
138
  for key in sorted(projects):
139
139
  row = projects[key]
140
- runs = [_close_totals(run) for _, run in sorted(row.pop("_runs").items())]
140
+ runs = [_close_run(run) for _, run in sorted(row.pop("_runs").items())]
141
141
  row = _close_totals(row)
142
142
  if runs:
143
143
  row["taskRuns"] = runs
@@ -167,6 +167,17 @@ def _round_usd(value: float) -> float:
167
167
  return whole / 1e6
168
168
 
169
169
 
170
+ def _close_run(run: Dict[str, Any]) -> Dict[str, Any]:
171
+ """A task run's totals, its cost rounded as `_round_usd` rounds but never above the exact cost: each run and its
172
+ project round apart, so many runs rounding up summed past the project and failed FAC-SEM-025 (board request
173
+ f5753c5a). Rounded this way the runs' sum stays within the project's rounded cost by construction."""
174
+ exact = run["_cost"]
175
+ run = _close_totals(run)
176
+ if run["costUsd"] is not None and run["costUsd"] > exact:
177
+ run["costUsd"] = _round_usd(run["costUsd"] - 0.000001)
178
+ return run
179
+
180
+
170
181
  def _close_totals(row: Dict[str, Any]) -> Dict[str, Any]:
171
182
  cost = row.pop("_cost")
172
183
  row["costUsd"] = None if row["calls"] and row["unpricedCalls"] == row["calls"] else _round_usd(cost)