@passioncode-ai/fabric-agent-adapter 0.44.0 → 0.44.1

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 (18) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/package.json +1 -1
  3. package/plugins/fabric-agent-adapter/.claude-plugin/plugin.json +1 -1
  4. package/plugins/fabric-agent-adapter/skills/adapting-projects-to-fabric/SKILL.md +1 -1
  5. package/plugins/fabric-agent-adapter/skills/building-fabric-services/SKILL.md +1 -1
  6. package/plugins/fabric-agent-adapter/skills/building-fabric-services/references/kit-modules.md +6 -3
  7. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop-rules.mjs +264 -0
  8. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-interop.mjs +31 -254
  9. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-package-schema.mjs +100 -0
  10. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric-package.mjs +2 -89
  11. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop.py +54 -369
  12. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_interop_rules.py +390 -0
  13. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_package.py +20 -187
  14. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/fabric_package_schema.py +207 -0
  15. package/plugins/fabric-agent-adapter/skills/building-fabric-services/scripts/kit.json +132 -14
  16. package/plugins/fabric-agent-adapter/skills/creating-fabric-agents/SKILL.md +1 -1
  17. package/plugins/fabric-agent-adapter/skills/working-with-fabric/SKILL.md +1 -1
  18. package/plugins/fabric-agent-adapter/skills/working-with-fabric/scripts/fabric_session_env.py +1 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,22 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.44.1 (2026-10-22)
4
+
5
+ ### Changed
6
+
7
+ - **Two kit modules split by responsibility** (FAA-92). `fabric_interop` and `fabric_package` stood at the 1000-line
8
+ ceiling `test_fabric_kit` enforces. The rules of `fabric-interop/0.1` that need no running service (constants,
9
+ `InteropError`, `UnknownJob`, trace context, the job limits of DEC-0087) moved to `fabric_interop_rules` /
10
+ `fabric-interop-rules.mjs` (L3), and the reading of `fabric-package.schema.json` to `fabric_package_schema` /
11
+ `fabric-package-schema.mjs` (L2), which now carries that schema as its data. `fabric_interop` and `fabric_package`
12
+ re-export every name they had, in both languages, so no caller changes; a service that copies the kit by hand
13
+ copies the two new files too (`fabric_kit.py install` and `status` name them). No behaviour changed.
14
+
15
+ ### Fixed
16
+
17
+ - A board test (`test_a_revision_conflict_on_renew_reads_again_and_keeps_the_attempt`) read the call order before its
18
+ handler's thread had started its effect, and failed under load. It now waits for the effect to start.
19
+
3
20
  ## 0.44.0 (2026-10-22)
4
21
 
5
22
  ### Added
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@passioncode-ai/fabric-agent-adapter",
3
- "version": "0.44.0",
3
+ "version": "0.44.1",
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; and teach a coding agent to work in a project through Fabric (fabric-mcp, fabric_whoami, claim and release), with Claude Code session hooks.",
6
- "version": "0.44.0",
6
+ "version": "0.44.1",
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.44.0"
8
+ version: "0.44.1"
9
9
  contract-version: "0.1.0"
10
10
  contract-commit: "bfa9a67aa3c6cb808c093e7d83cd59799f1ee7d0"
11
11
  ---
@@ -14,7 +14,7 @@ license: AGPL-3.0-only OR LicenseRef-PassionCode-Commercial
14
14
  compatibility: Python 3.9+ or Node.js 20+ for the kits (Node.js 22+ for a Linux service's instance lock); 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.
15
15
  metadata:
16
16
  author: PassionCode.ai
17
- version: "0.44.0"
17
+ version: "0.44.1"
18
18
  contract-version: "0.1.0"
19
19
  extension: "fabric-service/0.1"
20
20
  extension-commit: "bfa9a67aa3c6cb808c093e7d83cd59799f1ee7d0"
@@ -58,7 +58,8 @@ A module imports only modules listed before it in `kit.json`, so the kit has no
58
58
  | L2 | `fabric_delegation` | the callee's side of `fabric-delegation/0.1` (DEC-0039) |
59
59
  | L2 | `fabric_share` | share grants and registrations (DEC-0086) |
60
60
  | L2 | `fabric_ceremonies` | the headless join token (DEC-0084) |
61
- | L2 | `fabric_package` | `fabric-package/1`: pack, sign, verify (DEC-0050, DEC-0051) |
61
+ | L2 | `fabric_package_schema` | the reading of `fabric-package.schema.json` with JavaScript's pattern and format semantics (FAA-92) |
62
+ | L2 | `fabric_package` | `fabric-package/1`: pack, sign, verify; re-exports `schema_problems` (DEC-0050, DEC-0051) |
62
63
  | L3 | `fabric_base` | the protocol name, `ServiceError`, small shared helpers |
63
64
  | L3 | `fabric_platform` | Windows: named pipes, owner-only files (DEC-0032) |
64
65
  | L3 | `fabric_paths` | the services directory, a service's folders, atomic and private writes |
@@ -74,7 +75,8 @@ A module imports only modules listed before it in `kit.json`, so the kit has no
74
75
  | L3 | `fabric_supervisors` | launchd, systemd user units, Task Scheduler (DEC-0032, LC-14) |
75
76
  | L3 | `fabric_service` | the facade: every name 0.39.1 exported, re-exported from the modules above |
76
77
  | L3 | `fabric_profiles` | runner and model profiles ([profiles](profiles.md)) |
77
- | L3 | `fabric_interop` | `fabric-interop/0.1`: jobs, envelopes, MCP tools ([interop](interop.md)) |
78
+ | L3 | `fabric_interop_rules` | `fabric-interop/0.1` rules without a service: constants, `InteropError`, trace context, job limits (DEC-0087; FAA-92) |
79
+ | L3 | `fabric_interop` | `fabric-interop/0.1`: jobs, envelopes, MCP tools; re-exports the rule layer ([interop](interop.md)) |
78
80
  | L4 | `fabric_hub` | the Fabric hub's door (`fabric-hub/0.1`) |
79
81
  | L4 | `fabric_board_shapes` | the project board's wire: constants, refusal codes, shapes |
80
82
  | L4 | `fabric_board_link` | the board's link: the MCP session over the hub, the fsynced checkpoint, a claimed request as data |
@@ -94,7 +96,8 @@ the kit live in `../recipes/` ([the support agent](../recipes/support-agent/READ
94
96
  public name 0.39.1 had, in both languages (the snapshot is `test/fixtures/kit-api/kit-api-0.39.1.json`; a missing
95
97
  name fails `test_kit_api`), and the Python facade the private names other code reached through it (`_number`,
96
98
  `_DAY_MS`, `_utc_date_ms` from the core). `fabric_board` and `fabric-board.mjs` are
97
- facades the same way. What changed for a service that copies the kit: `fabric_service.py` alone is no longer enough;
99
+ facades the same way, and since 0.44.1 so are `fabric_interop` (over `fabric_interop_rules`) and `fabric_package` (over
100
+ `fabric_package_schema`), with their Node twins: patch the rule or schema module, not the facade. What changed for a service that copies the kit: `fabric_service.py` alone is no longer enough;
98
101
  install with `fabric_kit.py install` and `status` names what is missing. A test or service that replaced a function
99
102
  by assigning it on the facade (`fabric_service.X = ...`) no longer changes what the kit does: the code reads its own
100
103
  module's name, so patch the module that defines it (`fabric_lock.X`, `fabric_session_env.X`). A service that imported a module-private
@@ -0,0 +1,264 @@
1
+ // fabric-interop/0.1, the rule layer: the extension's names and limits, its errors, the trace context (C3.4), a usage
2
+ // object's reading (C3.2) and the job limits and uncertain effects (DEC-0087). Every function answers by a rule and
3
+ // touches no file; fabric-interop.mjs builds the tools, the envelope and the job store on it and re-exports every public
4
+ // name here. Part of the Node service kit (FAA-92, split from fabric-interop.mjs; layer L3); the twin of
5
+ // fabric_interop_rules.py. Node 20+, no dependency.
6
+ import crypto from 'node:crypto';
7
+ import { ServiceError } from './fabric-base.mjs';
8
+ import { jcs } from './fabric-delegation.mjs';
9
+ import { isString } from './fabric-jsvalue.mjs'; // the core's (FAA-65)
10
+ import { isObject } from './fabric-jsvalue.mjs'; // the core's (FAA-65)
11
+
12
+ export const PROTOCOL = 'fabric-interop/0.1';
13
+ export const EXTENSION_KEY = 'https://fabric.passioncode.ai/agent-contract/extensions/interop/0.1';
14
+ export const MCP_REVISION = '2026-07-28';
15
+ export const TERMINAL = ['completed', 'failed', 'cancelled'];
16
+ export const CONTRACT_VERSION = '0.1.0';
17
+ export const OUTCOMES = ['succeeded', 'partial', 'failed', 'cancelled', 'blocked'];
18
+ export const ENVELOPE_REQUIRED = ['id', 'contractVersion', 'outcome', 'done', 'proof', 'scope', 'notVerified', 'artifacts', 'createdAt', 'producer', 'output', 'usage'];
19
+ // The job handle inline, as a job tool's outputSchema carries it (contract interop-job-handle.schema.json).
20
+ export const JOB_HANDLE_SCHEMA = {
21
+ type: 'object', required: ['job'], additionalProperties: false,
22
+ properties: { job: { type: 'object', required: ['id', 'status'], additionalProperties: false,
23
+ properties: { id: { type: 'string', minLength: 1, maxLength: 128, pattern: '^[A-Za-z0-9._:-]+$' }, status: { const: 'working' } } } },
24
+ };
25
+
26
+ export const TRACEPARENT = /^([0-9a-f]{2})-([0-9a-f]{32})-([0-9a-f]{16})-([0-9a-f]{2})$/;
27
+ export const JOB_ID = /^[A-Za-z0-9._:-]{1,128}$/;
28
+ export const KEY = /^[A-Za-z0-9_.-]{1,64}$/;
29
+ export const SECRET_WORDS = /pass(word|phrase)|secret|api[\s_-]?key|access[\s_-]?key|private[\s_-]?key|(access|auth|bearer|refresh|session)[\s_-]?token|^token$|credential/i;
30
+ export const USAGE_KEYS = ['inputTokens', 'outputTokens', 'cacheReadTokens', 'cacheWriteTokens', 'costUsd', 'wallMs'];
31
+
32
+ export class InteropError extends ServiceError {}
33
+ export class UnknownJob extends InteropError {
34
+ constructor(jobId) { super(`unknown-job: no job ${jobId} here.`); this.jobId = jobId; }
35
+ }
36
+
37
+ // --- C3.4 trace context ---------------------------------------------------------------
38
+ export function parseTraceparent(value) {
39
+ const m = typeof value === 'string' ? TRACEPARENT.exec(value) : null;
40
+ if (!m) return null;
41
+ const [, version, traceId, spanId, flags] = m;
42
+ if (version === 'ff' || /^0+$/.test(traceId) || /^0+$/.test(spanId)) return null;
43
+ return { version, traceId, spanId, flags };
44
+ }
45
+
46
+ export const spanIdNew = () => { for (;;) { const v = crypto.randomBytes(8).toString('hex'); if (!/^0+$/.test(v)) return v; } };
47
+
48
+ export function childTraceparent(parent) {
49
+ const p = parseTraceparent(parent);
50
+ return `00-${p ? p.traceId : crypto.randomBytes(16).toString('hex')}-${spanIdNew()}-${p ? p.flags : '01'}`;
51
+ }
52
+
53
+ export const traceIds = (traceparent) => {
54
+ const p = parseTraceparent(traceparent);
55
+ return p ? { traceId: p.traceId, spanId: p.spanId } : {};
56
+ };
57
+
58
+ export function checkUsage(usage, nulls = false) {
59
+ if (!usage || typeof usage !== 'object') throw new InteropError('usage must be an object.');
60
+ for (const key of ['inputTokens', 'outputTokens', 'wallMs']) if (!(key in usage)) throw new InteropError(`usage.${key} is required.`);
61
+ for (const [key, value] of Object.entries(usage)) {
62
+ if (nulls && key === 'partial') {
63
+ if (typeof value !== 'boolean') throw new InteropError('usage.partial must be a boolean.');
64
+ continue;
65
+ }
66
+ if (!USAGE_KEYS.includes(key)) throw new InteropError(`usage.${key} is not a usage field.`);
67
+ if (nulls && value === null) continue;
68
+ const ok = typeof value === 'number' && value >= 0 && (key === 'costUsd' || Number.isInteger(value));
69
+ if (!ok) throw new InteropError(`usage.${key} must be a non-negative ${key === 'costUsd' ? 'number' : 'integer'}.`);
70
+ }
71
+ if (nulls && usageFindings(usage, '').length) throw new InteropError('usage.partial is false while a number is null: an unknown number makes the usage partial (FAC-SEM-119).');
72
+ return { ...usage };
73
+ }
74
+ // --- C3.2 limits and uncertain effects (DEC-0087) ------------------------------------------
75
+ // #region job-limits-kit-node, docs: plugins/fabric-agent-adapter/skills/building-fabric-services/references/interop.md#limits-and-uncertain-effects
76
+ // The contract's src/job-limits.ts, answer for answer (test/fixtures/contract/job-limit-vectors.json), the twin of
77
+ // fabric_interop.py. Every function here answers and never throws on malformed input.
78
+
79
+ export const QUOTA_EXHAUSTED = 'quota-exhausted';
80
+ export const OUTCOME_UNKNOWN = 'outcome-unknown';
81
+ export const LIMIT_REACHED = 'limit-reached';
82
+ export const INTEROP_NULL_USAGE = 'fabric-interop/0.2';
83
+ /** The revisions this kit reads, named in a request's `_meta.protocols` (DEC-0067, DEC-0087). */
84
+ export const PROTOCOLS_READ = Object.freeze(['fabric-interop/0.1', INTEROP_NULL_USAGE]);
85
+ export const JOB_STATUSES = ['working', 'input_required', 'completed', 'failed', 'cancelled'];
86
+ export const UTC_INSTANT = /^([0-9]{4})-([0-9]{2})-([0-9]{2})T([0-9]{2}):([0-9]{2}):([0-5][0-9])(?:\.([0-9]{1,3}))?Z$/;
87
+ export const PAUSE = /^quota-exhausted(?: until ([^ \t\n\r]+?))?(?:: ([ \t\n\r]*[^ \t\n\r][\s\S]*))?$/;
88
+ export const ERROR_CODE = /^[a-z][a-z0-9-]{1,63}$/;
89
+ export const MAX_STATUS_MESSAGE = 500;
90
+ export const MAX_ERROR_MESSAGE = 2000;
91
+ export const codePoints = (value) => [...value].length;
92
+ export const own = (value, key) => (isObject(value) && Object.hasOwn(value, key) ? value[key] : undefined);
93
+
94
+ /** A UTC instant as milliseconds, or NaN: the pattern and a day that exists, as the contract's strictTime reads it. */
95
+ export function utcTime(value) {
96
+ const m = isString(value) ? UTC_INSTANT.exec(value) : null;
97
+ if (!m) return Number.NaN;
98
+ const [year, month, day, hour, minute, second] = m.slice(1, 7).map(Number);
99
+ const date = new Date(0);
100
+ date.setUTCFullYear(year, month - 1, day);
101
+ if (date.getUTCMonth() !== month - 1 || date.getUTCDate() !== day || hour > 23 || minute > 59) return Number.NaN;
102
+ date.setUTCHours(hour, minute, second, Number((m[7] ?? '').padEnd(3, '0')));
103
+ return date.getTime();
104
+ }
105
+
106
+ export function quotaDataOk(data) {
107
+ const retryable = own(data, 'retryable');
108
+ if (!isObject(data) || typeof retryable !== 'boolean') return false;
109
+ if (Object.keys(data).some((name) => name !== 'retryable' && name !== 'resetsAt')) return false;
110
+ if (!Object.hasOwn(data, 'resetsAt')) return true;
111
+ return retryable && Number.isFinite(utcTime(own(data, 'resetsAt')));
112
+ }
113
+
114
+ export function jobOf(answer) {
115
+ const job = own(answer, 'job');
116
+ const id = own(job, 'id');
117
+ if (!isObject(job) || !isString(id) || id.length === 0 || !JOB_STATUSES.includes(own(job, 'status'))) return undefined;
118
+ return job;
119
+ }
120
+
121
+ export function readJobPause(answer) {
122
+ const job = jobOf(answer);
123
+ const message = own(job, 'statusMessage');
124
+ if (!job || own(job, 'status') !== 'working' || !isString(message) || !message.startsWith(QUOTA_EXHAUSTED)) return { paused: false };
125
+ const match = PAUSE.exec(message);
126
+ if (!match || codePoints(message) > MAX_STATUS_MESSAGE) return { paused: false, malformed: true };
127
+ const until = match[1];
128
+ if (until === undefined) return { paused: true };
129
+ return Number.isFinite(utcTime(until)) ? { paused: true, resetsAt: until } : { paused: false, malformed: true };
130
+ }
131
+
132
+ /** What a caller does with one fabric.job.get or fabric.job.cancel answer at `now`; never "submit anew". */
133
+ export function jobCallerAction(answer, now) {
134
+ const at = utcTime(now);
135
+ const job = jobOf(answer);
136
+ if (!Number.isFinite(at) || !job) return { action: 'invalid' };
137
+ const status = own(job, 'status');
138
+ if (status === 'working') {
139
+ const pause = readJobPause(answer);
140
+ if ('malformed' in pause) return { action: 'invalid' };
141
+ if (!pause.paused) return { action: 'poll' };
142
+ if (pause.resetsAt === undefined) return { action: 'wait' };
143
+ return utcTime(pause.resetsAt) > at ? { action: 'wait', notBefore: pause.resetsAt } : { action: 'poll' };
144
+ }
145
+ if (status === 'input_required') return { action: 'answer' };
146
+ if (status === 'completed') return { action: 'done' };
147
+ if (status === 'cancelled') return { action: 'cancelled' };
148
+ const error = own(job, 'error');
149
+ const code = own(error, 'code');
150
+ const message = own(error, 'message');
151
+ const codeOk = (isString(code) && ERROR_CODE.test(code)) || Number.isInteger(code);
152
+ const shapeOk = isObject(error) && Object.keys(error).every((name) => name === 'code' || name === 'message' || name === 'data');
153
+ if (!shapeOk || !codeOk || !isString(message) || message.length === 0 || codePoints(message) > MAX_ERROR_MESSAGE) return { action: 'invalid' };
154
+ if (code === OUTCOME_UNKNOWN) return { action: 'reconcile' };
155
+ if (code === LIMIT_REACHED) return { action: 'person' };
156
+ if (code !== QUOTA_EXHAUSTED) return { action: 'failed' };
157
+ const data = own(error, 'data');
158
+ if (!quotaDataOk(data)) return { action: 'invalid' };
159
+ if (!data.retryable) return { action: 'person' };
160
+ return data.resetsAt !== undefined && utcTime(data.resetsAt) > at ? { action: 'resend', notBefore: data.resetsAt } : { action: 'resend' };
161
+ }
162
+
163
+ /** The idempotency key of an intent {agentId, capability, input, request?}: a UUID of version 8 over its RFC 8785 form. */
164
+ export function intentIdempotencyKey(intent) {
165
+ const names = ['agentId', 'capability', 'input', 'request'];
166
+ if (!isObject(intent) || Object.keys(intent).some((name) => !names.includes(name))) return undefined;
167
+ const [agentId, capability, input, request] = names.map((name) => own(intent, name));
168
+ if (!isString(agentId) || agentId.length === 0 || !isString(capability) || capability.length === 0 || !isObject(input)) return undefined;
169
+ if (Object.hasOwn(intent, 'request') && (!isString(request) || request.length === 0 || codePoints(request) > 256)) return undefined;
170
+ let form;
171
+ try {
172
+ form = jcs(Object.hasOwn(intent, 'request') ? { agentId, capability, input, request } : { agentId, capability, input });
173
+ } catch {
174
+ return undefined;
175
+ }
176
+ const bytes = crypto.createHash('sha256').update(form, 'utf8').digest().subarray(0, 16);
177
+ bytes[6] = (bytes[6] & 0x0f) | 0x80;
178
+ bytes[8] = (bytes[8] & 0x3f) | 0x80;
179
+ const hex = bytes.toString('hex');
180
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
181
+ }
182
+
183
+ export function readsNullUsage(requestMeta) {
184
+ const protocols = own(requestMeta, 'protocols');
185
+ return Array.isArray(protocols) && protocols.includes(INTEROP_NULL_USAGE);
186
+ }
187
+
188
+ /** A request's `_meta` from this kit as a caller: its trace, and the interop revisions it reads (DEC-0087). */
189
+ export function requestMeta(traceparent) {
190
+ const meta = { protocols: [...PROTOCOLS_READ] };
191
+ if (parseTraceparent(traceparent)) meta.traceparent = traceparent;
192
+ return meta;
193
+ }
194
+
195
+ /** FAC-SEM-119 on a usage: partial false asserts every number known, so it stands beside no null. */
196
+ export function usageFindings(usage, at) {
197
+ if (!isObject(usage) || usage.partial !== false) return [];
198
+ const unknown = USAGE_KEYS.filter((name) => Object.hasOwn(usage, name) && usage[name] === null);
199
+ if (unknown.length === 0) return [];
200
+ return [{ code: 'FAC-SEM-119', instancePath: `${at}/partial`, message: `partial is false while ${unknown.join(', ')} is null: an unknown number makes the usage partial` }];
201
+ }
202
+
203
+ /** The usage a provider writes toward a caller: null and partial toward a 0.2 reader, the measured lower bound otherwise. */
204
+ export function usageToward(usage, meta) {
205
+ if (readsNullUsage(meta)) {
206
+ const checked = checkUsage(usage, true);
207
+ if (USAGE_KEYS.some((name) => checked[name] === null) && !('partial' in checked)) checked.partial = true;
208
+ return checked;
209
+ }
210
+ if (!isObject(usage)) throw new InteropError('usage must be an object.');
211
+ return checkUsage(Object.fromEntries(Object.entries(usage).filter(([key]) => key !== 'partial').map(([key, value]) => [key, value === null ? 0 : value])));
212
+ }
213
+
214
+ export function jobFindings(value) {
215
+ const findings = [];
216
+ if ('malformed' in readJobPause(value)) {
217
+ findings.push({ code: 'FAC-SEM-119', instancePath: '/job/statusMessage', message: "a working job's message that starts quota-exhausted reads `quota-exhausted`, `quota-exhausted until <UTC instant>`, then optionally `: <text>`, at most 500 characters" });
218
+ }
219
+ const error = own(own(value, 'job'), 'error');
220
+ const data = own(error, 'data');
221
+ if (own(error, 'code') === QUOTA_EXHAUSTED && isString(own(data, 'resetsAt')) && !Number.isFinite(utcTime(own(data, 'resetsAt')))) {
222
+ findings.push({ code: 'FAC-SEM-119', instancePath: '/job/error/data/resetsAt', message: 'resetsAt is a UTC instant on a day that exists' });
223
+ }
224
+ findings.push(...usageFindings(own(own(own(value, 'job'), 'result'), 'usage'), '/job/result/usage'));
225
+ return findings;
226
+ }
227
+
228
+ export const sameJson = (a, b) => JSON.stringify(a) === JSON.stringify(b);
229
+
230
+ /** FAC-SEM-119 (kinds interop-job, usage and result) and FAC-SEM-120 (kind job-caller); undefined for another kind. */
231
+ export function jobLimitRules(kind, value) {
232
+ if (kind === 'interop-job') return jobFindings(value);
233
+ if (kind === 'usage') return usageFindings(value, '');
234
+ if (kind === 'result') return usageFindings(own(value, 'usage'), '/usage');
235
+ if (kind === 'job-caller') {
236
+ const expected = jobCallerAction(own(value, 'answer'), own(value, 'now'));
237
+ return sameJson(own(value, 'action'), expected) ? [] : [{ code: 'FAC-SEM-120', instancePath: '/action', message: `the caller answers ${JSON.stringify(expected)} as jobCallerAction does` }];
238
+ }
239
+ return undefined;
240
+ }
241
+
242
+ /** A paused job's statusMessage in the pause grammar; throws InteropError for one a reader would call malformed. */
243
+ export function pauseMessage({ resetsAt, text } = {}) {
244
+ const message = `${QUOTA_EXHAUSTED}${resetsAt === undefined ? '' : ` until ${resetsAt}`}${text === undefined ? '' : `: ${text}`}`;
245
+ if ('malformed' in readJobPause({ job: { id: 'x', status: 'working', statusMessage: message } })) {
246
+ throw new InteropError('A pause reads quota-exhausted [until <UTC instant, Z, at most 3 fraction digits>][: <text>], at most 500 characters.');
247
+ }
248
+ return message;
249
+ }
250
+
251
+ /** The provider's answer to a repeated key: 'run' (the intent runs again, once) or 'same' (answer with that job). */
252
+ export function repeatedKeyDecision(job, now, { limitHolds, approvalGranted = false } = {}) {
253
+ if (!isObject(job) || job.status !== 'failed') return 'same';
254
+ const error = own(job, 'error');
255
+ const code = own(error, 'code');
256
+ if (code === LIMIT_REACHED) return approvalGranted === true ? 'run' : 'same';
257
+ if (code !== QUOTA_EXHAUSTED) return 'same';
258
+ if (limitHolds !== undefined && limitHolds !== null) return limitHolds === false ? 'run' : 'same';
259
+ const data = own(error, 'data');
260
+ const at = utcTime(now);
261
+ if (!quotaDataOk(data) || !data.retryable || !Number.isFinite(at)) return 'same';
262
+ return data.resetsAt === undefined || utcTime(data.resetsAt) <= at ? 'run' : 'same';
263
+ }
264
+ // #endregion job-limits-kit-node