@crouton-kit/crouter 0.3.40 → 0.3.42
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/dist/build-root.js +1 -0
- package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/__tests__/provider-rotation.test.ts +472 -9
- package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.ts +284 -76
- package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/strip-skills-docs.ts +31 -24
- package/dist/builtin-pi-packages/pi-crtr-extensions/lib/subscription-state.ts +52 -15
- package/dist/clients/attach/attach-cmd.js +720 -747
- package/dist/clients/attach/chat-view.js +11 -0
- package/dist/clients/attach/view-socket.d.ts +8 -1
- package/dist/clients/attach/view-socket.js +15 -1
- package/dist/clients/web/server.js +5 -28
- package/dist/clients/web/web-cmd.js +1 -1
- package/dist/commands/__tests__/revive-now-gate.test.js +60 -0
- package/dist/commands/canvas-rebuild-index.js +5 -5
- package/dist/commands/memory/shared.d.ts +7 -3
- package/dist/commands/memory/shared.js +35 -5
- package/dist/commands/memory/write.js +5 -3
- package/dist/commands/node-snapshot.js +9 -1
- package/dist/commands/node.js +37 -17
- package/dist/commands/push.js +8 -0
- package/dist/commands/revive.d.ts +10 -0
- package/dist/commands/revive.js +28 -10
- package/dist/commands/sys/__tests__/setup-core.test.js +19 -0
- package/dist/commands/sys/doctor.js +1 -1
- package/dist/commands/sys/setup-core.js +3 -2
- package/dist/commands/sys/setup.js +1 -1
- package/dist/commands/worktree.d.ts +2 -0
- package/dist/commands/worktree.js +94 -0
- package/dist/core/__tests__/boot.test.js +4 -4
- package/dist/core/__tests__/canvas.test.js +19 -7
- package/dist/core/__tests__/child-followup.test.js +15 -5
- package/dist/core/__tests__/daemon-boot.test.js +6 -1
- package/dist/core/__tests__/daemon-wedge.test.js +18 -1
- package/dist/core/__tests__/fault-classifier.test.js +30 -0
- package/dist/core/__tests__/fixtures/fake-engine.d.ts +13 -0
- package/dist/core/__tests__/fixtures/fake-engine.js +20 -0
- package/dist/core/__tests__/full/spike-harness.test.js +8 -5
- package/dist/core/__tests__/grace-clock.test.js +18 -2
- package/dist/core/__tests__/host-teardown-process-group.test.js +465 -0
- package/dist/core/__tests__/prune-to-limit.test.js +14 -0
- package/dist/core/__tests__/review-model-floor.test.js +32 -0
- package/dist/core/__tests__/revive.test.js +18 -4
- package/dist/core/__tests__/session-cycles.test.js +77 -0
- package/dist/core/__tests__/worktree.test.js +85 -0
- package/dist/core/canvas/boot.js +12 -7
- package/dist/core/canvas/canvas.d.ts +27 -8
- package/dist/core/canvas/canvas.js +54 -26
- package/dist/core/canvas/db.js +14 -0
- package/dist/core/canvas/history.js +1 -0
- package/dist/core/canvas/paths.d.ts +10 -9
- package/dist/core/canvas/paths.js +10 -9
- package/dist/core/canvas/pid.d.ts +155 -1
- package/dist/core/canvas/pid.js +306 -1
- package/dist/core/canvas/status-glyph.d.ts +7 -0
- package/dist/core/canvas/status-glyph.js +10 -1
- package/dist/core/canvas/types.d.ts +34 -0
- package/dist/core/fault-classifier.js +5 -1
- package/dist/core/profiles/select.d.ts +4 -2
- package/dist/core/profiles/select.js +30 -4
- package/dist/core/runtime/bearings.js +4 -0
- package/dist/core/runtime/branded-host.d.ts +7 -0
- package/dist/core/runtime/branded-host.js +44 -17
- package/dist/core/runtime/broker-sdk.js +28 -68
- package/dist/core/runtime/broker.js +35 -4
- package/dist/core/runtime/host.d.ts +3 -3
- package/dist/core/runtime/host.js +148 -33
- package/dist/core/runtime/launch.d.ts +15 -15
- package/dist/core/runtime/launch.js +58 -4
- package/dist/core/runtime/naming.js +3 -2
- package/dist/core/runtime/nodes.d.ts +3 -1
- package/dist/core/runtime/nodes.js +1 -0
- package/dist/core/runtime/pi-cli.d.ts +2 -0
- package/dist/core/runtime/pi-cli.js +51 -0
- package/dist/core/runtime/placement.d.ts +1 -1
- package/dist/core/runtime/placement.js +5 -2
- package/dist/core/runtime/recap.js +2 -1
- package/dist/core/runtime/revive.d.ts +21 -15
- package/dist/core/runtime/revive.js +87 -52
- package/dist/core/runtime/session-cycles.d.ts +30 -0
- package/dist/core/runtime/session-cycles.js +77 -0
- package/dist/core/runtime/spawn.d.ts +4 -0
- package/dist/core/runtime/spawn.js +152 -100
- package/dist/core/runtime/tmux.d.ts +7 -10
- package/dist/core/runtime/tmux.js +9 -11
- package/dist/core/worktree.d.ts +35 -0
- package/dist/core/worktree.js +158 -0
- package/dist/daemon/crtrd.d.ts +17 -8
- package/dist/daemon/crtrd.js +191 -40
- package/dist/index.d.ts +3 -0
- package/dist/index.js +7 -0
- package/dist/pi-extensions/canvas-context-intro.d.ts +1 -0
- package/dist/pi-extensions/canvas-context-intro.js +34 -23
- package/dist/pi-extensions/canvas-stophook.js +11 -5
- package/dist/web-client/assets/{index-CbO8L0mN.js → index-B00YpRQ1.js} +20 -20
- package/dist/web-client/assets/index-DrkcvANq.css +2 -0
- package/dist/web-client/index.html +2 -2
- package/docs/compat/hearth-crtr-v1.md +191 -0
- package/docs/public-api.md +75 -0
- package/package.json +3 -4
- package/dist/core/__tests__/hearth-bootstrap.test.js +0 -136
- package/dist/core/hearth/__tests__/model-auth-guest.test.js +0 -151
- package/dist/core/hearth/config.d.ts +0 -3
- package/dist/core/hearth/config.js +0 -108
- package/dist/core/hearth/guest-env.d.ts +0 -9
- package/dist/core/hearth/guest-env.js +0 -27
- package/dist/core/hearth/index.d.ts +0 -4
- package/dist/core/hearth/index.js +0 -4
- package/dist/core/hearth/model-auth-guest.d.ts +0 -8
- package/dist/core/hearth/model-auth-guest.js +0 -430
- package/dist/core/hearth/provider.d.ts +0 -36
- package/dist/core/hearth/provider.js +0 -10
- package/dist/core/hearth/providers/__tests__/sweep-and-release.test.js +0 -362
- package/dist/core/hearth/providers/blaxel-bootstrap.d.ts +0 -12
- package/dist/core/hearth/providers/blaxel-bootstrap.js +0 -147
- package/dist/core/hearth/providers/blaxel-home.d.ts +0 -60
- package/dist/core/hearth/providers/blaxel-home.js +0 -405
- package/dist/core/hearth/providers/blaxel.d.ts +0 -36
- package/dist/core/hearth/providers/blaxel.js +0 -364
- package/dist/core/hearth/providers/types.d.ts +0 -93
- package/dist/core/hearth/types.d.ts +0 -155
- package/dist/hearth/control-plane/__tests__/error-serialization.test.js +0 -29
- package/dist/hearth/control-plane/__tests__/node-message.test.js +0 -60
- package/dist/hearth/control-plane/__tests__/oauth-serving-marker.test.d.ts +0 -1
- package/dist/hearth/control-plane/__tests__/oauth-serving-marker.test.js +0 -44
- package/dist/hearth/control-plane/__tests__/rate-limit-recurrence.test.d.ts +0 -1
- package/dist/hearth/control-plane/__tests__/rate-limit-recurrence.test.js +0 -49
- package/dist/hearth/control-plane/__tests__/relay-security.test.d.ts +0 -1
- package/dist/hearth/control-plane/__tests__/relay-security.test.js +0 -314
- package/dist/hearth/control-plane/__tests__/scheduler-scan-loop.test.d.ts +0 -1
- package/dist/hearth/control-plane/__tests__/scheduler-scan-loop.test.js +0 -133
- package/dist/hearth/control-plane/__tests__/trigger-delivery.test.d.ts +0 -1
- package/dist/hearth/control-plane/__tests__/trigger-delivery.test.js +0 -170
- package/dist/hearth/control-plane/__tests__/wake-roll.test.d.ts +0 -1
- package/dist/hearth/control-plane/__tests__/wake-roll.test.js +0 -230
- package/dist/hearth/control-plane/__tests__/webhook-ingress.test.d.ts +0 -1
- package/dist/hearth/control-plane/__tests__/webhook-ingress.test.js +0 -167
- package/dist/hearth/control-plane/config.d.ts +0 -21
- package/dist/hearth/control-plane/config.js +0 -77
- package/dist/hearth/control-plane/db.d.ts +0 -30
- package/dist/hearth/control-plane/db.js +0 -561
- package/dist/hearth/control-plane/hearth-target.d.ts +0 -23
- package/dist/hearth/control-plane/hearth-target.js +0 -68
- package/dist/hearth/control-plane/ingress/rate-limit.d.ts +0 -24
- package/dist/hearth/control-plane/ingress/rate-limit.js +0 -100
- package/dist/hearth/control-plane/ingress/route-store.d.ts +0 -31
- package/dist/hearth/control-plane/ingress/route-store.js +0 -61
- package/dist/hearth/control-plane/ingress/webhook-delivery-store.d.ts +0 -41
- package/dist/hearth/control-plane/ingress/webhook-delivery-store.js +0 -69
- package/dist/hearth/control-plane/ingress/webhook-route.d.ts +0 -55
- package/dist/hearth/control-plane/ingress/webhook-route.js +0 -285
- package/dist/hearth/control-plane/main.d.ts +0 -1
- package/dist/hearth/control-plane/main.js +0 -88
- package/dist/hearth/control-plane/node-message.d.ts +0 -31
- package/dist/hearth/control-plane/node-message.js +0 -98
- package/dist/hearth/control-plane/register.d.ts +0 -15
- package/dist/hearth/control-plane/register.js +0 -34
- package/dist/hearth/control-plane/registry.d.ts +0 -22
- package/dist/hearth/control-plane/registry.js +0 -168
- package/dist/hearth/control-plane/relay.d.ts +0 -44
- package/dist/hearth/control-plane/relay.js +0 -711
- package/dist/hearth/control-plane/scheduler/fire-store.d.ts +0 -36
- package/dist/hearth/control-plane/scheduler/fire-store.js +0 -73
- package/dist/hearth/control-plane/scheduler/recurrence.d.ts +0 -7
- package/dist/hearth/control-plane/scheduler/recurrence.js +0 -58
- package/dist/hearth/control-plane/scheduler/scan-loop.d.ts +0 -38
- package/dist/hearth/control-plane/scheduler/scan-loop.js +0 -138
- package/dist/hearth/control-plane/scheduler/schedule-store.d.ts +0 -32
- package/dist/hearth/control-plane/scheduler/schedule-store.js +0 -66
- package/dist/hearth/control-plane/secrets.d.ts +0 -31
- package/dist/hearth/control-plane/secrets.js +0 -134
- package/dist/hearth/control-plane/server.d.ts +0 -27
- package/dist/hearth/control-plane/server.js +0 -482
- package/dist/hearth/control-plane/serving.d.ts +0 -15
- package/dist/hearth/control-plane/serving.js +0 -106
- package/dist/hearth/control-plane/session.d.ts +0 -68
- package/dist/hearth/control-plane/session.js +0 -273
- package/dist/hearth/control-plane/triggers/acl.d.ts +0 -14
- package/dist/hearth/control-plane/triggers/acl.js +0 -52
- package/dist/hearth/control-plane/triggers/audit-store.d.ts +0 -38
- package/dist/hearth/control-plane/triggers/audit-store.js +0 -79
- package/dist/hearth/control-plane/triggers/deliver.d.ts +0 -43
- package/dist/hearth/control-plane/triggers/deliver.js +0 -76
- package/dist/hearth/control-plane/triggers/envelope.d.ts +0 -29
- package/dist/hearth/control-plane/triggers/envelope.js +0 -38
- package/dist/hearth/control-plane/types.d.ts +0 -86
- package/dist/hearth/control-plane/types.js +0 -1
- package/dist/hearth/control-plane/wake.d.ts +0 -86
- package/dist/hearth/control-plane/wake.js +0 -550
- package/dist/web-client/assets/index-DwO46Cs5.css +0 -2
- /package/dist/{core/__tests__/hearth-bootstrap.test.d.ts → commands/__tests__/revive-now-gate.test.d.ts} +0 -0
- /package/dist/{core/hearth/__tests__/model-auth-guest.test.d.ts → commands/sys/__tests__/setup-core.test.d.ts} +0 -0
- /package/dist/core/{hearth/providers/__tests__/sweep-and-release.test.d.ts → __tests__/fault-classifier.test.d.ts} +0 -0
- /package/dist/core/{hearth/providers/types.js → __tests__/host-teardown-process-group.test.d.ts} +0 -0
- /package/dist/core/{hearth/types.js → __tests__/review-model-floor.test.d.ts} +0 -0
- /package/dist/{hearth/control-plane/__tests__/error-serialization.test.d.ts → core/__tests__/session-cycles.test.d.ts} +0 -0
- /package/dist/{hearth/control-plane/__tests__/node-message.test.d.ts → core/__tests__/worktree.test.d.ts} +0 -0
|
@@ -1,60 +0,0 @@
|
|
|
1
|
-
import assert from 'node:assert/strict';
|
|
2
|
-
import { test } from 'node:test';
|
|
3
|
-
// Bug-regression test (CLAUDE.md "Testing policy" #2) — locks in the P5-review
|
|
4
|
-
// (mr1ganv3-e3401250) CRITICAL fix: `wakeAndExecNodeMessage` invoked a
|
|
5
|
-
// nonexistent `crtr node spine msg <target> --tier <tier>` command (no `node
|
|
6
|
-
// spine` branch exists, and the `msg` leaf takes its target via `--to`, not
|
|
7
|
-
// positionally). Every exec exited non-zero, so `deliverTrigger` recorded
|
|
8
|
-
// `failed` for every cron fire and webhook — the entire SEAM-5 delivery path
|
|
9
|
-
// was dead. This test fails if the argv regresses to `node spine msg` or a
|
|
10
|
-
// positional target.
|
|
11
|
-
test('wakeAndExecNodeMessage runs `crtr node msg --to <target> --tier <tier>` with the body on stdin', async () => {
|
|
12
|
-
const { wakeAndExecNodeMessage, nodeMessageInternals } = await import('../node-message.js');
|
|
13
|
-
const orig = { ...nodeMessageInternals };
|
|
14
|
-
const execCalls = [];
|
|
15
|
-
nodeMessageInternals.getHomeById = ((home_id) => ({
|
|
16
|
-
home_id,
|
|
17
|
-
tenant_id: 't1',
|
|
18
|
-
user_id: 'u1',
|
|
19
|
-
provider: 'blaxel',
|
|
20
|
-
provider_sandbox_id: 'sbx-1',
|
|
21
|
-
provider_volume_id: null,
|
|
22
|
-
preview_endpoint: null,
|
|
23
|
-
home_agent_target: 'home-agent-1',
|
|
24
|
-
relay_auth_ref: null,
|
|
25
|
-
status: 'running',
|
|
26
|
-
template_version: null,
|
|
27
|
-
last_ready_at: null,
|
|
28
|
-
last_wake_at: null,
|
|
29
|
-
last_suspend_at: null,
|
|
30
|
-
last_wake_latency_ms: null,
|
|
31
|
-
health_verdict: 'healthy',
|
|
32
|
-
created_at: '2026-06-30T00:00:00.000Z',
|
|
33
|
-
updated_at: '2026-06-30T00:00:00.000Z',
|
|
34
|
-
}));
|
|
35
|
-
nodeMessageInternals.ensureAwake = async () => ({ preview_endpoint: 'https://x.example', relayToken: 'tok' });
|
|
36
|
-
nodeMessageInternals.loadM0Config = (() => ({}));
|
|
37
|
-
nodeMessageInternals.getHomeBackend = (() => ({
|
|
38
|
-
wake: async () => ({ provider_sandbox_id: 'sbx-1', provider_volume_id: null, preview_endpoint: null }),
|
|
39
|
-
suspend: async () => ({ provider_sandbox_id: 'sbx-1', provider_volume_id: null, preview_endpoint: null }),
|
|
40
|
-
getMachine: async () => null,
|
|
41
|
-
exec: async (machineId, input) => {
|
|
42
|
-
execCalls.push({ machineId, argv: input.argv, stdin: input.stdin });
|
|
43
|
-
return { exitCode: 0, timedOut: false, stdout: '', stderr: '' };
|
|
44
|
-
},
|
|
45
|
-
}));
|
|
46
|
-
try {
|
|
47
|
-
await wakeAndExecNodeMessage('h1', '{"source":"cron","trigger_id":"f1"}', 'urgent');
|
|
48
|
-
assert.equal(execCalls.length, 1);
|
|
49
|
-
const call = execCalls[0];
|
|
50
|
-
assert.equal(call.machineId, 'sbx-1');
|
|
51
|
-
// The real CLI form: `crtr node msg --to <target> --tier <tier>` — never
|
|
52
|
-
// `node spine msg` and never a positional target.
|
|
53
|
-
assert.deepEqual(call.argv, ['crtr', 'node', 'msg', '--to', 'home-agent-1', '--tier', 'urgent']);
|
|
54
|
-
assert.equal(call.argv.includes('spine'), false);
|
|
55
|
-
assert.equal(call.stdin, '{"source":"cron","trigger_id":"f1"}');
|
|
56
|
-
}
|
|
57
|
-
finally {
|
|
58
|
-
Object.assign(nodeMessageInternals, orig);
|
|
59
|
-
}
|
|
60
|
-
});
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export {};
|
|
@@ -1,44 +0,0 @@
|
|
|
1
|
-
// Regression test for the P2 review Major fix: a stale, abandoned OAuth
|
|
2
|
-
// marker must not make a later `beginOAuth` on the same home read as
|
|
3
|
-
// non-serving. See serving.ts's `beginOAuth`/`oauthMarkerLive`.
|
|
4
|
-
import { test } from 'node:test';
|
|
5
|
-
import assert from 'node:assert/strict';
|
|
6
|
-
import { mock } from 'node:test';
|
|
7
|
-
import { beginOAuth, endOAuth, isHomeServing, resetServingForTests } from '../serving.js';
|
|
8
|
-
const HOME = 'home-oauth-marker-test';
|
|
9
|
-
const OAUTH_MARKER_TTL_MS = 5 * 60_000;
|
|
10
|
-
test('a second beginOAuth refreshes a stale marker and stays live until both finishes land', () => {
|
|
11
|
-
resetServingForTests();
|
|
12
|
-
mock.timers.enable({ apis: ['Date'] });
|
|
13
|
-
try {
|
|
14
|
-
// Start A begins an OAuth window; it is immediately serving.
|
|
15
|
-
beginOAuth(HOME);
|
|
16
|
-
assert.equal(isHomeServing(HOME), true, 'start A should mark the home as serving');
|
|
17
|
-
// Start A is abandoned: advance the clock past the 5-minute TTL with no
|
|
18
|
-
// matching endOAuth. The marker goes stale, but the counter (1) is still
|
|
19
|
-
// positive because start A was never finished.
|
|
20
|
-
mock.timers.tick(OAUTH_MARKER_TTL_MS + 1_000);
|
|
21
|
-
assert.equal(isHomeServing(HOME), false, 'a stale marker with no refresh must read as not-serving');
|
|
22
|
-
// Start B is a fresh retry on the same home. Per the fix, beginOAuth
|
|
23
|
-
// refreshes oauthStartedAt on EVERY call, not only when the counter
|
|
24
|
-
// transitions from 0 — so B's own window must read as live immediately.
|
|
25
|
-
beginOAuth(HOME);
|
|
26
|
-
assert.equal(isHomeServing(HOME), true, 'a second beginOAuth must refresh the marker and read as serving');
|
|
27
|
-
// B's window survives well past when A's original (unrefreshed) TTL
|
|
28
|
-
// would have expired, proving the refresh — not the original timestamp
|
|
29
|
-
// — is what's being judged now.
|
|
30
|
-
mock.timers.tick(OAUTH_MARKER_TTL_MS - 1_000);
|
|
31
|
-
assert.equal(isHomeServing(HOME), true, 'B\'s window must still be live before its own TTL elapses');
|
|
32
|
-
// A late finish for start A arrives: counter floors from 2 -> 1. Still
|
|
33
|
-
// serving, because B's own start/finish window has not closed yet.
|
|
34
|
-
endOAuth(HOME);
|
|
35
|
-
assert.equal(isHomeServing(HOME), true, 'one matched finish (of two starts) must not end serving early');
|
|
36
|
-
// B finishes: counter 1 -> 0, marker cleared, no longer serving.
|
|
37
|
-
endOAuth(HOME);
|
|
38
|
-
assert.equal(isHomeServing(HOME), false, 'both starts matched by a finish must end serving');
|
|
39
|
-
}
|
|
40
|
-
finally {
|
|
41
|
-
mock.timers.reset();
|
|
42
|
-
resetServingForTests();
|
|
43
|
-
}
|
|
44
|
-
});
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export {};
|
|
@@ -1,49 +0,0 @@
|
|
|
1
|
-
import assert from 'node:assert/strict';
|
|
2
|
-
import { test } from 'node:test';
|
|
3
|
-
import { createRateLimiter } from '../ingress/rate-limit.js';
|
|
4
|
-
import { computeNextFire } from '../scheduler/recurrence.js';
|
|
5
|
-
// Category-2 (rate limiter) + category-3 (recurrence) test. The rate-limiter
|
|
6
|
-
// case locks the fix for an observed bug found during P5.5 implementation: an
|
|
7
|
-
// over-eager idle-prune deleted a per-key bucket right after its first draw,
|
|
8
|
-
// so per-key limiting never actually bit. The recurrence case locks the custom
|
|
9
|
-
// interval-parsing + timezone plumbing (my code, not cron-parser's) that cron
|
|
10
|
-
// firing depends on.
|
|
11
|
-
test('the per-key token bucket actually limits a burst and refills over time', () => {
|
|
12
|
-
let nowMs = 1_000_000;
|
|
13
|
-
const limiter = createRateLimiter({ capacity: 3, refillPerSec: 1, globalCapacity: 1_000, globalRefillPerSec: 1_000, now: () => nowMs });
|
|
14
|
-
// Three tokens available for one key, then it trips (the prune bug would let
|
|
15
|
-
// every request through by resetting the bucket to full each call).
|
|
16
|
-
assert.equal(limiter.checkRate('k').ok, true);
|
|
17
|
-
assert.equal(limiter.checkRate('k').ok, true);
|
|
18
|
-
assert.equal(limiter.checkRate('k').ok, true);
|
|
19
|
-
const tripped = limiter.checkRate('k');
|
|
20
|
-
assert.equal(tripped.ok, false);
|
|
21
|
-
assert.equal(typeof tripped.retryAfterMs, 'number');
|
|
22
|
-
// A different key has its own independent bucket.
|
|
23
|
-
assert.equal(limiter.checkRate('other').ok, true);
|
|
24
|
-
// After ~2s one key refills 2 tokens.
|
|
25
|
-
nowMs += 2_000;
|
|
26
|
-
assert.equal(limiter.checkRate('k').ok, true);
|
|
27
|
-
assert.equal(limiter.checkRate('k').ok, true);
|
|
28
|
-
assert.equal(limiter.checkRate('k').ok, false);
|
|
29
|
-
});
|
|
30
|
-
test('the global ceiling sheds a flood spread across many distinct keys', () => {
|
|
31
|
-
let nowMs = 0;
|
|
32
|
-
const limiter = createRateLimiter({ capacity: 100, refillPerSec: 100, globalCapacity: 5, globalRefillPerSec: 0.0001, now: () => nowMs });
|
|
33
|
-
let ok = 0;
|
|
34
|
-
for (let i = 0; i < 20; i++) {
|
|
35
|
-
if (limiter.checkRate(`key-${i}`).ok)
|
|
36
|
-
ok += 1;
|
|
37
|
-
}
|
|
38
|
-
assert.equal(ok, 5); // global ceiling caps total admits regardless of key spread
|
|
39
|
-
});
|
|
40
|
-
test('computeNextFire handles cron expressions, timezones, and @every intervals', () => {
|
|
41
|
-
assert.equal(computeNextFire('*/5 * * * *', 'UTC', '2026-06-30T12:01:00.000Z'), '2026-06-30T12:05:00.000Z');
|
|
42
|
-
// Interval form is timezone-independent, strictly after `after`.
|
|
43
|
-
assert.equal(computeNextFire('@every 30m', 'America/New_York', '2026-06-30T12:00:00.000Z'), '2026-06-30T12:30:00.000Z');
|
|
44
|
-
assert.equal(computeNextFire('@every 90s', 'UTC', '2026-06-30T12:00:00.000Z'), '2026-06-30T12:01:30.000Z');
|
|
45
|
-
// A tz-bearing daily schedule resolves to the correct UTC instant: 09:00
|
|
46
|
-
// EDT the same day is 13:00 UTC (after 08:00 EDT / 12:00 UTC).
|
|
47
|
-
assert.equal(computeNextFire('0 9 * * *', 'America/New_York', '2026-06-30T12:00:00.000Z'), '2026-06-30T13:00:00.000Z');
|
|
48
|
-
assert.throws(() => computeNextFire('not a cron', 'UTC', '2026-06-30T12:00:00.000Z'));
|
|
49
|
-
});
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export {};
|
|
@@ -1,314 +0,0 @@
|
|
|
1
|
-
import assert from 'node:assert/strict';
|
|
2
|
-
import { EventEmitter } from 'node:events';
|
|
3
|
-
import http from 'node:http';
|
|
4
|
-
import { test } from 'node:test';
|
|
5
|
-
import { WebSocket, WebSocketServer } from 'ws';
|
|
6
|
-
import { buildUpstreamHttpUrl, proxyHttp, proxyUpgrade } from '../relay.js';
|
|
7
|
-
// Category-3 test (CLAUDE.md "Testing policy" #3 — load-bearing Hearth/CP
|
|
8
|
-
// security regression). Approved by Silas 2026-06-30 as a narrow carve-out
|
|
9
|
-
// after the P4.3 relay review (mr16xp6d-2a5061f5 / mr16z2lz-3cd35b0c) found
|
|
10
|
-
// these exact invariants had zero durable coverage — only a throwaway smoke
|
|
11
|
-
// that was deleted after the implementation pass. The invariants below are
|
|
12
|
-
// the ones part-plan T3.2's acceptance checks and design.md's R-CP-2/R-CP-3/
|
|
13
|
-
// R-CP-6 non-negotiables name explicitly:
|
|
14
|
-
// - R-CP-3: the browser leg never supplies, and never sees reflected, the
|
|
15
|
-
// CP->home relay token; the home leg sees ONLY `Authorization: Bearer
|
|
16
|
-
// <relayToken>`.
|
|
17
|
-
// - criterion 4 (origin-form-only request target): scheme-relative,
|
|
18
|
-
// absolute, raw/encoded-backslash, and encoded-CRLF request targets are
|
|
19
|
-
// rejected before any upstream contact (open-proxy/SSRF prevention).
|
|
20
|
-
// - R-CP-2/R-CP-6: `home` is resolved once, server-side, from the
|
|
21
|
-
// caller's session; no client-supplied `?target=`/`/node/<id>` can move
|
|
22
|
-
// the request onto a different home's preview origin or node target.
|
|
23
|
-
// A regression in any of these is silent (still typechecks, still serves
|
|
24
|
-
// chat) and is exactly the kind of defect this carve-out exists for.
|
|
25
|
-
const testConfig = {
|
|
26
|
-
host: '127.0.0.1',
|
|
27
|
-
port: 0,
|
|
28
|
-
publicOrigin: 'http://127.0.0.1:0',
|
|
29
|
-
cookieSecure: false,
|
|
30
|
-
wakeTimeoutMs: 2_000,
|
|
31
|
-
readyTimeoutMs: 2_000,
|
|
32
|
-
};
|
|
33
|
-
function makeHome(overrides = {}) {
|
|
34
|
-
const now = '2026-06-30T00:00:00.000Z';
|
|
35
|
-
return {
|
|
36
|
-
home_id: 'home-a',
|
|
37
|
-
tenant_id: 'tenant-1',
|
|
38
|
-
user_id: 'user-1',
|
|
39
|
-
provider: 'blaxel',
|
|
40
|
-
provider_sandbox_id: 'sandbox-a',
|
|
41
|
-
provider_volume_id: 'volume-a',
|
|
42
|
-
preview_endpoint: 'https://home-a.example.invalid',
|
|
43
|
-
home_agent_target: 'node-a',
|
|
44
|
-
relay_auth_ref: 'ref-a',
|
|
45
|
-
status: 'running',
|
|
46
|
-
template_version: null,
|
|
47
|
-
last_ready_at: null,
|
|
48
|
-
last_wake_at: null,
|
|
49
|
-
last_suspend_at: null,
|
|
50
|
-
last_wake_latency_ms: null,
|
|
51
|
-
health_verdict: 'healthy',
|
|
52
|
-
created_at: now,
|
|
53
|
-
updated_at: now,
|
|
54
|
-
...overrides,
|
|
55
|
-
};
|
|
56
|
-
}
|
|
57
|
-
// --- Minimal req/res fakes (same shape as wake-proxy/__tests__'s FakeReq/
|
|
58
|
-
// FakeRes — only the surface proxyHttp actually touches). ------------------
|
|
59
|
-
class FakeReq extends EventEmitter {
|
|
60
|
-
url;
|
|
61
|
-
method;
|
|
62
|
-
headers;
|
|
63
|
-
constructor(path, headers = {}, method = 'GET') {
|
|
64
|
-
super();
|
|
65
|
-
this.url = path;
|
|
66
|
-
this.method = method;
|
|
67
|
-
this.headers = headers;
|
|
68
|
-
}
|
|
69
|
-
}
|
|
70
|
-
class FakeRes extends EventEmitter {
|
|
71
|
-
statusCode = 200;
|
|
72
|
-
statusMessage = '';
|
|
73
|
-
headersSent = false;
|
|
74
|
-
writableEnded = false;
|
|
75
|
-
headers = new Map();
|
|
76
|
-
chunks = [];
|
|
77
|
-
socket = { setNoDelay() { } };
|
|
78
|
-
setHeader(name, value) {
|
|
79
|
-
this.headers.set(name.toLowerCase(), value);
|
|
80
|
-
}
|
|
81
|
-
getHeader(name) {
|
|
82
|
-
return this.headers.get(name.toLowerCase());
|
|
83
|
-
}
|
|
84
|
-
hasHeader(name) {
|
|
85
|
-
return this.headers.has(name.toLowerCase());
|
|
86
|
-
}
|
|
87
|
-
writeHead(code, headers) {
|
|
88
|
-
this.statusCode = code;
|
|
89
|
-
this.headersSent = true;
|
|
90
|
-
if (headers)
|
|
91
|
-
for (const [k, v] of Object.entries(headers))
|
|
92
|
-
this.setHeader(k, v);
|
|
93
|
-
}
|
|
94
|
-
flushHeaders() {
|
|
95
|
-
this.headersSent = true;
|
|
96
|
-
}
|
|
97
|
-
write(chunk) {
|
|
98
|
-
this.headersSent = true;
|
|
99
|
-
this.chunks.push(Buffer.from(chunk));
|
|
100
|
-
return true;
|
|
101
|
-
}
|
|
102
|
-
end(chunk) {
|
|
103
|
-
this.headersSent = true;
|
|
104
|
-
if (chunk !== undefined)
|
|
105
|
-
this.chunks.push(Buffer.from(chunk));
|
|
106
|
-
this.writableEnded = true;
|
|
107
|
-
this.emit('finish');
|
|
108
|
-
}
|
|
109
|
-
destroy() {
|
|
110
|
-
this.writableEnded = true;
|
|
111
|
-
}
|
|
112
|
-
body() {
|
|
113
|
-
return Buffer.concat(this.chunks).toString('utf8');
|
|
114
|
-
}
|
|
115
|
-
}
|
|
116
|
-
function asReq(req) {
|
|
117
|
-
return req;
|
|
118
|
-
}
|
|
119
|
-
function asRes(res) {
|
|
120
|
-
return res;
|
|
121
|
-
}
|
|
122
|
-
async function listen(server) {
|
|
123
|
-
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
|
124
|
-
const address = server.address();
|
|
125
|
-
if (address === null || typeof address === 'string')
|
|
126
|
-
throw new Error('expected a network address');
|
|
127
|
-
return {
|
|
128
|
-
origin: `http://127.0.0.1:${address.port}`,
|
|
129
|
-
close: () => new Promise((resolve) => server.close(() => resolve())),
|
|
130
|
-
};
|
|
131
|
-
}
|
|
132
|
-
// --- 1. HTTP browser->home header/token isolation -------------------------
|
|
133
|
-
test('proxyHttp: browser auth/cookie/forwarding headers never reach upstream; only the relay Bearer token does; the token is never reflected to the browser', async () => {
|
|
134
|
-
let captured = null;
|
|
135
|
-
const upstream = http.createServer((req, res) => {
|
|
136
|
-
captured = req.headers;
|
|
137
|
-
// Deliberately reflect the relay token back in the response, the way a
|
|
138
|
-
// misbehaving or compromised home might — this is what makes the
|
|
139
|
-
// browser-side assertion below non-vacuous.
|
|
140
|
-
res.writeHead(200, { 'content-type': 'text/plain', authorization: 'Bearer RELAY_TOKEN_A' });
|
|
141
|
-
res.end('upstream-ok');
|
|
142
|
-
});
|
|
143
|
-
const { origin, close } = await listen(upstream);
|
|
144
|
-
try {
|
|
145
|
-
const req = new FakeReq('/views/chat', {
|
|
146
|
-
authorization: 'Bearer BROWSER_OWN_TOKEN',
|
|
147
|
-
cookie: 'session=browser-cookie',
|
|
148
|
-
forwarded: 'for=1.2.3.4;proto=https',
|
|
149
|
-
'x-forwarded-for': '1.2.3.4',
|
|
150
|
-
'x-forwarded-proto': 'https',
|
|
151
|
-
'x-forwarded-host': 'evil.example',
|
|
152
|
-
'x-real-ip': '1.2.3.4',
|
|
153
|
-
'proxy-authorization': 'Basic evil',
|
|
154
|
-
'sec-websocket-key': 'not-a-real-ws-request',
|
|
155
|
-
'x-keep-me': 'safe-custom-header',
|
|
156
|
-
});
|
|
157
|
-
const res = new FakeRes();
|
|
158
|
-
await proxyHttp(asReq(req), asRes(res), makeHome(), {
|
|
159
|
-
ensureAwake: async () => ({ preview_endpoint: origin, relayToken: 'RELAY_TOKEN_A' }),
|
|
160
|
-
resolveRunning: () => null,
|
|
161
|
-
invalidateWarmVerdict: () => undefined,
|
|
162
|
-
loadConfig: () => testConfig,
|
|
163
|
-
});
|
|
164
|
-
assert.equal(res.statusCode, 200);
|
|
165
|
-
assert.equal(res.body(), 'upstream-ok');
|
|
166
|
-
assert.ok(captured, 'upstream must have received the forwarded request');
|
|
167
|
-
const upstreamHeaders = captured;
|
|
168
|
-
assert.equal(upstreamHeaders.authorization, 'Bearer RELAY_TOKEN_A', 'upstream must see only the relay token');
|
|
169
|
-
assert.equal(upstreamHeaders.cookie, undefined, 'browser cookie must not reach upstream');
|
|
170
|
-
assert.equal(upstreamHeaders.forwarded, undefined, 'client-supplied Forwarded must not reach upstream');
|
|
171
|
-
assert.equal(upstreamHeaders['x-forwarded-for'], undefined);
|
|
172
|
-
assert.equal(upstreamHeaders['x-forwarded-proto'], undefined);
|
|
173
|
-
assert.equal(upstreamHeaders['x-forwarded-host'], undefined);
|
|
174
|
-
assert.equal(upstreamHeaders['x-real-ip'], undefined);
|
|
175
|
-
assert.equal(upstreamHeaders['proxy-authorization'], undefined);
|
|
176
|
-
assert.equal(upstreamHeaders['sec-websocket-key'], undefined);
|
|
177
|
-
assert.equal(upstreamHeaders['x-keep-me'], 'safe-custom-header', 'a safe custom header must still pass through');
|
|
178
|
-
assert.equal(res.getHeader('authorization'), undefined, 'an upstream-reflected relay token must never reach the browser response');
|
|
179
|
-
}
|
|
180
|
-
finally {
|
|
181
|
-
await close();
|
|
182
|
-
}
|
|
183
|
-
});
|
|
184
|
-
// --- 2. Origin-form / SSRF rejection --------------------------------------
|
|
185
|
-
test('buildUpstreamHttpUrl: rejects dangerous request targets before any upstream contact', () => {
|
|
186
|
-
const previewEndpoint = 'https://home-a.example.invalid';
|
|
187
|
-
const dangerous = [
|
|
188
|
-
'//evil.example.com/path',
|
|
189
|
-
'http://evil.example.com/path',
|
|
190
|
-
'\\evil.example.com/path',
|
|
191
|
-
'%5Cevil.example.com/path',
|
|
192
|
-
'%0d%0aHost:%20evil.example.com',
|
|
193
|
-
];
|
|
194
|
-
for (const target of dangerous) {
|
|
195
|
-
assert.throws(() => buildUpstreamHttpUrl(previewEndpoint, target), `expected rejection for: ${target}`);
|
|
196
|
-
}
|
|
197
|
-
});
|
|
198
|
-
test('buildUpstreamHttpUrl: accepts an ordinary origin-form target and stays on the home origin', () => {
|
|
199
|
-
const url = buildUpstreamHttpUrl('https://home-a.example.invalid', '/views/chat?x=1');
|
|
200
|
-
assert.equal(url.origin, 'https://home-a.example.invalid');
|
|
201
|
-
assert.equal(url.pathname, '/views/chat');
|
|
202
|
-
});
|
|
203
|
-
// --- 3. Cross-home/target isolation ---------------------------------------
|
|
204
|
-
test('proxyHttp: a session-resolved home A is never widened by a client ?target= to home B', async () => {
|
|
205
|
-
let hitsA = 0;
|
|
206
|
-
let hitsB = 0;
|
|
207
|
-
const upstreamA = http.createServer((_req, res) => {
|
|
208
|
-
hitsA += 1;
|
|
209
|
-
res.writeHead(200);
|
|
210
|
-
res.end('home-a-ok');
|
|
211
|
-
});
|
|
212
|
-
const upstreamB = http.createServer((_req, res) => {
|
|
213
|
-
hitsB += 1;
|
|
214
|
-
res.writeHead(200);
|
|
215
|
-
res.end('home-b-ok');
|
|
216
|
-
});
|
|
217
|
-
const a = await listen(upstreamA);
|
|
218
|
-
const b = await listen(upstreamB);
|
|
219
|
-
try {
|
|
220
|
-
const req = new FakeReq('/views/chat?target=home-b');
|
|
221
|
-
const res = new FakeRes();
|
|
222
|
-
let ensureAwakeAskedFor = null;
|
|
223
|
-
let resolveRunningAskedFor = null;
|
|
224
|
-
await proxyHttp(asReq(req), asRes(res), makeHome({ home_id: 'home-a' }), {
|
|
225
|
-
ensureAwake: async (home_id) => {
|
|
226
|
-
ensureAwakeAskedFor = home_id;
|
|
227
|
-
// The relay's only home-selection input is the `home` row it was
|
|
228
|
-
// called with — never the request's own query string.
|
|
229
|
-
if (home_id !== 'home-a')
|
|
230
|
-
return { preview_endpoint: b.origin, relayToken: 'RELAY_TOKEN_B' };
|
|
231
|
-
return { preview_endpoint: a.origin, relayToken: 'RELAY_TOKEN_A' };
|
|
232
|
-
},
|
|
233
|
-
resolveRunning: (home_id) => {
|
|
234
|
-
resolveRunningAskedFor = home_id;
|
|
235
|
-
return null;
|
|
236
|
-
},
|
|
237
|
-
invalidateWarmVerdict: () => undefined,
|
|
238
|
-
loadConfig: () => testConfig,
|
|
239
|
-
});
|
|
240
|
-
assert.equal(resolveRunningAskedFor, 'home-a', 'resolveRunning must be asked for the session-resolved home, never a client-supplied target');
|
|
241
|
-
assert.equal(ensureAwakeAskedFor, 'home-a', 'ensureAwake must be asked for the session-resolved home, never a client-supplied target');
|
|
242
|
-
assert.equal(res.body(), 'home-a-ok');
|
|
243
|
-
assert.equal(hitsA, 1);
|
|
244
|
-
assert.equal(hitsB, 0, 'home B must never be contacted for a home-A-resolved request');
|
|
245
|
-
}
|
|
246
|
-
finally {
|
|
247
|
-
await Promise.all([a.close(), b.close()]);
|
|
248
|
-
}
|
|
249
|
-
});
|
|
250
|
-
test('proxyUpgrade: WS upstream target is always home.home_agent_target, never the client-claimed /node/<id> path or ?target=', async () => {
|
|
251
|
-
let upstreamAHits = [];
|
|
252
|
-
let upstreamBHits = 0;
|
|
253
|
-
const wssA = new WebSocketServer({ port: 0 });
|
|
254
|
-
wssA.on('connection', (ws, req) => {
|
|
255
|
-
upstreamAHits.push({ url: req.url, authorization: req.headers.authorization, cookie: req.headers.cookie });
|
|
256
|
-
ws.on('message', (data) => ws.send(data));
|
|
257
|
-
});
|
|
258
|
-
const wssB = new WebSocketServer({ port: 0 });
|
|
259
|
-
wssB.on('connection', () => {
|
|
260
|
-
upstreamBHits += 1;
|
|
261
|
-
});
|
|
262
|
-
const aPort = wssA.address().port;
|
|
263
|
-
const bPort = wssB.address().port;
|
|
264
|
-
const home = makeHome({ home_id: 'home-a', home_agent_target: 'node-a' });
|
|
265
|
-
let ensureAwakeAskedFor = null;
|
|
266
|
-
const relayServer = http.createServer((_req, res) => {
|
|
267
|
-
res.writeHead(404);
|
|
268
|
-
res.end();
|
|
269
|
-
});
|
|
270
|
-
relayServer.on('upgrade', (req, socket, head) => {
|
|
271
|
-
proxyUpgrade(req, socket, head, home, {
|
|
272
|
-
ensureAwake: async (home_id) => {
|
|
273
|
-
ensureAwakeAskedFor = home_id;
|
|
274
|
-
// Same DI seam as the HTTP cross-home test: a regression that derived
|
|
275
|
-
// the wake target from the client's `?target=` would make this
|
|
276
|
-
// resolve to home B's listener, and the assertions below would catch
|
|
277
|
-
// the resulting connection landing on wssB instead of wssA.
|
|
278
|
-
if (home_id !== 'home-a')
|
|
279
|
-
return { preview_endpoint: `http://127.0.0.1:${bPort}`, relayToken: 'RELAY_TOKEN_B' };
|
|
280
|
-
return { preview_endpoint: `http://127.0.0.1:${aPort}`, relayToken: 'RELAY_TOKEN_A' };
|
|
281
|
-
},
|
|
282
|
-
loadConfig: () => testConfig,
|
|
283
|
-
});
|
|
284
|
-
});
|
|
285
|
-
const relay = await listen(relayServer);
|
|
286
|
-
let client = null;
|
|
287
|
-
try {
|
|
288
|
-
client = new WebSocket(`${relay.origin.replace('http://', 'ws://')}/node/some-other-client-claimed-id?target=home-b`, {
|
|
289
|
-
headers: { authorization: 'Bearer BROWSER_OWN_TOKEN', cookie: 'session=browser-cookie' },
|
|
290
|
-
});
|
|
291
|
-
const echoed = await new Promise((resolve, reject) => {
|
|
292
|
-
client.once('open', () => client.send('ping'));
|
|
293
|
-
client.once('message', (data) => resolve(data.toString()));
|
|
294
|
-
client.once('error', reject);
|
|
295
|
-
});
|
|
296
|
-
assert.equal(echoed, 'ping');
|
|
297
|
-
assert.equal(ensureAwakeAskedFor, 'home-a', 'ensureAwake must be asked for the session-resolved home, never the client-claimed path/target');
|
|
298
|
-
assert.equal(upstreamAHits.length, 1);
|
|
299
|
-
assert.equal(upstreamAHits[0]?.url, '/node/node-a', 'upstream path must be the server-resolved home_agent_target, not the client path/target');
|
|
300
|
-
assert.equal(upstreamAHits[0]?.authorization, 'Bearer RELAY_TOKEN_A');
|
|
301
|
-
assert.equal(upstreamAHits[0]?.cookie, undefined, 'the browser cookie must never reach the upstream websocket');
|
|
302
|
-
assert.equal(upstreamBHits, 0, 'home B must never receive a connection for a home-A-resolved request');
|
|
303
|
-
}
|
|
304
|
-
finally {
|
|
305
|
-
if (client) {
|
|
306
|
-
if (client.readyState === WebSocket.OPEN || client.readyState === WebSocket.CONNECTING) {
|
|
307
|
-
client.terminate();
|
|
308
|
-
}
|
|
309
|
-
}
|
|
310
|
-
await new Promise((resolve) => wssA.close(() => resolve()));
|
|
311
|
-
await new Promise((resolve) => wssB.close(() => resolve()));
|
|
312
|
-
await relay.close();
|
|
313
|
-
}
|
|
314
|
-
});
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export {};
|
|
@@ -1,133 +0,0 @@
|
|
|
1
|
-
import assert from 'node:assert/strict';
|
|
2
|
-
import { test } from 'node:test';
|
|
3
|
-
import { runSchedulerScan } from '../scheduler/scan-loop.js';
|
|
4
|
-
// Category-3 test (CLAUDE.md "Testing policy" #3 — load-bearing Hearth/CP
|
|
5
|
-
// regression). Invariant: master plan P5.4 / part-plan T-C1 R-CRON-3 — the CP
|
|
6
|
-
// cron scan is idempotent and restart-safe. A due schedule fires exactly once
|
|
7
|
-
// per slot even across repeated scans, and a fire left `waking` by a crashed
|
|
8
|
-
// CP is re-driven to a SINGLE delivery, never a double. A regression here is
|
|
9
|
-
// silent (still typechecks) and directly re-introduces the double-fire failure
|
|
10
|
-
// mode this ledger exists to prevent.
|
|
11
|
-
const SCHEDULE = {
|
|
12
|
-
schedule_id: 's1',
|
|
13
|
-
tenant_id: 't1',
|
|
14
|
-
user_id: 'u1',
|
|
15
|
-
home_id: 'h1',
|
|
16
|
-
label: 'nightly',
|
|
17
|
-
recurrence: '*/5 * * * *',
|
|
18
|
-
timezone: 'UTC',
|
|
19
|
-
delivery_payload: { hello: 'world' },
|
|
20
|
-
next_fire_at: '2026-06-30T12:00:00.000Z',
|
|
21
|
-
last_fired_at: null,
|
|
22
|
-
enabled: true,
|
|
23
|
-
created_at: '2026-06-30T00:00:00.000Z',
|
|
24
|
-
updated_at: '2026-06-30T00:00:00.000Z',
|
|
25
|
-
};
|
|
26
|
-
function fireFor(schedule, state) {
|
|
27
|
-
return {
|
|
28
|
-
fire_id: 'f1',
|
|
29
|
-
schedule_id: schedule.schedule_id,
|
|
30
|
-
home_id: schedule.home_id,
|
|
31
|
-
tenant_id: schedule.tenant_id,
|
|
32
|
-
user_id: schedule.user_id,
|
|
33
|
-
scheduled_for: schedule.next_fire_at,
|
|
34
|
-
state,
|
|
35
|
-
attempts: 0,
|
|
36
|
-
last_error: null,
|
|
37
|
-
delivered_at: null,
|
|
38
|
-
created_at: schedule.created_at,
|
|
39
|
-
updated_at: schedule.updated_at,
|
|
40
|
-
};
|
|
41
|
-
}
|
|
42
|
-
function makeDeps(rec, overrides, outcome = { outcome: 'delivered' }) {
|
|
43
|
-
return {
|
|
44
|
-
selectDueSchedules: () => [],
|
|
45
|
-
getScheduleById: (id) => (id === SCHEDULE.schedule_id ? SCHEDULE : null),
|
|
46
|
-
claimFire: (input) => {
|
|
47
|
-
rec.claimCount += 1;
|
|
48
|
-
return { fire: fireFor(SCHEDULE, 'pending'), claimed: rec.claimCount === 1 };
|
|
49
|
-
},
|
|
50
|
-
setFireState: (fire_id, state) => {
|
|
51
|
-
rec.fireStates.push({ fire_id, state });
|
|
52
|
-
},
|
|
53
|
-
advanceNextFire: (schedule_id, next, last) => {
|
|
54
|
-
rec.advances.push({ schedule_id, next, last });
|
|
55
|
-
},
|
|
56
|
-
selectResumableFires: () => [],
|
|
57
|
-
computeNextFire: () => '2026-06-30T12:05:00.000Z',
|
|
58
|
-
deliverTrigger: async (input) => {
|
|
59
|
-
rec.deliveries.push({
|
|
60
|
-
home_id: input.home_id,
|
|
61
|
-
source_ref: input.source_ref,
|
|
62
|
-
trigger_id: input.envelope.trigger_id,
|
|
63
|
-
tier: input.tier,
|
|
64
|
-
source: input.envelope.source,
|
|
65
|
-
payload: input.envelope.payload,
|
|
66
|
-
});
|
|
67
|
-
return outcome;
|
|
68
|
-
},
|
|
69
|
-
now: () => '2026-06-30T12:00:01.000Z',
|
|
70
|
-
intervalMs: 15_000,
|
|
71
|
-
...overrides,
|
|
72
|
-
};
|
|
73
|
-
}
|
|
74
|
-
function makeRecorder() {
|
|
75
|
-
return { deliveries: [], fireStates: [], advances: [], claimCount: 0 };
|
|
76
|
-
}
|
|
77
|
-
test('a due schedule fires exactly once with urgent tier and trigger_id == fire_id', async () => {
|
|
78
|
-
const rec = makeRecorder();
|
|
79
|
-
const deps = makeDeps(rec, { selectDueSchedules: () => [SCHEDULE] });
|
|
80
|
-
await runSchedulerScan(deps);
|
|
81
|
-
assert.equal(rec.deliveries.length, 1);
|
|
82
|
-
const d = rec.deliveries[0];
|
|
83
|
-
assert.equal(d.tier, 'urgent');
|
|
84
|
-
assert.equal(d.source, 'cron');
|
|
85
|
-
assert.equal(d.trigger_id, 'f1'); // R-CRON-5: envelope trigger_id is the durable fire_id
|
|
86
|
-
assert.equal(d.home_id, 'h1');
|
|
87
|
-
assert.deepEqual(d.payload, { hello: 'world' });
|
|
88
|
-
// next_fire_at advanced before delivery so the slot cannot be re-selected.
|
|
89
|
-
assert.deepEqual(rec.advances, [{ schedule_id: 's1', next: '2026-06-30T12:05:00.000Z', last: '2026-06-30T12:00:00.000Z' }]);
|
|
90
|
-
assert.deepEqual(rec.fireStates.map((s) => s.state), ['waking', 'delivered']);
|
|
91
|
-
});
|
|
92
|
-
test('the same due slot is claimed only once across repeated scans (no double fire)', async () => {
|
|
93
|
-
const rec = makeRecorder();
|
|
94
|
-
// Both scans see the schedule as due, but claimFire grants the slot once.
|
|
95
|
-
const deps = makeDeps(rec, { selectDueSchedules: () => [SCHEDULE] });
|
|
96
|
-
await runSchedulerScan(deps);
|
|
97
|
-
await runSchedulerScan(deps);
|
|
98
|
-
assert.equal(rec.claimCount, 2); // both passes attempted a claim
|
|
99
|
-
assert.equal(rec.deliveries.length, 1); // only the winning claim delivered
|
|
100
|
-
});
|
|
101
|
-
test('a fire left waking by a crashed CP is resumed to a single delivery, not doubled', async () => {
|
|
102
|
-
const rec = makeRecorder();
|
|
103
|
-
let resumables = [fireFor(SCHEDULE, 'waking')];
|
|
104
|
-
const deps = makeDeps(rec, {
|
|
105
|
-
selectResumableFires: () => {
|
|
106
|
-
const out = resumables;
|
|
107
|
-
resumables = []; // next scan finds it resolved
|
|
108
|
-
return out;
|
|
109
|
-
},
|
|
110
|
-
selectDueSchedules: () => [], // slot already advanced/claimed pre-crash
|
|
111
|
-
});
|
|
112
|
-
await runSchedulerScan(deps);
|
|
113
|
-
await runSchedulerScan(deps);
|
|
114
|
-
assert.equal(rec.claimCount, 0); // resume path never re-claims
|
|
115
|
-
assert.equal(rec.deliveries.length, 1); // exactly one delivery for the stranded fire
|
|
116
|
-
assert.equal(rec.deliveries[0].trigger_id, 'f1');
|
|
117
|
-
});
|
|
118
|
-
test('a failed delivery marks the fire failed with an incremented attempt, no retry storm', async () => {
|
|
119
|
-
const rec = makeRecorder();
|
|
120
|
-
const attemptFlags = [];
|
|
121
|
-
const deps = makeDeps(rec, {
|
|
122
|
-
selectDueSchedules: () => [SCHEDULE],
|
|
123
|
-
setFireState: (fire_id, state, options) => {
|
|
124
|
-
rec.fireStates.push({ fire_id, state });
|
|
125
|
-
if (options?.increment_attempts !== undefined)
|
|
126
|
-
attemptFlags.push(options.increment_attempts);
|
|
127
|
-
},
|
|
128
|
-
}, { outcome: 'failed', reason: 'boom' });
|
|
129
|
-
await runSchedulerScan(deps);
|
|
130
|
-
assert.deepEqual(rec.fireStates.map((s) => s.state), ['waking', 'failed']);
|
|
131
|
-
assert.deepEqual(attemptFlags, [true]);
|
|
132
|
-
assert.equal(rec.deliveries.length, 1); // one attempt; the natural next tick is the only re-fire
|
|
133
|
-
});
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export {};
|