@passioncode-ai/fabric-agent-adapter 0.17.0 → 0.19.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 +108 -0
- package/README.md +2 -2
- 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 +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +13 -5
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/events-and-notifications.md +9 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/lifecycle.md +70 -19
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/protocol.md +1 -1
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/settings-backups.md +67 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/usage.md +23 -0
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py +12 -6
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-service.mjs +782 -25
- package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_service.py +843 -15
- package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,113 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.19.0 - 2026-10-12
|
|
4
|
+
|
|
5
|
+
Settings backups, every spending limit and the feed client's token header in both kits. These are the
|
|
6
|
+
service-side surfaces the pinned contract already carries: DEC-0025 and DEC-0027 (FAA-13).
|
|
7
|
+
|
|
8
|
+
### Added
|
|
9
|
+
|
|
10
|
+
- **Settings backups (`fabric-settings-backup/1`, FAC-SEM-035).** Each function exists in both kits.
|
|
11
|
+
- **Location.** Snapshots live under `<root>/settings-backups/<id>/<instance>/`, outside the data directory.
|
|
12
|
+
`<root>` is per system: `~/Library/Application Support/PassionCode`, `%APPDATA%\PassionCode` or
|
|
13
|
+
`$XDG_CONFIG_HOME/PassionCode`.
|
|
14
|
+
- **Writing.** `make_settings_backup` builds a snapshot from allow-listed tables of JSON scalars. It refuses
|
|
15
|
+
secret tables, credential columns and fractions.
|
|
16
|
+
- `write_settings_backup` writes atomically, 0600 in a 0700 folder.
|
|
17
|
+
- It does not rewrite an unchanged snapshot.
|
|
18
|
+
- It removes a temporary file an interrupted write left behind.
|
|
19
|
+
- It keeps the 14 newest snapshots.
|
|
20
|
+
- `backup_due` decides when the next snapshot is due.
|
|
21
|
+
- **Reading.** `read_settings_backup` refuses a damaged file and names why.
|
|
22
|
+
- `newest_intact_backup` skips damaged files and empty snapshots.
|
|
23
|
+
- `restore_plan` adds rows and never overwrites them. It names what it drops, leaves to defaults or refuses.
|
|
24
|
+
- **The checksum** uses the settings canonical form: `settings_canonical_json` and `settings_backup_digest`.
|
|
25
|
+
They are checked against the contract's vectors.
|
|
26
|
+
- **Parity.** Both kits write byte-identical files.
|
|
27
|
+
- **Every spending limit (DEC-0027, FAC-SEM-031).**
|
|
28
|
+
- `usage_report` and `usageReport` take a `budgets` list.
|
|
29
|
+
- A machine day or month limit is counted from the receipts, so it always agrees with `budget`.
|
|
30
|
+
- A per-order limit gets `spentUsd: null`.
|
|
31
|
+
- The report is refused when its limits break FAC-SEM-031.
|
|
32
|
+
- `usage_limit_problems` and `usageLimitProblems` check a report.
|
|
33
|
+
- **The feed client (FAC-SEM-036).** `token_header` and `tokenHeader` give the one header the descriptor
|
|
34
|
+
declares. `check_service.py` now sends its token through it.
|
|
35
|
+
- **An independent review found 20 moderate defects; all are fixed with tests.**
|
|
36
|
+
- **The readers follow the schema exactly**, and the two kits accept the same files:
|
|
37
|
+
- a trailing newline is refused (Python matches a whole string now);
|
|
38
|
+
- a timestamp is any RFC 3339 date-time with a real calendar date, including an offset;
|
|
39
|
+
- a string holds at most 1 MiB and a row at most 512 values;
|
|
40
|
+
- every `extensions` key is a URI;
|
|
41
|
+
- NaN is refused;
|
|
42
|
+
- in Node, invalid UTF-8 and a `1.0` integer are refused the way Python refuses them.
|
|
43
|
+
- **A damaged file never stops anything.** The scan skips it, the digest does not crash, a damaged newest file
|
|
44
|
+
does not suppress a good snapshot, and an impossible file name makes a snapshot due.
|
|
45
|
+
- **Safer writes:**
|
|
46
|
+
- only a temporary file older than an hour is removed;
|
|
47
|
+
- the snapshot just written is never pruned, even after the clock steps back.
|
|
48
|
+
- **The restore preview** refuses a key the snapshot holds twice.
|
|
49
|
+
- **Limits:**
|
|
50
|
+
- a machine limit over a rolling window is counted from the receipts;
|
|
51
|
+
- a limit that is not an object is refused.
|
|
52
|
+
- **Both descriptor validators** check `auth.header` and `auth.scheme` the way the schema does.
|
|
53
|
+
- **Tests.**
|
|
54
|
+
- The contract's fixtures at the pin are copied to `test/fixtures/contract/dec-0025-0027/`.
|
|
55
|
+
- The Windows CI job runs both kits' new cases.
|
|
56
|
+
- Mutation testing: 79 planted defects, all caught.
|
|
57
|
+
- **Docs.**
|
|
58
|
+
- `references/settings-backups.md` is new.
|
|
59
|
+
- `usage.md` covers every limit.
|
|
60
|
+
- `events-and-notifications.md` covers reading a feed.
|
|
61
|
+
- Updated: `SKILL.md` step 5 and the lifecycle reference's directory table.
|
|
62
|
+
- The scenario eval `settings-backups-and-every-limit` and two triggers came first.
|
|
63
|
+
|
|
64
|
+
## 0.18.0 - 2026-10-12
|
|
65
|
+
|
|
66
|
+
Services on Linux and Windows in both kits (contract DEC-0032, DEC-0033; FAA-21): the supervisor files the
|
|
67
|
+
contract names, and the Node kit on Windows.
|
|
68
|
+
|
|
69
|
+
### Added
|
|
70
|
+
|
|
71
|
+
- **The supervisor writers, in both kits.** They write byte-identical files, and a test holds them to that.
|
|
72
|
+
- **Linux — a systemd user unit.** `systemd_unit` / `systemdUnit` writes `Restart=always`, `RestartSec=10`,
|
|
73
|
+
`TimeoutStopSec` above the drain, `WantedBy=default.target` and `FABRIC_SERVICE_SUPERVISOR=systemd`.
|
|
74
|
+
`systemd_install` / `systemdInstall` runs `daemon-reload` and `enable --now`. `systemd_uninstall` /
|
|
75
|
+
`systemdUninstall` runs `disable --now`, removes the unit, then runs `daemon-reload`.
|
|
76
|
+
- **Windows — a per-user Scheduled Task.** `task_xml` / `taskXml` writes a logon trigger, least privilege,
|
|
77
|
+
restart every minute on failure, no time limit and `IgnoreNew`. `task_install` / `taskInstall` runs
|
|
78
|
+
`schtasks /Create` from a UTF-16 file it then removes, then `/Run`. `task_uninstall` / `taskUninstall` runs
|
|
79
|
+
`/End`, then `/Delete`.
|
|
80
|
+
- **No secret in a supervisor file.** The writers refuse a credential-shaped argument, environment name or
|
|
81
|
+
value, and any control character.
|
|
82
|
+
- **An upgrade restarts what runs; the operator's off stays off (LC-14).** A unit that `is-enabled` reports
|
|
83
|
+
disabled, or a task whose settings say `Enabled false`, is rewritten and left off unless `force_enable`.
|
|
84
|
+
- **Checked by the real readers:** `systemd-analyze verify` on the Linux job (a quoted `WorkingDirectory=` is
|
|
85
|
+
refused there, so it is written unquoted), and `schtasks /Create` of a throwaway task on the Windows job.
|
|
86
|
+
- **The Node kit on Windows and Linux:**
|
|
87
|
+
- `servicesDir` and `serviceDirs` use `%LOCALAPPDATA%`.
|
|
88
|
+
- `expand` and `validateDescriptor` match the Python kit: all four managers, `unit` and `task` paired with
|
|
89
|
+
their manager, Windows paths, never a network share.
|
|
90
|
+
- The token file gets a protected ACL: written with `icacls`, read with Windows PowerShell 5.1. A refusal
|
|
91
|
+
names the SID.
|
|
92
|
+
- The instance lock is a named pipe on Windows (an abstract socket on Linux, as before).
|
|
93
|
+
- **One copy across the kits.** The Python kit holds the Node kit's lock name (`lock_name` / `lockName`)
|
|
94
|
+
beside its file lock, so the two kits exclude each other on every system. `check_service.py` now reports the
|
|
95
|
+
lock as held when a Node service holds it. Before this release it failed every Node service on Linux.
|
|
96
|
+
- **A copy under systemd backs off on a held lock** as one under launchd does (`supervised()`).
|
|
97
|
+
- **The Windows CI job** runs the Node kit's platform, supervisor and service tests and the Python supervisor
|
|
98
|
+
tests.
|
|
99
|
+
- **Docs:**
|
|
100
|
+
- `references/lifecycle.md`, *Linux and Windows*: the verbs per system and the Windows directories.
|
|
101
|
+
- `SKILL.md` step 4.
|
|
102
|
+
- `protocol.md`.
|
|
103
|
+
|
|
104
|
+
The scenario eval `linux-and-windows-supervision` and two triggers came first.
|
|
105
|
+
- **The probe only looks** (`/proc/net/unix`, `WaitNamedPipeW`) and never takes the lock's name.
|
|
106
|
+
- **An independent review** found 2 critical and 9 moderate defects before release; each is fixed with a test, except
|
|
107
|
+
that a Scheduled Task still cannot tell the service it is supervised (a task sets no environment; documented).
|
|
108
|
+
- **Mutation testing:** 14 planted defects in the Python writers, 23 in the Node port, 7 in the lock name
|
|
109
|
+
and probe and a quoted `WorkingDirectory=` against `systemd-analyze` were all caught.
|
|
110
|
+
|
|
3
111
|
## 0.17.0 - 2026-10-12
|
|
4
112
|
|
|
5
113
|
The organisation's lifecycle rules LC-19 and LC-21 apply to what the adapter ships and checks (FAA-22, FAA-23):
|
package/README.md
CHANGED
|
@@ -230,8 +230,8 @@ FABRIC_CONTRACT_OLD=/path/to/legacy-contract FABRIC_CONTRACT_NEW=/path/to/curren
|
|
|
230
230
|
```
|
|
231
231
|
|
|
232
232
|
Without both environment variables, those tests explicitly skip as `NOT_RUN`.
|
|
233
|
-
This source checkout is 0.
|
|
234
|
-
were never released); 0.
|
|
233
|
+
This source checkout is 0.19.0. 0.18.0 is the latest published GitHub/npm release (2026-10-12; 0.7.0 and 0.11.0
|
|
234
|
+
were never released); 0.19.0 is published by its `v0.19.0` tag. Source delivery does not update installed skills or live sessions.
|
|
235
235
|
|
|
236
236
|
## License
|
|
237
237
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@passioncode-ai/fabric-agent-adapter",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.19.0",
|
|
4
4
|
"description": "Create Fabric-compatible agents, run them as always-alive local services with dashboards (fabric-service/0.1), and adapt existing projects to the Fabric Agent Contract. This package is the installer CLI.",
|
|
5
5
|
"scripts": {
|
|
6
6
|
"test": "python3 test/validate.py && python3 -m unittest discover -s test -q && node --test 'test/*.test.mjs'"
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
"name": "fabric-agent-adapter",
|
|
4
4
|
"displayName": "Fabric Agent Adapter",
|
|
5
5
|
"description": "Create new Fabric-compatible agents, build them as always-alive local services with dashboards (fabric-service/0.1), and adapt existing projects to the Fabric Agent Contract through evidence-backed MCP, A2A, or local-runner provider bundles.",
|
|
6
|
-
"version": "0.
|
|
6
|
+
"version": "0.19.0",
|
|
7
7
|
"author": {
|
|
8
8
|
"name": "PassionCode.ai",
|
|
9
9
|
"url": "https://passioncode.ai/"
|
|
@@ -5,7 +5,7 @@ license: AGPL-3.0-only OR LicenseRef-PassionCode-Commercial
|
|
|
5
5
|
compatibility: Requires filesystem access and Python 3.9+. Exact schema checks additionally need git, Node.js, pnpm, and the pinned fabric-agent-contract checkout (a public repository). Works without those tools in an explicitly degraded structural-check mode.
|
|
6
6
|
metadata:
|
|
7
7
|
author: PassionCode.ai
|
|
8
|
-
version: "0.
|
|
8
|
+
version: "0.19.0"
|
|
9
9
|
contract-version: "0.1.0"
|
|
10
10
|
contract-commit: "0cacd08b0e95b6abf131eae1206b60792138bc3c"
|
|
11
11
|
---
|
|
@@ -13,10 +13,10 @@ description: >-
|
|
|
13
13
|
kits, a TLS sample, live and layout probes. NOT for a one-off script or cron job, a hosted product with no agent
|
|
14
14
|
behind it, the provider manifest (adapting-projects-to-fabric), or building Fabric Dashboards.
|
|
15
15
|
license: AGPL-3.0-only OR LicenseRef-PassionCode-Commercial
|
|
16
|
-
compatibility: Python 3.9+ or Node.js 20+ for the kits; the probe needs Python 3.9+. launchd steps are macOS-only
|
|
16
|
+
compatibility: Python 3.9+ or Node.js 20+ for the kits; the probe needs Python 3.9+. launchd steps are macOS-only; Linux services install a systemd user unit and Windows ones a per-user Scheduled Task with the kits' writers. Dashboard handoff optionally uses Fabric Dashboards MCP link/host_status/open; without it, report unresolved host capability. The contract checkout is optional.
|
|
17
17
|
metadata:
|
|
18
18
|
author: PassionCode.ai
|
|
19
|
-
version: "0.
|
|
19
|
+
version: "0.19.0"
|
|
20
20
|
contract-version: "0.1.0"
|
|
21
21
|
extension: "fabric-service/0.1"
|
|
22
22
|
extension-commit: "0cacd08b0e95b6abf131eae1206b60792138bc3c"
|
|
@@ -190,10 +190,14 @@ The installer, not the service, owns the plist and the descriptor. Sequence:
|
|
|
190
190
|
4. After an upgrade, `prune_releases(releases_dir, current=…)` keeps the running release
|
|
191
191
|
and the one before it.
|
|
192
192
|
|
|
193
|
+
On Linux the supervisor is a systemd user unit (`systemd_unit` + `systemd_install`), on
|
|
194
|
+
Windows a per-user Scheduled Task (`task_xml` + `task_install`); the Node kit has the same
|
|
195
|
+
writers (`systemdUnit`, `taskXml`, …) and both kits write identical files.
|
|
196
|
+
|
|
193
197
|
Code runs from an immutable release directory; an upgrade writes a new release,
|
|
194
198
|
rewrites the plist and restarts. Read [the lifecycle reference](references/lifecycle.md)
|
|
195
199
|
for the plist fields, the release layout, log rotation, behaving under a host lifecycle broker
|
|
196
|
-
and the Linux
|
|
200
|
+
and the Linux and Windows supervisors.
|
|
197
201
|
|
|
198
202
|
## Step 5 — the dashboard and the events
|
|
199
203
|
|
|
@@ -224,8 +228,12 @@ log, retention and notification policy.
|
|
|
224
228
|
|
|
225
229
|
An agent that calls paid models reports **what it spent**: one receipt per call from the
|
|
226
230
|
provider's own usage numbers, served behind the token at `surfaces.usage` as the DEC-0021 usage
|
|
227
|
-
report. An unknown cost is null, never 0.
|
|
228
|
-
before wiring it.
|
|
231
|
+
report. An unknown cost is null, never 0. It lists **every** spending limit the service applies
|
|
232
|
+
in `budgets`, enforced or not. Read [the usage reference](references/usage.md) before wiring it.
|
|
233
|
+
|
|
234
|
+
A service whose store holds what the operator decided keeps **settings backups** outside its data
|
|
235
|
+
directory, restores only on a fresh database and never overwrites. Read
|
|
236
|
+
[the settings backups reference](references/settings-backups.md).
|
|
229
237
|
|
|
230
238
|
## Step 6 — verify
|
|
231
239
|
|
|
@@ -11,6 +11,15 @@
|
|
|
11
11
|
|
|
12
12
|
The feed is a view: never copy rows into a second store that can drift from the first.
|
|
13
13
|
|
|
14
|
+
## Reading a feed (FAC-SEM-036)
|
|
15
|
+
|
|
16
|
+
A client of `GET /fabric/v1/events` — or of any token-protected route — sends the token in the
|
|
17
|
+
header the descriptor's `auth.header` names, in the form `auth.scheme` names: the raw token for
|
|
18
|
+
`none`, `Bearer <token>` for `Bearer`. Never assume `Authorization: Bearer`: a client that did
|
|
19
|
+
was refused on every poll and silently dropped every notify event. `token_header(auth, token)` /
|
|
20
|
+
`tokenHeader(auth, token)` gives the one header; the token never travels in another header or
|
|
21
|
+
in the URL.
|
|
22
|
+
|
|
14
23
|
## Writing an event
|
|
15
24
|
|
|
16
25
|
- `kind`: dotted, stable, past tense or state (`job.failed`, `job.awaiting_choice`,
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/lifecycle.md
CHANGED
|
@@ -8,23 +8,23 @@ wins and this page is the defect. The rule each section serves:
|
|
|
8
8
|
| Contract rule | Here | Kit (Python / Node) |
|
|
9
9
|
|---|---|---|
|
|
10
10
|
| LC-01 quit reaches exit within a deadline | [Shutdown](#shutdown) | `Drain` / `Drain` |
|
|
11
|
-
| LC-03 a job is bounded and exclusive | [one copy under
|
|
12
|
-
| LC-09 the footprint is declared | the descriptor and the plist; the service's own `AGENTS.md` names its label and port | `write_descriptor`, `launchd_plist` |
|
|
11
|
+
| LC-03 a job is bounded and exclusive | [one copy under a supervisor](#one-copy-under-a-supervisor) | `hold_single_instance` / `holdSingleInstance` |
|
|
12
|
+
| LC-09 the footprint is declared | the descriptor and the plist, unit or task; the service's own `AGENTS.md` names its label (unit, task) and port | `write_descriptor`, `launchd_plist`, `systemd_unit` / `systemdUnit`, `task_xml` / `taskXml` |
|
|
13
13
|
| LC-11 previous release kept, older pruned | [Install, upgrade, uninstall](#install-upgrade-uninstall) | `prune_releases` / `pruneReleases` |
|
|
14
14
|
| LC-12 every file is bounded | [Logs](#logs) | `RotatingLog`, `cap_stdout_log` / `RotatingLog`, `capStdoutLog` |
|
|
15
|
-
| LC-14 operator intent wins; uninstall is symmetric | [Install, upgrade, uninstall](#install-upgrade-uninstall) | `launchd_install`, `launchd_uninstall` (Python) |
|
|
15
|
+
| LC-14 operator intent wins; uninstall is symmetric | [Install, upgrade, uninstall](#install-upgrade-uninstall), [Linux and Windows](#linux-and-windows) | `launchd_install`, `launchd_uninstall` (Python); `systemd_install` / `systemdInstall`, `task_install` / `taskInstall` and their uninstalls |
|
|
16
16
|
| LC-15 builds clean up after themselves | release pruning runs inside the installer, not later | `prune_releases` / `pruneReleases` |
|
|
17
17
|
|
|
18
18
|
## Contents
|
|
19
19
|
|
|
20
20
|
- [launchd plist](#launchd-plist-generate-it-with-launchd_plist)
|
|
21
21
|
- [Install, upgrade, uninstall](#install-upgrade-uninstall)
|
|
22
|
-
- [One copy under
|
|
22
|
+
- [One copy under a supervisor](#one-copy-under-a-supervisor)
|
|
23
23
|
- [Directories](#directories)
|
|
24
24
|
- [Logs](#logs)
|
|
25
25
|
- [Shutdown](#shutdown)
|
|
26
26
|
- [Being managed by a host lifecycle broker](#being-managed-by-a-host-lifecycle-broker)
|
|
27
|
-
- [Linux](#linux)
|
|
27
|
+
- [Linux and Windows](#linux-and-windows)
|
|
28
28
|
|
|
29
29
|
## launchd plist (generate it with `launchd_plist`)
|
|
30
30
|
|
|
@@ -70,24 +70,25 @@ that deletes an override, and a leftover `disabled` would make a later fresh ins
|
|
|
70
70
|
off. Tests use a throwaway label or a fake `launchctl` first on `PATH` — never the
|
|
71
71
|
operator's real jobs.
|
|
72
72
|
|
|
73
|
-
## One copy under
|
|
73
|
+
## One copy under a supervisor
|
|
74
74
|
|
|
75
75
|
Started by hand, a copy that finds `service.lock` held prints one sentence naming the
|
|
76
76
|
holder and exits 75 at once. Under launchd that exit is a respawn every `ThrottleInterval`
|
|
77
|
-
(10 s), forever
|
|
78
|
-
off in-process — 0.5 s doubling to 30 s, 300 s in all — takes over if the holder leaves,
|
|
77
|
+
(10 s), forever, and under systemd one every `RestartSec`. So a copy whose plist or unit
|
|
78
|
+
carries `FABRIC_SERVICE_SUPERVISOR=launchd` (or `systemd`) backs off in-process — 0.5 s doubling to 30 s, 300 s in all — takes over if the holder leaves,
|
|
79
79
|
and exits 75 only when the wait runs out: a few starts an hour instead of 360. It touches
|
|
80
80
|
nothing while it waits. Two installs sharing one data directory are still the defect to
|
|
81
81
|
remove; the back-off only stops the loop from costing the machine.
|
|
82
82
|
|
|
83
83
|
## Directories
|
|
84
84
|
|
|
85
|
-
| What | macOS | Linux |
|
|
86
|
-
|
|
87
|
-
| data + config | `~/Library/Application Support/<id>/` | `${XDG_DATA_HOME:-~/.local/share}/<id>/` |
|
|
88
|
-
| logs | `~/Library/Logs/<id>/` | `${XDG_STATE_HOME:-~/.local/state}/<id>/logs/` |
|
|
89
|
-
| cache (deletable any time) | `~/Library/Caches/<id>/` | `${XDG_CACHE_HOME:-~/.cache}/<id>/` |
|
|
90
|
-
| code | `~/.local/share/<id>/releases/…` | same |
|
|
85
|
+
| What | macOS | Linux | Windows (DEC-0032) |
|
|
86
|
+
|---|---|---|---|
|
|
87
|
+
| data + config | `~/Library/Application Support/<id>/` | `${XDG_DATA_HOME:-~/.local/share}/<id>/` | `%LOCALAPPDATA%\<id>\` |
|
|
88
|
+
| logs | `~/Library/Logs/<id>/` | `${XDG_STATE_HOME:-~/.local/state}/<id>/logs/` | `%LOCALAPPDATA%\<id>\Logs\` |
|
|
89
|
+
| cache (deletable any time) | `~/Library/Caches/<id>/` | `${XDG_CACHE_HOME:-~/.cache}/<id>/` | `%LOCALAPPDATA%\<id>\Cache\` |
|
|
90
|
+
| code | `~/.local/share/<id>/releases/…` | same | under `%LOCALAPPDATA%` too, never a network share |
|
|
91
|
+
| settings backups ([DEC-0025](settings-backups.md)) | `~/Library/Application Support/PassionCode/settings-backups/<id>/<instance>/` | `${XDG_CONFIG_HOME:-~/.config}/PassionCode/settings-backups/…` | `%APPDATA%\PassionCode\settings-backups\…` |
|
|
91
92
|
|
|
92
93
|
A repository that versions the data itself (a registry, a plan) may hold the data; the
|
|
93
94
|
service's own code checkout and its releases may not. Directories 0700, secret files 0600. A store carries a schema version and the service
|
|
@@ -140,8 +141,58 @@ has to survive being driven from outside:
|
|
|
140
141
|
well-known document for a service, a process-level answer for a plain app — and keep it honest
|
|
141
142
|
while the app is still starting.
|
|
142
143
|
|
|
143
|
-
## Linux
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
144
|
+
## Linux and Windows
|
|
145
|
+
|
|
146
|
+
Both kits write and install the supervisor file the contract names (DEC-0032, service.md
|
|
147
|
+
*Supervisor*); the two kits write byte-identical files. A supervisor file never carries a
|
|
148
|
+
secret: the writers refuse a credential-shaped argument, environment name or value, and any
|
|
149
|
+
control character (a line break would end a unit line early).
|
|
150
|
+
|
|
151
|
+
**Linux — a systemd user unit.** `systemd_unit(unit, argv, working_directory=…)` /
|
|
152
|
+
`systemdUnit(unit, argv, {workingDirectory})` writes `Restart=always`, `RestartSec=10`,
|
|
153
|
+
`TimeoutStopSec` above the drain (15 s default), `KillMode=mixed`, `WantedBy=default.target`
|
|
154
|
+
and `FABRIC_SERVICE_SUPERVISOR=systemd`, quoting every argument (`%` → `%%`, `$` → `$$`).
|
|
155
|
+
`WorkingDirectory=` is written unquoted: systemd reads it as the rest of the line, so quotes would
|
|
156
|
+
become part of the path and the unit would be refused (`systemd-analyze verify` checks it in CI). The
|
|
157
|
+
descriptor says `"lifecycle": {"manager": "systemd", "unit": "<id>.<instance>.service"}`.
|
|
158
|
+
|
|
159
|
+
| Verb | Command |
|
|
160
|
+
|---|---|
|
|
161
|
+
| install, upgrade | `systemd_install` / `systemdInstall`: write `~/.config/systemd/user/<unit>`, `daemon-reload`, `enable`, `restart` (so the new unit is what runs). A unit already installed that `is-enabled` reports `disabled` stays off: the file is rewritten and nothing starts (LC-14), unless `force_enable` |
|
|
162
|
+
| stop, keep off | `systemctl --user disable --now <unit>` |
|
|
163
|
+
| uninstall | `systemd_uninstall` / `systemdUninstall`: `disable --now`, remove the unit, `daemon-reload` |
|
|
164
|
+
|
|
165
|
+
A user unit runs while the person is logged in; one that must run without a session needs
|
|
166
|
+
`loginctl enable-linger <user>`, which is the operator's decision, not the installer's.
|
|
167
|
+
|
|
168
|
+
**Windows — a per-user Scheduled Task.** `task_xml(task, argv, working_directory=…)` /
|
|
169
|
+
`taskXml(task, argv, {workingDirectory})` writes a task with a logon trigger for the current
|
|
170
|
+
user, `InteractiveToken` and `LeastPrivilege` (no administrator rights), restart on failure
|
|
171
|
+
every minute, no execution time limit and `MultipleInstancesPolicy` `IgnoreNew`, its settings in the
|
|
172
|
+
order `schtasks` exports them; the arguments are quoted the way the C runtime parses them. The Windows
|
|
173
|
+
CI job has the real `schtasks` create, read back and delete such a task. The descriptor says
|
|
174
|
+
`"lifecycle": {"manager": "task-scheduler", "task": "\\PassionCode\\<id>.<instance>"}`.
|
|
175
|
+
|
|
176
|
+
| Verb | Command |
|
|
177
|
+
|---|---|
|
|
178
|
+
| install, upgrade | `task_install` / `taskInstall`: `schtasks /Create /TN <task> /XML <file> /F` from a UTF-16 file it then removes, then `/End` (when it existed) and `/Run`. A task already installed and disabled stays off: it is recreated and disabled again (LC-14), unless `force_enable` |
|
|
179
|
+
| stop, keep off | `schtasks /End /TN <task>`, then `schtasks /Change /TN <task> /DISABLE` |
|
|
180
|
+
| uninstall | `task_uninstall` / `taskUninstall`: `/End`, then `/Delete /F` |
|
|
181
|
+
|
|
182
|
+
A task cannot set an environment variable, so a copy Task Scheduler restarts does not back off
|
|
183
|
+
in-process; `IgnoreNew` keeps the scheduler from starting a second copy, and a hand-started copy
|
|
184
|
+
holding the lock makes the scheduled one exit 75 once a minute until it leaves.
|
|
185
|
+
|
|
186
|
+
**One copy on each system.** The Python kit takes `flock` (Linux) or a locked byte
|
|
187
|
+
(Windows); the Node kit cannot, so it holds a name instead — an abstract unix socket on Linux,
|
|
188
|
+
a named pipe on Windows (`lock_name` / `lockName`), each gone with the process. The Python kit
|
|
189
|
+
holds the same name beside its file lock, so the two kits exclude each other everywhere, and
|
|
190
|
+
`check_service.py` reports the lock held when either is. The probe only looks (`/proc/net/unix`,
|
|
191
|
+
`WaitNamedPipeW`); it never takes the name, so it cannot make a service that is starting fail.
|
|
192
|
+
|
|
193
|
+
**Token files on Windows.** The privacy of `service.token` is its ACL (service.md *Windows
|
|
194
|
+
token files*): the kits write a protected list — the user, SYSTEM and Administrators, set before the
|
|
195
|
+
token's bytes go in, any entry already there dropped — and refuse
|
|
196
|
+
on read a link, a file outside the profile, another owner, or any other granted SID, naming the
|
|
197
|
+
SID. The Node kit reads the list with Windows PowerShell 5.1 (.NET, no modules) and writes it
|
|
198
|
+
with `icacls`; the path goes through the environment, never a command line.
|
|
@@ -31,7 +31,7 @@ File `<id>.<instance>.json`, mode 0600 (on Windows a protected ACL), atomic.
|
|
|
31
31
|
| `name`, `summary` | ≤ 80 and ≤ 200 characters |
|
|
32
32
|
| `origin` | `http://127.0.0.1:<port>`; the port is a machine-wide claim |
|
|
33
33
|
| `auth` | `tokenFile` (0600; on Windows a protected ACL — owner and every granting entry the current user, SYSTEM or Administrators: service.md *Windows token files*, DEC-0032, DEC-0033); `header` default `Authorization` with `scheme` `Bearer`; any other header carries the raw token with `scheme: "none"` |
|
|
34
|
-
| `lifecycle` | `manager` `launchd` (then `label` and `plist`), `systemd` (then `unit`, a `systemctl --user` unit), `task-scheduler` (then `task`, a per-user Scheduled Task) or `none`; `unit` and `task` are refused beside any other manager (DEC-0032).
|
|
34
|
+
| `lifecycle` | `manager` `launchd` (then `label` and `plist`), `systemd` (then `unit`, a `systemctl --user` unit), `task-scheduler` (then `task`, a per-user Scheduled Task) or `none`; `unit` and `task` are refused beside any other manager (DEC-0032). Both kits and the probe accept all four; both kits write the unit and the task (`systemd_unit`, `task_xml` / `systemdUnit`, `taskXml`) |
|
|
35
35
|
| `paths` | `data` (required), `logs[]`, optional `config`, `cache` |
|
|
36
36
|
| `commands` | only `doctor` and `update`, argument arrays whose first item is an absolute or `~/` path (Windows: also `C:\…`, `C:/…` or `~\…`, never a network share — DEC-0032) |
|
|
37
37
|
| `source.repository`, `fabricManifest` | optional |
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/settings-backups.md
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# Settings backups — what the operator decided survives the data directory (DEC-0025)
|
|
2
|
+
|
|
3
|
+
Fabric Agent Contract DEC-0025, `docs/specification/service.md` → *Settings backup*, schema
|
|
4
|
+
`schemas/settings-backup.schema.json`, rule `FAC-SEM-035`. A service whose store holds what the operator
|
|
5
|
+
decided — which projects it serves, how each is bound, which secret slot each binding uses, their overrides —
|
|
6
|
+
SHOULD keep settings backups, so a lost, purged or wrongly migrated data directory costs the operator nothing.
|
|
7
|
+
|
|
8
|
+
## Where
|
|
9
|
+
|
|
10
|
+
`<root>/settings-backups/<id>/<instance>/settings-<UTC %Y%m%dT%H%M%S%fZ>.json`, outside the data directory:
|
|
11
|
+
|
|
12
|
+
| System | `<root>` (`settings_backup_root` / `settingsBackupRoot`) |
|
|
13
|
+
|---|---|
|
|
14
|
+
| macOS | `~/Library/Application Support/PassionCode` |
|
|
15
|
+
| Windows | `%APPDATA%\PassionCode` |
|
|
16
|
+
| Linux | `$XDG_CONFIG_HOME/PassionCode`, default `~/.config/PassionCode` |
|
|
17
|
+
|
|
18
|
+
`settings_backup_dir(id, instance)` / `settingsBackupDir(id, instance)` names the folder. Directories are 0700
|
|
19
|
+
(on Windows a protected ACL), files 0600, writes atomic.
|
|
20
|
+
|
|
21
|
+
## Take a snapshot
|
|
22
|
+
|
|
23
|
+
```python
|
|
24
|
+
dirs = fs.settings_backup_dir(ID, INSTANCE)
|
|
25
|
+
if fs.backup_due(dirs): # none yet, or the newest is older than 24 hours
|
|
26
|
+
doc = fs.make_settings_backup(service_id=ID, instance=INSTANCE, version=VERSION, schema=SCHEMA_VERSION,
|
|
27
|
+
tables=allow_listed_tables(db), reason="start") # or daily, uninstall, manual
|
|
28
|
+
fs.write_settings_backup(doc, dirs) # None when unchanged; keeps the 14 newest
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
```js
|
|
32
|
+
const dir = k.settingsBackupDir(ID, INSTANCE);
|
|
33
|
+
if (k.backupDue(dir)) k.writeSettingsBackup(k.makeSettingsBackup({ id: ID, instance: INSTANCE, version, schema, tables, reason: 'daily' }), dir);
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
- **Tables on an allow-list**, each `{"columns": [...], "rows": [[...], ...]}` of JSON scalars. A fraction is its
|
|
37
|
+
decimal string (`"0.25"`), binary data is never stored, and an integer stays within ±(2^53 − 1).
|
|
38
|
+
- **Secrets by name only.** `make_settings_backup` refuses a table that can only hold connections, tokens,
|
|
39
|
+
secrets, credentials, passwords, login codes or sessions, and a column whose name says it holds a credential
|
|
40
|
+
value (`access_token`, `client_secret`); `secret_name` and `token_slot` pass. The names cannot see values, so
|
|
41
|
+
back up what is on the allow-list, never everything except a deny-list.
|
|
42
|
+
- **When:** at start when `backup_due`, then daily; on uninstall a last one, read-only (open the store without
|
|
43
|
+
migrating or writing). An unchanged snapshot is not rewritten; a temporary file an interrupted write left is
|
|
44
|
+
removed first. A fresh, empty database writes none: it would hide the good snapshot before it is restored.
|
|
45
|
+
- Both kits write byte-identical files (`test/fabric-service-backups.test.mjs`, last case).
|
|
46
|
+
|
|
47
|
+
## Read and restore
|
|
48
|
+
|
|
49
|
+
`read_settings_backup` / `readSettingsBackup` refuses a file whose checksum, counts or row widths disagree with
|
|
50
|
+
its tables, that carries a value with no canonical form, or whose `format` it does not know, naming why
|
|
51
|
+
(`FAC-SEM-035`). The checksum is SHA-256 of the settings canonical form of `tables`
|
|
52
|
+
(`settings_canonical_json` / `settingsCanonicalJson`: Python's `json.dumps(sort_keys=True,
|
|
53
|
+
ensure_ascii=False)`).
|
|
54
|
+
|
|
55
|
+
Restore **adds and never overwrites**:
|
|
56
|
+
|
|
57
|
+
```python
|
|
58
|
+
path, doc, skipped = fs.newest_intact_backup(dirs) # the newest intact snapshot with content; damaged ones named
|
|
59
|
+
plan = fs.restore_plan(doc, {"projects": {"columns": [...], "key": ["id"], "required": ["id", "name"],
|
|
60
|
+
"existing": existing_keys("projects")}})
|
|
61
|
+
# plan["tables"]["projects"]: insert, present, droppedColumns, defaultedColumns, missingRequired, refused
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
- **Automatic restore** only on a *fresh* database — no domain records **and** the service has never started —
|
|
65
|
+
so a restart never undoes a deletion. Insert `plan[...]["insert"]` and add every row the database refuses to
|
|
66
|
+
`refused` with its key and reason.
|
|
67
|
+
- **Manual restore** shows the plan first; applying it is the operator's act, never an agent's.
|
|
@@ -50,6 +50,29 @@ left out.
|
|
|
50
50
|
**Budget.** Pass `budget` only if the service **enforces** that limit itself. The report states
|
|
51
51
|
it, and the host only shows it.
|
|
52
52
|
|
|
53
|
+
**Every limit (DEC-0027).** Pass `budgets` to list every spending limit the service applies,
|
|
54
|
+
enforced or not, so the spend view shows all of them — the operator asked for that on 2026-10-07:
|
|
55
|
+
|
|
56
|
+
```python
|
|
57
|
+
ledger.report(service_id=ID, budget={"period": "month", "limitUsd": 100}, budgets=[
|
|
58
|
+
{"id": "machine.monthly", "scope": "machine", "kind": "monthly", "period": "month", "limitUsd": 100, "enforced": True},
|
|
59
|
+
{"id": "project.demo.daily", "scope": "project", "subject": "demo", "kind": "daily", "windowSeconds": 86400,
|
|
60
|
+
"limitUsd": 5, "spentUsd": 0.31, "enforced": True},
|
|
61
|
+
{"id": "job.cap", "scope": "job", "kind": "per_job", "limitUsd": 2, "enforced": True},
|
|
62
|
+
])
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
- A machine limit with a `period` and no `spentUsd` is counted by the kit from the receipts, the
|
|
66
|
+
way `budget` is, so `budget` and its twin in `budgets` never disagree. A per-order limit
|
|
67
|
+
(`per_job`, `approval`) gets `spentUsd: null`. Every other limit states its own spend.
|
|
68
|
+
- `scope` is `machine`, `project`, `pool` or `job`; a `project` or `pool` limit names its
|
|
69
|
+
`subject`. A limit has at most one window — `period`, `windowSeconds` or `since` — and it agrees
|
|
70
|
+
with the kind (`daily`: the day or 86400 s; `monthly`: the month or 28–31 days; `velocity`: a
|
|
71
|
+
rolling window). `tripped: true` (work stopped by it now) needs `enforced: true`. An unknown
|
|
72
|
+
kind is shown generically, never refused.
|
|
73
|
+
- The kits refuse a report that breaks these (`usage_limit_problems` / `usageLimitProblems`,
|
|
74
|
+
`FAC-SEM-031`). A breach is not refused: the report shows it, and the host shows it first.
|
|
75
|
+
|
|
53
76
|
Call `ledger.prune()` at start (or daily) to keep the file at the reported window (LC-12).
|
|
54
77
|
|
|
55
78
|
`JsonlUsageLedger` appends 0600 JSON lines. A line torn by a killed writer is skipped, and the
|
package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/check_service.py
CHANGED
|
@@ -162,10 +162,9 @@ class Probe:
|
|
|
162
162
|
conn.close()
|
|
163
163
|
|
|
164
164
|
def auth_headers(self) -> Dict[str, str]:
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
return {header: ("Bearer " + value) if auth.get("scheme", "Bearer") == "Bearer" else value}
|
|
165
|
+
# The header and form the descriptor declares, never an assumed `Authorization: Bearer` (FAC-SEM-036).
|
|
166
|
+
name, value = fs.token_header(self.d.get("auth"), self.token or "")
|
|
167
|
+
return {name: value}
|
|
169
168
|
|
|
170
169
|
# descriptor ------------------------------------------------------------
|
|
171
170
|
def descriptor_rules(self) -> None:
|
|
@@ -730,7 +729,7 @@ class Probe:
|
|
|
730
729
|
self.add("lifecycle.instance-lock", "PASS", "held by the running service")
|
|
731
730
|
else:
|
|
732
731
|
msvcrt.locking(fd, msvcrt.LK_UNLCK, 1)
|
|
733
|
-
self.
|
|
732
|
+
self.lock_free(lock)
|
|
734
733
|
finally:
|
|
735
734
|
os.close(fd)
|
|
736
735
|
return
|
|
@@ -747,10 +746,17 @@ class Probe:
|
|
|
747
746
|
self.add("lifecycle.instance-lock", "PASS" if held else "FAIL", "held by the running service" if held else str(exc))
|
|
748
747
|
else:
|
|
749
748
|
fcntl.flock(fd, fcntl.LOCK_UN)
|
|
750
|
-
self.
|
|
749
|
+
self.lock_free(lock)
|
|
751
750
|
finally:
|
|
752
751
|
os.close(fd)
|
|
753
752
|
|
|
753
|
+
def lock_free(self, lock: Path) -> None:
|
|
754
|
+
"""The file lock is free; a Node kit service holds the lock's name instead (fs.lock_name)."""
|
|
755
|
+
if fs.lock_name_held(lock):
|
|
756
|
+
self.add("lifecycle.instance-lock", "PASS", "held by the running service (the lock's name, %s)" % ("a named pipe" if fs.WINDOWS else "an abstract socket"))
|
|
757
|
+
else:
|
|
758
|
+
self.add("lifecycle.instance-lock", "FAIL", "%s exists but nobody holds it" % lock)
|
|
759
|
+
|
|
754
760
|
def run(self) -> List[Result]:
|
|
755
761
|
self.descriptor_rules()
|
|
756
762
|
if not self.port:
|