iit-mcp-autotask 0.7.1 → 0.8.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/README.md +46 -27
- package/dist/main.js +13 -0
- package/dist/tools/catalog-data.d.ts +1 -1
- package/dist/tools/catalog-data.js +1 -1
- package/dist/tools/server.d.ts +2 -2
- package/dist/tools/server.js +1 -1
- package/dist/tools/write.js +83 -29
- package/dist/write/store.d.ts +49 -18
- package/dist/write/store.js +991 -424
- package/package.json +19 -18
- package/server.json +4 -4
package/README.md
CHANGED
|
@@ -8,10 +8,10 @@ context into one call, a generic read layer for the long tail, one result envelo
|
|
|
8
8
|
packaged playbooks, strong security boundaries, and predictable per-tenant in-process state (pooled
|
|
9
9
|
client + SDK-owned TTL caches).
|
|
10
10
|
|
|
11
|
-
> **Package status (2026-10-
|
|
12
|
-
> at `0.
|
|
11
|
+
> **Package status (2026-10-19):** the runtime dependency `node-autotask` is published to public npm
|
|
12
|
+
> at `0.19.0`; the committed `pnpm-lock.yaml` pins that
|
|
13
13
|
> release with its integrity hash, so installs resolve deterministically. The dependency is always npm
|
|
14
|
-
> (node-autotask pinned exactly at `0.
|
|
14
|
+
> (node-autotask pinned exactly at `0.19.0`) — never a `file:`, `git:`, `link:` or workspace reference
|
|
15
15
|
> to a local checkout.
|
|
16
16
|
|
|
17
17
|
> **0.6.0 is a breaking release** (unreleased): tool names, result shapes and configuration all
|
|
@@ -120,7 +120,7 @@ An operator deployment needs:
|
|
|
120
120
|
|----------|---------|
|
|
121
121
|
| `AUTOTASK_PROFILE=operator` (or an operator token) | the profile |
|
|
122
122
|
| `AUTOTASK_POLICY_FILE` | the write policy: a closed JSON schema, default deny ([example](examples/policy.time-entries.json)) |
|
|
123
|
-
| `AUTOTASK_STATE_DIR` | mode 0700; holds encrypted plans, the idempotency ledger, the hash-chained audit log
|
|
123
|
+
| `AUTOTASK_STATE_DIR` | mode 0700; holds encrypted plans, the idempotency ledger, the hash-chained audit log, the primary lock and the holder registry (many processes may share one dir — see below) |
|
|
124
124
|
| `AUTOTASK_RUN_SCOPE` | stdio: `{"companyIds":[...]}` or `{"all":true}` (the policy's own scope); it can only narrow the policy. On HTTP each operator token carries `runScope` instead |
|
|
125
125
|
| `AUTOTASK_PRINCIPAL` | stdio: the name plans and the audit log are bound to (default `stdio`) |
|
|
126
126
|
| `AUTOTASK_KILL_FILE` | optional: while this file exists every preview and apply is denied (default `<state dir>/KILL`) |
|
|
@@ -159,19 +159,25 @@ the same key only replays `unknown`. Re-read the target; if the write is absent,
|
|
|
159
159
|
a new `idempotency_key` (a new attempt) after the 10-minute cooldown; its duplicate query refuses
|
|
160
160
|
it if the first write did land. Payload field names are matched case-insensitively, and keys that
|
|
161
161
|
differ only by case, or are not plain ASCII field names, are refused. `STATE_UNAVAILABLE` means the
|
|
162
|
-
state dir could not be checked (`retry`) or the server
|
|
162
|
+
state dir could not be checked (`retry`) or the server's own registration in the state dir was removed (`stop`; see below). Use a separate Autotask API user for operator deployments, and run
|
|
163
163
|
the live write leg on throwaway data before relying on it (B7 in the 0.6.0 plan).
|
|
164
164
|
|
|
165
165
|
#### The state lock: recovery
|
|
166
166
|
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
167
|
+
Many processes may share a state dir (issue #68): every process registers its boot in a holder
|
|
168
|
+
registry (`<state dir>/holders/<boot>` plus a 5 s lease) before any other state work, and a
|
|
169
|
+
per-operation lock (`<state dir>/op.lock`) serialises the short shared-state critical sections —
|
|
170
|
+
the boot ledger rewrite, an apply's claim (the fresh ledger scan + the write-ahead `sending`
|
|
171
|
+
line), the apply's result append and every audit append. The dir still names ONE primary in
|
|
172
|
+
`<state dir>/lock` (host, pid and a per-boot id): a boot that finds a LIVE primary (same host,
|
|
173
|
+
a fresh lease) no longer refuses — it boots as a SECONDARY and shares the dir under the op lock.
|
|
170
174
|
|
|
171
|
-
The
|
|
172
|
-
than 15 s (3 refresh cycles) is expired.
|
|
175
|
+
The primary keeps the #55 recovery machinery unchanged. The primary refreshes
|
|
176
|
+
`<state dir>/lock.lease` every 5 s, and a lease older than 15 s (3 refresh cycles) is expired.
|
|
177
|
+
The succession rules:
|
|
173
178
|
|
|
174
|
-
- A live
|
|
179
|
+
- A live primary is never taken: a FRESH lease vetoes any takeover, whatever the pid probe says.
|
|
180
|
+
A secondary boot under a live primary simply registers and shares; it never touches the lock.
|
|
175
181
|
- After a crash, a SIGKILL or an OOM kill the next boot takes the lock over by itself when the
|
|
176
182
|
old holder is gone, including a container where node is PID 1 again (the boot id tells the runs
|
|
177
183
|
apart).
|
|
@@ -182,16 +188,16 @@ than 15 s (3 refresh cycles) is expired. The takeover rules:
|
|
|
182
188
|
its own. With a new hostname (the docker default: the container id) the R6 host refusal applies
|
|
183
189
|
and the operator deletes `<state dir>/lock` (the case below). Every takeover is appended to the
|
|
184
190
|
`audit.jsonl` hash chain as a `lock_takeover` line, and `/ready` reports the lock's age, holder
|
|
185
|
-
id and
|
|
191
|
+
id, lease freshness and the holder count, so an orchestrator can tell a stale lock from a live
|
|
192
|
+
one.
|
|
193
|
+
|
|
194
|
+
Boots sweep the dead registrations, and a `sending` ledger entry becomes `unknown` only when its
|
|
195
|
+
origin is not a live holder: a live peer's in-flight write stays `sending` (an apply of the same
|
|
196
|
+
key replays it, it is never re-sent), while a dead peer's — or a pre-0.8.0, unstamped — entry
|
|
197
|
+
becomes `unknown` exactly as in the single-process case.
|
|
186
198
|
|
|
187
199
|
Cases that need an operator:
|
|
188
200
|
|
|
189
|
-
- **`locked by a running process (pid N)`** (with or without `; its lease is X ms old`): another
|
|
190
|
-
live server holds this state dir (the lease-less form is a pre-0.6.0 server, which never wrote
|
|
191
|
-
one). Confirm with `ps -p N` that it is not a server you forgot, stop it, and start again. If it
|
|
192
|
-
is really gone and the lock's pid is dead, no unlock is needed: the next boot takes the lock
|
|
193
|
-
over immediately (a lease-less pre-0.6.0 lock has no lease to wait out). A live pid with no
|
|
194
|
-
lease is never taken over - stop that server, or delete `<state dir>/lock`.
|
|
195
201
|
- **`locked by host H (pid N); one container per state dir`**: the lock belongs to another host or
|
|
196
202
|
container, whose pids mean nothing here, so it is never taken over automatically. Make sure the
|
|
197
203
|
server on H is stopped (or the container is gone), then delete `<state dir>/lock` and start
|
|
@@ -201,12 +207,25 @@ Cases that need an operator:
|
|
|
201
207
|
atomic renames, so this is not a crash remnant of our own). Make sure no server is running on
|
|
202
208
|
this state dir, delete `<state dir>/lock.lease`, and start again.
|
|
203
209
|
- **`another process is taking over the lock (<state dir>/lock.takeover)`**: a boot crashed in the
|
|
204
|
-
middle of a takeover, a disk error hit mid-create, or two boots raced
|
|
205
|
-
dir, delete `<state dir>/lock.takeover`,
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
+
middle of a takeover, a disk error hit mid-create, or two boots raced and the wait ran out.
|
|
211
|
+
Make sure no other server is starting on this state dir, delete `<state dir>/lock.takeover`,
|
|
212
|
+
and start again.
|
|
213
|
+
- **`<state dir>/lock disappeared or is unreadable` / `names no pid`**: the lock file is gone or
|
|
214
|
+
corrupt (our writes are atomic, so this is not a crash remnant of our own). Make sure no server
|
|
215
|
+
is running on this state dir, delete `<state dir>/lock` (and `lock.lease`), and start again.
|
|
216
|
+
- **`the op lock could not be taken at boot: …`**: the per-operation lock was already stuck when
|
|
217
|
+
this boot started (the `op.lock` case below, seen at boot). Make sure no server is running on
|
|
218
|
+
this state dir, delete `<state dir>/op.lock` (and `op.lock.lease`), and start again.
|
|
219
|
+
- **`op.lock names no pid` / `a peer process holds <state dir>/op.lock; the wait ran out`**: the
|
|
220
|
+
per-operation lock is stuck — a boot crashed mid-operation and the 10 s wait ran out, or the
|
|
221
|
+
file is corrupt. Make sure no server is running on this state dir, delete `<state dir>/op.lock`
|
|
222
|
+
(and `op.lock.lease`), and start again.
|
|
223
|
+
|
|
224
|
+
Never delete a running server's registration (`<state dir>/holders/<boot>`) while it is running:
|
|
225
|
+
writes stop (`STATE_UNAVAILABLE`) the moment the server notices the file is gone, and it stops
|
|
226
|
+
refreshing the registration's lease. Deleting the primary `lock` out from under a running primary
|
|
227
|
+
is not a stop signal (the next boot simply takes the lock over) — the registration is the stop
|
|
228
|
+
signal. Keep the rest of the state dir; `audit.jsonl` is the write history.
|
|
210
229
|
|
|
211
230
|
### Query results: TSV, `output_path` and the saved-file shape
|
|
212
231
|
|
|
@@ -410,7 +429,7 @@ preview/apply path; a plan previewed under 0.6.0 cannot be applied after a rollb
|
|
|
410
429
|
|----------|---------|------|
|
|
411
430
|
| `POST /mcp` | the canonical MCP endpoint (Streamable HTTP transport); Host/Origin validated, 1 MiB body cap | a bearer token in `tokens` mode; per-request `X-Autotask-*` headers for tool calls |
|
|
412
431
|
| `GET /health` | process alive — pre-auth, **no vendor call** (container healthcheck) | none |
|
|
413
|
-
| `GET /ready` | config valid, SDK plan-hash pin checked, server initialised, client factory ready — **no live vendor call** (credentials are request-scoped). In an operator deployment the payload also carries `lock` (the state lock's age, holder id
|
|
432
|
+
| `GET /ready` | config valid, SDK plan-hash pin checked, server initialised, client factory ready — **no live vendor call** (credentials are request-scoped). In an operator deployment the payload also carries `lock` (the state lock's age, holder id, lease freshness and the holder count — see "The state lock: recovery") | none |
|
|
414
433
|
| `GET /doctor` | zero-wire credential/config preflight: per credential field `present`/`len`/`prefix2`/`shapeOk` (`AUTOTASK_SECRET`'s prefix is masked to `[REDACTED]` — no character of a secret is ever emitted; identifiers keep their format prefix), the base-url + allow-list resolution (`default_only` / `allow_list` / `default_and_list` / `any`), the SDK plan-hash gate status and the host-policy mode — **no vendor call, by construction** | Host/Origin validated; a bearer token in `tokens` mode |
|
|
415
434
|
| `GET /metadata` | server identity + the MCP protocol **era pair** (baseline `2026-07-28` + legacy fallback `2025-11-25`) + capability-manifest pointer + the `deployment` block (profile, toolsets, approval mode, caller auth, rate limiting) — the served payload is the repo's `server.json` + build stamp | Host/Origin validated; a bearer token in `tokens` mode |
|
|
416
435
|
|
|
@@ -655,7 +674,7 @@ independent review with its resolutions, [`docs/reviews/0.6.0.md`](docs/reviews/
|
|
|
655
674
|
|
|
656
675
|
## SDK dependency
|
|
657
676
|
|
|
658
|
-
`iit-mcp-autotask` depends on **`node-autotask` from npm at exactly `0.
|
|
677
|
+
`iit-mcp-autotask` depends on **`node-autotask` from npm at exactly `0.19.0`** (an exact pin —
|
|
659
678
|
no range operator; subpaths `.` / `/capabilities` / `/operations` / `/resources` / `/types` /
|
|
660
679
|
`/pool`) — the only **vendor** dependency; the other two runtime dependencies are
|
|
661
680
|
`@modelcontextprotocol/server` (the MCP framework) and `zod` (schema validation). It never
|
|
@@ -665,7 +684,7 @@ user — the published tarball ships no lockfile) must resolve to the release th
|
|
|
665
684
|
reviewed against, or the plan-hash boot check refuses to start.
|
|
666
685
|
|
|
667
686
|
**Publishing `node-autotask` is the maintainer's action, done manually.** It is published on the
|
|
668
|
-
public registry at `0.
|
|
687
|
+
public registry at `0.19.0` (published 2026-10-19); the
|
|
669
688
|
exact pin and the committed `pnpm-lock.yaml` (with `pnpm-workspace.yaml`) pin the real published
|
|
670
689
|
tarball with its integrity hash, so installs resolve to exactly that release — the lockfile
|
|
671
690
|
freezes the pnpm/git/Docker paths, the pin freezes the npm/npx paths. The docs are written against this
|
package/dist/main.js
CHANGED
|
@@ -189,6 +189,19 @@ function main() {
|
|
|
189
189
|
log.error('uncaught exception', { err: err instanceof Error ? (err.stack ?? err.message) : String(err) });
|
|
190
190
|
void handle.close().finally(() => process.exit(1));
|
|
191
191
|
});
|
|
192
|
+
// SIGTERM/SIGINT: close the transport and exit through process.exit, whose 'exit' hook
|
|
193
|
+
// releases the state dir (issue #68: a terminated process must not leave its holder
|
|
194
|
+
// registration behind for the next boot to sweep).
|
|
195
|
+
let stopping = false;
|
|
196
|
+
const stop = (signal) => {
|
|
197
|
+
if (stopping)
|
|
198
|
+
return; // a second signal mid-close: the first one is finishing it
|
|
199
|
+
stopping = true;
|
|
200
|
+
log.info('shutting down', { signal });
|
|
201
|
+
void handle.close().finally(() => process.exit(0));
|
|
202
|
+
};
|
|
203
|
+
process.on('SIGTERM', () => stop('SIGTERM'));
|
|
204
|
+
process.on('SIGINT', () => stop('SIGINT'));
|
|
192
205
|
return;
|
|
193
206
|
}
|
|
194
207
|
// tokens mode: the tokens file is read and validated once, before the listener binds.
|
|
@@ -96,7 +96,7 @@ export declare function cacheOptionsForPreset(preset: 'on' | 'off', fieldCacheTt
|
|
|
96
96
|
* fails the boot (or the CI test) instead of quietly shipping a tool surface that no longer
|
|
97
97
|
* describes the SDK behind it.
|
|
98
98
|
*/
|
|
99
|
-
export declare const BUILT_AGAINST_PLAN_HASH = "
|
|
99
|
+
export declare const BUILT_AGAINST_PLAN_HASH = "078f288c280029abcbc632ac9a842ba952296ef28872c28da67a08af6ed76475";
|
|
100
100
|
/**
|
|
101
101
|
* Fail fast when the installed SDK's plan has moved out from under this surface.
|
|
102
102
|
* Called at boot (main.ts) and safe to call repeatedly (pure comparison).
|
|
@@ -159,7 +159,7 @@ export function cacheOptionsForPreset(preset, fieldCacheTtlMs) {
|
|
|
159
159
|
* fails the boot (or the CI test) instead of quietly shipping a tool surface that no longer
|
|
160
160
|
* describes the SDK behind it.
|
|
161
161
|
*/
|
|
162
|
-
export const BUILT_AGAINST_PLAN_HASH = '
|
|
162
|
+
export const BUILT_AGAINST_PLAN_HASH = '078f288c280029abcbc632ac9a842ba952296ef28872c28da67a08af6ed76475';
|
|
163
163
|
/**
|
|
164
164
|
* Fail fast when the installed SDK's plan has moved out from under this surface.
|
|
165
165
|
* Called at boot (main.ts) and safe to call repeatedly (pure comparison).
|
package/dist/tools/server.d.ts
CHANGED
|
@@ -28,7 +28,7 @@ import { type TokenStore } from '../http-guard.js';
|
|
|
28
28
|
import type { WriteRuntime } from '../write/runtime.js';
|
|
29
29
|
/** The wire identity of this server (the initialize answer + the `/metadata` payload). */
|
|
30
30
|
export declare const SERVER_NAME = "iit-mcp-autotask";
|
|
31
|
-
export declare const SERVER_VERSION = "0.
|
|
31
|
+
export declare const SERVER_VERSION = "0.8.0";
|
|
32
32
|
export declare const SERVER_TITLE = "Autotask MCP Server";
|
|
33
33
|
/**
|
|
34
34
|
* The MCP protocol era this server advertises (the `/metadata` stamp, both levels).
|
|
@@ -60,7 +60,7 @@ export declare function serverInstructions(config: Pick<Config, 'AUTOTASK_PROFIL
|
|
|
60
60
|
/** The full identity stamp (the `/metadata` build block — the era pair, FR-2). */
|
|
61
61
|
export declare const SERVER_IDENTITY: {
|
|
62
62
|
readonly name: "iit-mcp-autotask";
|
|
63
|
-
readonly version: "0.
|
|
63
|
+
readonly version: "0.8.0";
|
|
64
64
|
readonly mcpProtocolBaseline: "2026-07-28";
|
|
65
65
|
readonly legacyProtocolFallback: "2025-11-25";
|
|
66
66
|
};
|
package/dist/tools/server.js
CHANGED
|
@@ -29,7 +29,7 @@ import { registerTools } from './register.js';
|
|
|
29
29
|
import { configForToken } from '../http-guard.js';
|
|
30
30
|
/** The wire identity of this server (the initialize answer + the `/metadata` payload). */
|
|
31
31
|
export const SERVER_NAME = 'iit-mcp-autotask';
|
|
32
|
-
export const SERVER_VERSION = '0.
|
|
32
|
+
export const SERVER_VERSION = '0.8.0';
|
|
33
33
|
export const SERVER_TITLE = 'Autotask MCP Server';
|
|
34
34
|
/**
|
|
35
35
|
* The MCP protocol era this server advertises (the `/metadata` stamp, both levels).
|
package/dist/tools/write.js
CHANGED
|
@@ -604,21 +604,37 @@ export function buildApply(ctx) {
|
|
|
604
604
|
return refused('denied', 'stop', reason('PRINCIPAL_MISMATCH', 'this plan belongs to another principal or another Autotask tenant credential'), plan);
|
|
605
605
|
}
|
|
606
606
|
return store.withApplyLock(async () => {
|
|
607
|
-
const
|
|
608
|
-
|
|
607
|
+
const base = { idemKey: plan.idemKey, planHash: plan.planHash, targetKey: plan.facts.targetKey, op: plan.op };
|
|
608
|
+
// The hit answer — shared by the FIRST lookup (its old position) and the claim's rescan:
|
|
609
|
+
// the same plan hash answers with the recorded outcome (`replayed: true`), except a
|
|
610
|
+
// retryable failure that wrote nothing (a 429) which may run again under the same key;
|
|
611
|
+
// another plan hash is `IDEMPOTENCY_CONFLICT`, `verify_then_repreview` (pre-release V4:
|
|
612
|
+
// not a dead end). `undefined` means the hit does not end the apply.
|
|
613
|
+
const hitAnswer = (hit) => {
|
|
609
614
|
if (hit.planHash !== plan.planHash) {
|
|
610
|
-
// Pre-release V4: not a dead end. The key already ran another plan (for example a retry
|
|
611
|
-
// re-previewed after the state moved on): re-read, and run anything still needed as a new attempt.
|
|
612
615
|
const was = hit.state === 'sending' ? 'unknown' : hit.state;
|
|
613
616
|
return refused('denied', 'verify_then_repreview', reason('IDEMPOTENCY_CONFLICT', `this idempotency key already ran a different plan (outcome ${was}). Do not re-apply under this key: re-read the target to see whether that write landed; if a write is still needed, preview again under a new idempotency_key (a new run id or attempt)`), plan);
|
|
614
617
|
}
|
|
615
|
-
// F1: a retryable failure (a 429, or refused before it was sent) wrote nothing, so the
|
|
616
|
-
// plan may run again under the same key; the SDK's own cooldown still gates the
|
|
617
|
-
// Everything else replays: succeeded, a deterministic failure, and above all
|
|
618
|
-
//
|
|
619
|
-
//
|
|
618
|
+
// F1: a retryable failure (a 429, or refused before it was sent) wrote nothing, so the
|
|
619
|
+
// same plan may run again under the same key; the SDK's own cooldown still gates the
|
|
620
|
+
// send. Everything else replays: succeeded, a deterministic failure, and above all
|
|
621
|
+
// `unknown` and a lingering `sending` (a peer's in-flight write, issue #68), which are
|
|
622
|
+
// never re-sent (a new attempt needs a new key and a fresh preview, whose duplicate
|
|
623
|
+
// query finds a write that did land).
|
|
620
624
|
if (!(hit.state === 'failed' && hit.outcome?.retryable === true))
|
|
621
625
|
return outcomeAnswer(plan, hit, { replayed: true });
|
|
626
|
+
return undefined;
|
|
627
|
+
};
|
|
628
|
+
// GATES in the old relative order (issue #68): the first lookup is a FRESH from-disk
|
|
629
|
+
// scan, no op lock — a same-key replay NEVER dials the vendor, so an apply after a
|
|
630
|
+
// landed write answers from the ledger; a snapshot re-read before this gate would read
|
|
631
|
+
// the CHANGED vendor state, drift, and refuse before reaching the replay path (the
|
|
632
|
+
// ordering flaw that broke acceptance criterion 3).
|
|
633
|
+
const hit = store.lookup(plan.idemKey);
|
|
634
|
+
if (hit !== undefined) {
|
|
635
|
+
const early = hitAnswer(hit);
|
|
636
|
+
if (early !== undefined)
|
|
637
|
+
return early;
|
|
622
638
|
}
|
|
623
639
|
if (now() > plan.expiresAt)
|
|
624
640
|
return refused('error', 'repreview', reason('PLAN_EXPIRED', 'the plan is older than 15 minutes; preview again'), plan);
|
|
@@ -632,6 +648,9 @@ export function buildApply(ctx) {
|
|
|
632
648
|
if (store.coolingDown(plan.facts.targetKey, now())) {
|
|
633
649
|
return refused('denied', 'verify_then_repreview', reason('COOLDOWN_ACTIVE', 'an earlier write to this target has an unknown outcome; re-read it, then preview again after the 10-minute cooldown'), plan);
|
|
634
650
|
}
|
|
651
|
+
// The snapshot re-read (the only vendor call before the send) stays OUTSIDE the op lock
|
|
652
|
+
// — the claim's critical section must never span a vendor round-trip (issue #68) — in
|
|
653
|
+
// its old position: after the gates above, before the claim.
|
|
635
654
|
const record = ctx.record(plan.op);
|
|
636
655
|
const shape = writeShape(record);
|
|
637
656
|
const parsed = { args: plan.args, targets: plan.targets, ...(SHAPE_ARGS[shape].includes('data') ? { data: plan.args.at(-1) } : {}) };
|
|
@@ -649,14 +668,34 @@ export function buildApply(ctx) {
|
|
|
649
668
|
if ('code' in snap || snap.hash !== plan.snapshotHash) {
|
|
650
669
|
return refused('error', 'repreview', reason('STATE_DRIFT', 'the target changed since the preview; preview again'), plan);
|
|
651
670
|
}
|
|
652
|
-
// Last point a cancellation
|
|
671
|
+
// Last point a cancellation stops the write before anything is claimed: nothing has been sent.
|
|
653
672
|
if (signal?.aborted === true)
|
|
654
673
|
return abortedResult();
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
674
|
+
// The CLAIM (issue #68): one op-lock critical section — the blocked() re-check, a FRESH
|
|
675
|
+
// ledger re-scan and the `sending` append. The re-scan closes the cross-process race: a
|
|
676
|
+
// peer's claim landing between the first lookup and here is visible inside the lock,
|
|
677
|
+
// and the loser gets the honest hit answer (replay/unknown) without sending. The
|
|
678
|
+
// expiry/policy/counters/cooldown checks stay OUTSIDE it — they already ran above, and
|
|
679
|
+
// the file is the single source of truth, so a locked re-check would add nothing.
|
|
680
|
+
const opBlocked = store.acquireOpLock();
|
|
681
|
+
if (opBlocked !== undefined) {
|
|
682
|
+
return refused('error', 'retry', reason(opBlocked.code, opBlocked.detail), plan, { retryable: true });
|
|
683
|
+
}
|
|
684
|
+
try {
|
|
685
|
+
const late = store.blocked();
|
|
686
|
+
if (late !== undefined)
|
|
687
|
+
return refused(late.next === 'retry' ? 'error' : 'denied', late.next, reason(late.code, late.detail), plan, { retryable: late.next === 'retry' });
|
|
688
|
+
const rescan = store.lookup(plan.idemKey);
|
|
689
|
+
if (rescan !== undefined) {
|
|
690
|
+
const lateHit = hitAnswer(rescan);
|
|
691
|
+
if (lateHit !== undefined)
|
|
692
|
+
return lateHit;
|
|
693
|
+
}
|
|
694
|
+
store.append({ ...base, state: 'sending', at: now() });
|
|
695
|
+
}
|
|
696
|
+
finally {
|
|
697
|
+
store.releaseOpLock();
|
|
698
|
+
}
|
|
660
699
|
store.audit({ event: 'apply_start', principal: w.principal, op: plan.op, targets: plan.targets, planHash: plan.planHash, decision: 'sending', reasons: [] }, now());
|
|
661
700
|
// The SDK write: no MCP signal (M7, never aborted once sending), capped at the apply wait.
|
|
662
701
|
// Retries are off via the PER-CALL maxRetries: 0 on the tenant client (node-autotask#105:
|
|
@@ -700,23 +739,38 @@ export function buildApply(ctx) {
|
|
|
700
739
|
finally {
|
|
701
740
|
clearTimeout(timer);
|
|
702
741
|
}
|
|
703
|
-
|
|
704
|
-
//
|
|
705
|
-
//
|
|
706
|
-
// the
|
|
707
|
-
//
|
|
708
|
-
//
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
742
|
+
// The result append takes the op lock (issue #68 review): one O_APPEND line is not
|
|
743
|
+
// enough — a peer that boots between our open('a') and writeSync renames the ledger out
|
|
744
|
+
// from under the write, and the `succeeded` line (with its `created` block) is orphaned
|
|
745
|
+
// on the old inode. Under the op lock the append serialises with the boot rewrite and
|
|
746
|
+
// with the peer's claim, and the apply_result audit rides the SAME acquisition (audit is
|
|
747
|
+
// re-entrant-aware: it appends directly while the lock is ours).
|
|
748
|
+
const blockedAfter = store.acquireOpLock();
|
|
749
|
+
if (blockedAfter !== undefined) {
|
|
750
|
+
throw new ConfigError(`AUTOTASK_STATE_DIR: the write may have landed on the vendor side, but its result could not be recorded (${blockedAfter.detail}); re-read the target before any retry`);
|
|
751
|
+
}
|
|
752
|
+
try {
|
|
753
|
+
store.append(entry);
|
|
754
|
+
const head = store.audit({ event: 'apply_result', principal: w.principal, op: plan.op, targets: plan.targets, planHash: plan.planHash, decision: entry.state, reasons: entry.outcome?.code === undefined ? [] : [entry.outcome.code] }, now());
|
|
755
|
+
ctx.log.info('write applied', { op: plan.op, outcome: entry.state, auditHead: head });
|
|
756
|
+
}
|
|
757
|
+
finally {
|
|
758
|
+
store.releaseOpLock();
|
|
759
|
+
}
|
|
760
|
+
// The last blocked() gate was before the vendor send: this process's holder registration
|
|
761
|
+
// may have been removed while the write was in flight. A GENUINE stop (STATE_UNAVAILABLE
|
|
762
|
+
// with next === 'stop': the registration was deleted) throws — answering success for a
|
|
763
|
+
// write that an external actor has already stopped is worse than a loud failure (the
|
|
764
|
+
// ledger and audit lines WERE written; the failure is about this process's standing).
|
|
765
|
+
// A kill switch that comes up during the send does NOT throw: the registration is still
|
|
766
|
+
// present, the audit line appends, and the kill switch stops the NEXT write (the
|
|
767
|
+
// main-branch behaviour, kept deliberately). A transient unreadable registration
|
|
768
|
+
// (STATE_UNAVAILABLE with next === 'retry') proceeds to the audit as normal: the write
|
|
769
|
+
// completed.
|
|
714
770
|
const after = store.blocked();
|
|
715
771
|
if (after !== undefined && after.code === 'STATE_UNAVAILABLE' && after.next === 'stop') {
|
|
716
|
-
throw new ConfigError(`AUTOTASK_STATE_DIR:
|
|
772
|
+
throw new ConfigError(`AUTOTASK_STATE_DIR: this process's holder registration was removed while the write was in flight; the write completed on the vendor side and its ledger and audit lines were recorded, but this process is stopped (${after.detail})`);
|
|
717
773
|
}
|
|
718
|
-
const head = store.audit({ event: 'apply_result', principal: w.principal, op: plan.op, targets: plan.targets, planHash: plan.planHash, decision: entry.state, reasons: entry.outcome?.code === undefined ? [] : [entry.outcome.code] }, now());
|
|
719
|
-
ctx.log.info('write applied', { op: plan.op, outcome: entry.state, auditHead: head });
|
|
720
774
|
if (entry.state !== 'succeeded') {
|
|
721
775
|
const data = error === undefined ? {} : { error: honestError(typedErrorPayload(error, ctx.correlationId, requestSecrets(ctx)), error, entry) };
|
|
722
776
|
return outcomeAnswer(plan, entry, { replayed: false, data });
|
package/dist/write/store.d.ts
CHANGED
|
@@ -4,8 +4,9 @@ export declare const PLAN_TTL_MS: number;
|
|
|
4
4
|
export declare const LEDGER_RETENTION_MS: number;
|
|
5
5
|
export declare const COOLDOWN_MS: number;
|
|
6
6
|
/**
|
|
7
|
-
* The lease heartbeat: while it
|
|
8
|
-
*
|
|
7
|
+
* The lease heartbeat: while it is primary, the holder rewrites `<state>/lock.lease` atomically
|
|
8
|
+
* at this interval (issue #55); EVERY process also refreshes its own registration's lease here
|
|
9
|
+
* (issue #68) — one timer, both heartbeats.
|
|
9
10
|
*
|
|
10
11
|
* 5 s: one small 0600 file rewrite + fsync every 5 s is negligible against the apply path, and it
|
|
11
12
|
* keeps the takeover delay after a container replacement short (the lease is dead within one
|
|
@@ -16,14 +17,16 @@ export declare const LEASE_REFRESH_MS = 5000;
|
|
|
16
17
|
* A lease older than this is expired: the holder has been silent for three full refresh cycles,
|
|
17
18
|
* so its lock may be taken over. A healthy event loop does not stall for 15 s (GC, fsync and OS
|
|
18
19
|
* scheduling are far shorter), so a live holder is never taken in practice; a pathological stall
|
|
19
|
-
* longer than this CAN be taken over, and the stalled process detects the
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
* (a forked chain is worse than a missing line), while the explicit re-check in the tool fails
|
|
24
|
-
* a genuinely lost lock loudly so the chain is never forked.
|
|
20
|
+
* longer than this CAN be taken over, and the stalled process detects the loss and stops new
|
|
21
|
+
* writes (`STATE_UNAVAILABLE`) the moment it resumes. The same expiry window is the op lock's
|
|
22
|
+
* dead-man's switch: holds are milliseconds, so a holder whose op lease went stale is a crashed
|
|
23
|
+
* mid-op process, and the next acquire takes the op lock over.
|
|
25
24
|
*/
|
|
26
25
|
export declare const LEASE_EXPIRY_MS: number;
|
|
26
|
+
/** The op lock's poll interval while a peer holds it (holds are milliseconds; 10 ms is plenty). */
|
|
27
|
+
export declare const OP_LOCK_POLL_MS = 10;
|
|
28
|
+
/** The op lock's wait cap: a live holder almost always releases long before this. */
|
|
29
|
+
export declare const OP_LOCK_WAIT_MS = 10000;
|
|
27
30
|
/** What a plan names as its target: the resource and the ids a snapshot read. */
|
|
28
31
|
export interface PlanTargets {
|
|
29
32
|
resource: string;
|
|
@@ -72,6 +75,10 @@ export interface LedgerEntry {
|
|
|
72
75
|
principal: string;
|
|
73
76
|
tenantId: string;
|
|
74
77
|
};
|
|
78
|
+
/** The per-boot identity of the process that appended this line (issue #68); absent on legacy entries. */
|
|
79
|
+
boot?: string;
|
|
80
|
+
/** The pid of the process that appended this line (issue #68); absent on legacy entries. */
|
|
81
|
+
pid?: number;
|
|
75
82
|
}
|
|
76
83
|
/** The identity fields of a credential (never its secret): what the tenant id is an HMAC of. */
|
|
77
84
|
export interface TenantIdentity {
|
|
@@ -96,7 +103,7 @@ export interface AuditEvent {
|
|
|
96
103
|
}
|
|
97
104
|
/** The facts /ready reports on the state lock (issue #55): age, holder id, lease freshness. */
|
|
98
105
|
export interface LockInfo {
|
|
99
|
-
/** This process holds the lock. */
|
|
106
|
+
/** This process is the primary (holds the lock). */
|
|
100
107
|
held: boolean;
|
|
101
108
|
/** The holder the lock currently names (absent when the lock is gone or unreadable). */
|
|
102
109
|
holder?: {
|
|
@@ -110,7 +117,15 @@ export interface LockInfo {
|
|
|
110
117
|
leaseAgeMs?: number;
|
|
111
118
|
/** True when a readable lease is older than {@link LEASE_EXPIRY_MS}. */
|
|
112
119
|
leaseExpired?: boolean;
|
|
120
|
+
/** How many processes have registered in `<state>/holders/` (including this one, issue #68). No liveness filter: dead registrations are swept at the next boot. */
|
|
121
|
+
holders: number;
|
|
113
122
|
}
|
|
123
|
+
/** Why an op-lock acquisition gave up: the bounded wait ran out, or the op lock needs an operator. */
|
|
124
|
+
export type OpLockBlocked = {
|
|
125
|
+
code: 'STATE_UNAVAILABLE';
|
|
126
|
+
next: 'retry';
|
|
127
|
+
detail: string;
|
|
128
|
+
};
|
|
114
129
|
export interface StateStore {
|
|
115
130
|
readonly dir: string;
|
|
116
131
|
tenantId(identity: TenantIdentity): string;
|
|
@@ -118,9 +133,9 @@ export interface StateStore {
|
|
|
118
133
|
savePlan(plan: PlanRecord): void;
|
|
119
134
|
/** The decrypted plan, or undefined when absent or not authentic (forged, edited, swapped). */
|
|
120
135
|
loadPlan(handle: string): PlanRecord | undefined;
|
|
121
|
-
/** The latest ledger entry for an idempotency key. */
|
|
136
|
+
/** The latest ledger entry for an idempotency key (a fresh scan of the file). */
|
|
122
137
|
lookup(idemKey: string): LedgerEntry | undefined;
|
|
123
|
-
/** Append + fsync. */
|
|
138
|
+
/** Append + fsync (one O_APPEND line; the line is stamped with this boot's identity). */
|
|
124
139
|
append(entry: LedgerEntry): void;
|
|
125
140
|
counters(op: string, now: number): Counters;
|
|
126
141
|
/** True while an unknown outcome on this target is younger than the cooldown. */
|
|
@@ -132,31 +147,45 @@ export interface StateStore {
|
|
|
132
147
|
createdHere(resource: string, id: number, now: number, days: number, principal: string, tenantId: string): {
|
|
133
148
|
companyIds: number[];
|
|
134
149
|
} | undefined;
|
|
135
|
-
/**
|
|
150
|
+
/**
|
|
151
|
+
* Append one chained audit line; returns the new head hash. The append runs under the op lock,
|
|
152
|
+
* which adopts the file's tail when the chain advanced under a peer; the line is skipped (logged)
|
|
153
|
+
* and the current head returned only when the store is closed or the op lock cannot be taken.
|
|
154
|
+
*/
|
|
136
155
|
audit(event: AuditEvent, now: number): string;
|
|
137
156
|
/** The facts /ready reports on the state lock: who holds it, how long, and how fresh its lease is. */
|
|
138
157
|
lockInfo(): LockInfo;
|
|
139
158
|
killed(): boolean;
|
|
140
159
|
/**
|
|
141
|
-
* Why writes may not run right now: the kill switch (stop),
|
|
142
|
-
*
|
|
160
|
+
* Why writes may not run right now: the kill switch (stop), this process's holder registration
|
|
161
|
+
* gone (stop) or unreadable for the moment (retry); undefined when writes may run.
|
|
143
162
|
*/
|
|
144
163
|
blocked(): {
|
|
145
164
|
code: 'KILL_SWITCH' | 'STATE_UNAVAILABLE';
|
|
146
165
|
next: 'stop' | 'retry';
|
|
147
166
|
detail: string;
|
|
148
167
|
} | undefined;
|
|
149
|
-
/**
|
|
168
|
+
/**
|
|
169
|
+
* Take the per-operation lock (issue #68). Returns undefined when acquired — the caller must
|
|
170
|
+
* then call {@link releaseOpLock} — or the reason the bounded wait gave up.
|
|
171
|
+
*/
|
|
172
|
+
acquireOpLock(): OpLockBlocked | undefined;
|
|
173
|
+
/** Release the per-operation lock if it is still this process's. */
|
|
174
|
+
releaseOpLock(): void;
|
|
175
|
+
/** One apply at a time per process. */
|
|
150
176
|
withApplyLock<T>(fn: () => Promise<T>): Promise<T>;
|
|
151
177
|
close(): void;
|
|
152
178
|
}
|
|
153
179
|
/**
|
|
154
|
-
* Take the state lock: O_EXCL, or a takeover of a holder that is no longer active
|
|
180
|
+
* Take the state lock: O_EXCL, or a takeover of a holder that is no longer active, or — when the
|
|
181
|
+
* current holder is a LIVE primary — a plain secondary boot (issue #68): the lock stays with the
|
|
182
|
+
* primary and this process shares the dir under the op lock.
|
|
155
183
|
*
|
|
156
184
|
* The takeover is serialised by a second O_EXCL file, `lock.takeover` (review F7): only its holder
|
|
157
185
|
* may replace the stale lock, and it re-reads the lock first, so two processes booting after a crash
|
|
158
|
-
* cannot both take over (the loser sees either the takeover file
|
|
159
|
-
*
|
|
186
|
+
* cannot both take over (the loser sees either the takeover file — and now WAITS for the taker
|
|
187
|
+
* instead of refusing — or the winner's live pid + fresh lease). A takeover file that survives the
|
|
188
|
+
* wait cap (a crash mid-takeover left it behind) refuses boot until an operator removes it.
|
|
160
189
|
*
|
|
161
190
|
* The verdict has three parts (issue #55): another host's lock is never taken (R6); a FRESH lease
|
|
162
191
|
* (the holder rewrote `lock.lease` within {@link LEASE_EXPIRY_MS}) vetoes the takeover outright —
|
|
@@ -182,6 +211,8 @@ export interface OpenStoreOptions {
|
|
|
182
211
|
/** `AUTOTASK_KILL_FILE`; default `<state>/KILL`. */
|
|
183
212
|
killFile?: string;
|
|
184
213
|
now?: () => number;
|
|
214
|
+
/** The op lock's wait cap (and the lock-takeover contention wait). Default {@link OP_LOCK_WAIT_MS}. */
|
|
215
|
+
opLockWaitMs?: number;
|
|
185
216
|
}
|
|
186
217
|
/** Open (or create) the state directory; throws ConfigError on every boot refusal. */
|
|
187
218
|
export declare function openStateStore(dir: string, opts: OpenStoreOptions): StateStore;
|