@elaraai/e3-rack 0.0.0-bootstrap.0 → 1.0.94
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 +407 -2
- package/dist/src/client/client.spec.d.ts +6 -0
- package/dist/src/client/client.spec.d.ts.map +1 -0
- package/dist/src/client/client.spec.js +150 -0
- package/dist/src/client/client.spec.js.map +1 -0
- package/dist/src/client/connect.d.ts +52 -0
- package/dist/src/client/connect.d.ts.map +1 -0
- package/dist/src/client/connect.js +112 -0
- package/dist/src/client/connect.js.map +1 -0
- package/dist/src/client/hub-client.d.ts +57 -0
- package/dist/src/client/hub-client.d.ts.map +1 -0
- package/dist/src/client/hub-client.js +87 -0
- package/dist/src/client/hub-client.js.map +1 -0
- package/dist/src/client/local-http.d.ts +78 -0
- package/dist/src/client/local-http.d.ts.map +1 -0
- package/dist/src/client/local-http.js +149 -0
- package/dist/src/client/local-http.js.map +1 -0
- package/dist/src/client/session-attempts.d.ts +20 -0
- package/dist/src/client/session-attempts.d.ts.map +1 -0
- package/dist/src/client/session-attempts.js +88 -0
- package/dist/src/client/session-attempts.js.map +1 -0
- package/dist/src/client/session-data-server.d.ts +37 -0
- package/dist/src/client/session-data-server.d.ts.map +1 -0
- package/dist/src/client/session-data-server.js +280 -0
- package/dist/src/client/session-data-server.js.map +1 -0
- package/dist/src/client/session-data-server.spec.d.ts +6 -0
- package/dist/src/client/session-data-server.spec.d.ts.map +1 -0
- package/dist/src/client/session-data-server.spec.js +192 -0
- package/dist/src/client/session-data-server.spec.js.map +1 -0
- package/dist/src/client/session.d.ts +119 -0
- package/dist/src/client/session.d.ts.map +1 -0
- package/dist/src/client/session.js +290 -0
- package/dist/src/client/session.js.map +1 -0
- package/dist/src/hub/config.d.ts +74 -0
- package/dist/src/hub/config.d.ts.map +1 -0
- package/dist/src/hub/config.js +159 -0
- package/dist/src/hub/config.js.map +1 -0
- package/dist/src/hub/fair-lease-store.d.ts +36 -0
- package/dist/src/hub/fair-lease-store.d.ts.map +1 -0
- package/dist/src/hub/fair-lease-store.js +73 -0
- package/dist/src/hub/fair-lease-store.js.map +1 -0
- package/dist/src/hub/hub.d.ts +91 -0
- package/dist/src/hub/hub.d.ts.map +1 -0
- package/dist/src/hub/hub.js +641 -0
- package/dist/src/hub/hub.js.map +1 -0
- package/dist/src/hub/hub.spec.d.ts +6 -0
- package/dist/src/hub/hub.spec.d.ts.map +1 -0
- package/dist/src/hub/hub.spec.js +363 -0
- package/dist/src/hub/hub.spec.js.map +1 -0
- package/dist/src/hub/infrastructure.spec.d.ts +6 -0
- package/dist/src/hub/infrastructure.spec.d.ts.map +1 -0
- package/dist/src/hub/infrastructure.spec.js +211 -0
- package/dist/src/hub/infrastructure.spec.js.map +1 -0
- package/dist/src/hub/lock.d.ts +62 -0
- package/dist/src/hub/lock.d.ts.map +1 -0
- package/dist/src/hub/lock.js +165 -0
- package/dist/src/hub/lock.js.map +1 -0
- package/dist/src/hub/main.d.ts +16 -0
- package/dist/src/hub/main.d.ts.map +1 -0
- package/dist/src/hub/main.js +56 -0
- package/dist/src/hub/main.js.map +1 -0
- package/dist/src/hub/session-bridge.d.ts +116 -0
- package/dist/src/hub/session-bridge.d.ts.map +1 -0
- package/dist/src/hub/session-bridge.js +336 -0
- package/dist/src/hub/session-bridge.js.map +1 -0
- package/dist/src/identity/file-identity-store.d.ts +18 -0
- package/dist/src/identity/file-identity-store.d.ts.map +1 -0
- package/dist/src/identity/file-identity-store.js +34 -0
- package/dist/src/identity/file-identity-store.js.map +1 -0
- package/dist/src/identity/file-identity-store.spec.d.ts +6 -0
- package/dist/src/identity/file-identity-store.spec.d.ts.map +1 -0
- package/dist/src/identity/file-identity-store.spec.js +59 -0
- package/dist/src/identity/file-identity-store.spec.js.map +1 -0
- package/dist/src/identity/in-memory-identity-store.d.ts +48 -0
- package/dist/src/identity/in-memory-identity-store.d.ts.map +1 -0
- package/dist/src/identity/in-memory-identity-store.js +102 -0
- package/dist/src/identity/in-memory-identity-store.js.map +1 -0
- package/dist/src/identity/machine-identity.d.ts +134 -0
- package/dist/src/identity/machine-identity.d.ts.map +1 -0
- package/dist/src/identity/machine-identity.js +65 -0
- package/dist/src/identity/machine-identity.js.map +1 -0
- package/dist/src/index.d.ts +34 -0
- package/dist/src/index.d.ts.map +1 -0
- package/dist/src/index.js +34 -0
- package/dist/src/index.js.map +1 -0
- package/dist/src/lease/closure.d.ts +96 -0
- package/dist/src/lease/closure.d.ts.map +1 -0
- package/dist/src/lease/closure.js +192 -0
- package/dist/src/lease/closure.js.map +1 -0
- package/dist/src/lease/closure.spec.d.ts +6 -0
- package/dist/src/lease/closure.spec.d.ts.map +1 -0
- package/dist/src/lease/closure.spec.js +35 -0
- package/dist/src/lease/closure.spec.js.map +1 -0
- package/dist/src/lease/coordinator.d.ts +34 -0
- package/dist/src/lease/coordinator.d.ts.map +1 -0
- package/dist/src/lease/coordinator.js +42 -0
- package/dist/src/lease/coordinator.js.map +1 -0
- package/dist/src/lease/in-memory-lease-store.d.ts +58 -0
- package/dist/src/lease/in-memory-lease-store.d.ts.map +1 -0
- package/dist/src/lease/in-memory-lease-store.js +132 -0
- package/dist/src/lease/in-memory-lease-store.js.map +1 -0
- package/dist/src/lease/lease-store.d.ts +169 -0
- package/dist/src/lease/lease-store.d.ts.map +1 -0
- package/dist/src/lease/lease-store.js +94 -0
- package/dist/src/lease/lease-store.js.map +1 -0
- package/dist/src/lease/lease-store.spec.d.ts +6 -0
- package/dist/src/lease/lease-store.spec.d.ts.map +1 -0
- package/dist/src/lease/lease-store.spec.js +8 -0
- package/dist/src/lease/lease-store.spec.js.map +1 -0
- package/dist/src/lease/rack-dispatch.d.ts +105 -0
- package/dist/src/lease/rack-dispatch.d.ts.map +1 -0
- package/dist/src/lease/rack-dispatch.js +6 -0
- package/dist/src/lease/rack-dispatch.js.map +1 -0
- package/dist/src/mutation-queue.d.ts +13 -0
- package/dist/src/mutation-queue.d.ts.map +1 -0
- package/dist/src/mutation-queue.js +17 -0
- package/dist/src/mutation-queue.js.map +1 -0
- package/dist/src/paths.d.ts +56 -0
- package/dist/src/paths.d.ts.map +1 -0
- package/dist/src/paths.js +82 -0
- package/dist/src/paths.js.map +1 -0
- package/dist/src/protocol/control.d.ts +295 -0
- package/dist/src/protocol/control.d.ts.map +1 -0
- package/dist/src/protocol/control.js +74 -0
- package/dist/src/protocol/control.js.map +1 -0
- package/dist/src/protocol/index.d.ts +9 -0
- package/dist/src/protocol/index.d.ts.map +1 -0
- package/dist/src/protocol/index.js +9 -0
- package/dist/src/protocol/index.js.map +1 -0
- package/dist/src/protocol/rack-wire.d.ts +357 -0
- package/dist/src/protocol/rack-wire.d.ts.map +1 -0
- package/dist/src/protocol/rack-wire.js +262 -0
- package/dist/src/protocol/rack-wire.js.map +1 -0
- package/dist/src/protocol/rack-wire.spec.d.ts +6 -0
- package/dist/src/protocol/rack-wire.spec.d.ts.map +1 -0
- package/dist/src/protocol/rack-wire.spec.js +14 -0
- package/dist/src/protocol/rack-wire.spec.js.map +1 -0
- package/dist/src/protocol/registration.d.ts +70 -0
- package/dist/src/protocol/registration.d.ts.map +1 -0
- package/dist/src/protocol/registration.js +67 -0
- package/dist/src/protocol/registration.js.map +1 -0
- package/dist/src/protocol/session-data.d.ts +69 -0
- package/dist/src/protocol/session-data.d.ts.map +1 -0
- package/dist/src/protocol/session-data.js +26 -0
- package/dist/src/protocol/session-data.js.map +1 -0
- package/dist/src/protocol/task-envelope.d.ts +198 -0
- package/dist/src/protocol/task-envelope.d.ts.map +1 -0
- package/dist/src/protocol/task-envelope.js +93 -0
- package/dist/src/protocol/task-envelope.js.map +1 -0
- package/dist/src/protocol/wire-golden.d.ts +8 -0
- package/dist/src/protocol/wire-golden.d.ts.map +1 -0
- package/dist/src/protocol/wire-golden.js +34 -0
- package/dist/src/protocol/wire-golden.js.map +1 -0
- package/dist/src/protocol/wire-samples.d.ts +11 -0
- package/dist/src/protocol/wire-samples.d.ts.map +1 -0
- package/dist/src/protocol/wire-samples.js +52 -0
- package/dist/src/protocol/wire-samples.js.map +1 -0
- package/dist/src/registry/file-rack-registry.d.ts +68 -0
- package/dist/src/registry/file-rack-registry.d.ts.map +1 -0
- package/dist/src/registry/file-rack-registry.js +138 -0
- package/dist/src/registry/file-rack-registry.js.map +1 -0
- package/dist/src/registry/file-rack-registry.spec.d.ts +6 -0
- package/dist/src/registry/file-rack-registry.spec.d.ts.map +1 -0
- package/dist/src/registry/file-rack-registry.spec.js +50 -0
- package/dist/src/registry/file-rack-registry.spec.js.map +1 -0
- package/dist/src/registry/rack-registry.d.ts +64 -0
- package/dist/src/registry/rack-registry.d.ts.map +1 -0
- package/dist/src/registry/rack-registry.js +65 -0
- package/dist/src/registry/rack-registry.js.map +1 -0
- package/dist/src/registry/rack-registry.spec.d.ts +6 -0
- package/dist/src/registry/rack-registry.spec.d.ts.map +1 -0
- package/dist/src/registry/rack-registry.spec.js +8 -0
- package/dist/src/registry/rack-registry.spec.js.map +1 -0
- package/dist/src/routing/eligibility.d.ts +38 -0
- package/dist/src/routing/eligibility.d.ts.map +1 -0
- package/dist/src/routing/eligibility.js +78 -0
- package/dist/src/routing/eligibility.js.map +1 -0
- package/dist/src/routing/host-coupling.d.ts +26 -0
- package/dist/src/routing/host-coupling.d.ts.map +1 -0
- package/dist/src/routing/host-coupling.js +62 -0
- package/dist/src/routing/host-coupling.js.map +1 -0
- package/dist/src/routing/policy.d.ts +188 -0
- package/dist/src/routing/policy.d.ts.map +1 -0
- package/dist/src/routing/policy.js +212 -0
- package/dist/src/routing/policy.js.map +1 -0
- package/dist/src/routing/routing.spec.d.ts +6 -0
- package/dist/src/routing/routing.spec.d.ts.map +1 -0
- package/dist/src/routing/routing.spec.js +100 -0
- package/dist/src/routing/routing.spec.js.map +1 -0
- package/dist/src/routing/tiers.d.ts +22 -0
- package/dist/src/routing/tiers.d.ts.map +1 -0
- package/dist/src/routing/tiers.js +26 -0
- package/dist/src/routing/tiers.js.map +1 -0
- package/dist/src/runner/api-server.d.ts +45 -0
- package/dist/src/runner/api-server.d.ts.map +1 -0
- package/dist/src/runner/api-server.js +21 -0
- package/dist/src/runner/api-server.js.map +1 -0
- package/dist/src/runner/capacity.d.ts +13 -0
- package/dist/src/runner/capacity.d.ts.map +1 -0
- package/dist/src/runner/capacity.js +18 -0
- package/dist/src/runner/capacity.js.map +1 -0
- package/dist/src/runner/rack-body.d.ts +43 -0
- package/dist/src/runner/rack-body.d.ts.map +1 -0
- package/dist/src/runner/rack-body.js +208 -0
- package/dist/src/runner/rack-body.js.map +1 -0
- package/dist/src/runner/rack-runner.d.ts +62 -0
- package/dist/src/runner/rack-runner.d.ts.map +1 -0
- package/dist/src/runner/rack-runner.js +75 -0
- package/dist/src/runner/rack-runner.js.map +1 -0
- package/dist/src/runner/rack-runner.spec.d.ts +6 -0
- package/dist/src/runner/rack-runner.spec.d.ts.map +1 -0
- package/dist/src/runner/rack-runner.spec.js +302 -0
- package/dist/src/runner/rack-runner.spec.js.map +1 -0
- package/dist/src/runner/split.spec.d.ts +6 -0
- package/dist/src/runner/split.spec.d.ts.map +1 -0
- package/dist/src/runner/split.spec.js +188 -0
- package/dist/src/runner/split.spec.js.map +1 -0
- package/dist/src/runner-event.d.ts +261 -0
- package/dist/src/runner-event.d.ts.map +1 -0
- package/dist/src/runner-event.js +332 -0
- package/dist/src/runner-event.js.map +1 -0
- package/dist/src/server/beast2.d.ts +19 -0
- package/dist/src/server/beast2.d.ts.map +1 -0
- package/dist/src/server/beast2.js +18 -0
- package/dist/src/server/beast2.js.map +1 -0
- package/dist/src/server/body-limit.d.ts +16 -0
- package/dist/src/server/body-limit.d.ts.map +1 -0
- package/dist/src/server/body-limit.js +53 -0
- package/dist/src/server/body-limit.js.map +1 -0
- package/dist/src/server/body-limit.spec.d.ts +2 -0
- package/dist/src/server/body-limit.spec.d.ts.map +1 -0
- package/dist/src/server/body-limit.spec.js +32 -0
- package/dist/src/server/body-limit.spec.js.map +1 -0
- package/dist/src/server/bounded.d.ts +16 -0
- package/dist/src/server/bounded.d.ts.map +1 -0
- package/dist/src/server/bounded.js +39 -0
- package/dist/src/server/bounded.js.map +1 -0
- package/dist/src/server/dispatch.d.ts +137 -0
- package/dist/src/server/dispatch.d.ts.map +1 -0
- package/dist/src/server/dispatch.js +267 -0
- package/dist/src/server/dispatch.js.map +1 -0
- package/dist/src/server/env-publication.d.ts +45 -0
- package/dist/src/server/env-publication.d.ts.map +1 -0
- package/dist/src/server/env-publication.js +10 -0
- package/dist/src/server/env-publication.js.map +1 -0
- package/dist/src/server/index.d.ts +16 -0
- package/dist/src/server/index.d.ts.map +1 -0
- package/dist/src/server/index.js +17 -0
- package/dist/src/server/index.js.map +1 -0
- package/dist/src/server/protocol.spec.d.ts +6 -0
- package/dist/src/server/protocol.spec.d.ts.map +1 -0
- package/dist/src/server/protocol.spec.js +78 -0
- package/dist/src/server/protocol.spec.js.map +1 -0
- package/dist/src/server/rack-routes.d.ts +175 -0
- package/dist/src/server/rack-routes.d.ts.map +1 -0
- package/dist/src/server/rack-routes.js +755 -0
- package/dist/src/server/rack-routes.js.map +1 -0
- package/dist/src/server/rack-routes.spec.d.ts +6 -0
- package/dist/src/server/rack-routes.spec.d.ts.map +1 -0
- package/dist/src/server/rack-routes.spec.js +477 -0
- package/dist/src/server/rack-routes.spec.js.map +1 -0
- package/dist/src/server/storage-bridge.d.ts +140 -0
- package/dist/src/server/storage-bridge.d.ts.map +1 -0
- package/dist/src/server/storage-bridge.js +6 -0
- package/dist/src/server/storage-bridge.js.map +1 -0
- package/dist/src/state-file.d.ts +10 -0
- package/dist/src/state-file.d.ts.map +1 -0
- package/dist/src/state-file.js +38 -0
- package/dist/src/state-file.js.map +1 -0
- package/dist/src/testing/in-memory.d.ts +116 -0
- package/dist/src/testing/in-memory.d.ts.map +1 -0
- package/dist/src/testing/in-memory.js +193 -0
- package/dist/src/testing/in-memory.js.map +1 -0
- package/dist/src/testing/index.d.ts +14 -0
- package/dist/src/testing/index.d.ts.map +1 -0
- package/dist/src/testing/index.js +14 -0
- package/dist/src/testing/index.js.map +1 -0
- package/dist/src/testing/lease-store-contract.d.ts +14 -0
- package/dist/src/testing/lease-store-contract.d.ts.map +1 -0
- package/dist/src/testing/lease-store-contract.js +78 -0
- package/dist/src/testing/lease-store-contract.js.map +1 -0
- package/dist/src/testing/protocol-suite.d.ts +38 -0
- package/dist/src/testing/protocol-suite.d.ts.map +1 -0
- package/dist/src/testing/protocol-suite.js +207 -0
- package/dist/src/testing/protocol-suite.js.map +1 -0
- package/dist/src/testing/rack-registry-contract.d.ts +15 -0
- package/dist/src/testing/rack-registry-contract.d.ts.map +1 -0
- package/dist/src/testing/rack-registry-contract.js +106 -0
- package/dist/src/testing/rack-registry-contract.js.map +1 -0
- package/dist/src/testing/test-rack-agent.d.ts +121 -0
- package/dist/src/testing/test-rack-agent.d.ts.map +1 -0
- package/dist/src/testing/test-rack-agent.js +316 -0
- package/dist/src/testing/test-rack-agent.js.map +1 -0
- package/dist/src/tls/tls.d.ts +67 -0
- package/dist/src/tls/tls.d.ts.map +1 -0
- package/dist/src/tls/tls.js +200 -0
- package/dist/src/tls/tls.js.map +1 -0
- package/dist/src/tls/tls.spec.d.ts +6 -0
- package/dist/src/tls/tls.spec.d.ts.map +1 -0
- package/dist/src/tls/tls.spec.js +101 -0
- package/dist/src/tls/tls.spec.js.map +1 -0
- package/dist/src/version.d.ts +9 -0
- package/dist/src/version.d.ts.map +1 -0
- package/dist/src/version.js +10 -0
- package/dist/src/version.js.map +1 -0
- package/package.json +67 -7
|
@@ -0,0 +1,755 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copyright (c) 2025 Elara AI Pty Ltd
|
|
3
|
+
* Licensed under BSL 1.1. See LICENSE for details.
|
|
4
|
+
*/
|
|
5
|
+
/**
|
|
6
|
+
* Machine-authenticated /api/rack routes (epic #67 design §9).
|
|
7
|
+
*
|
|
8
|
+
* #72 ships the identity half: the enrollment exchange (`POST /rack/enroll`,
|
|
9
|
+
* authenticated by the single-use enrollment token itself) and the
|
|
10
|
+
* **machine-auth middleware** every other /rack/* route sits behind.
|
|
11
|
+
* #73 adds the dispatch protocol behind that middleware:
|
|
12
|
+
* - `POST /rack/lease` — long-poll claim of the next matching task
|
|
13
|
+
* - `POST /rack/heartbeat` — liveness + stop signals for dead leases
|
|
14
|
+
* - `POST /rack/lease/extend` — visibility refresh while running
|
|
15
|
+
* - `POST /rack/complete` — terminal result (accepted-and-discarded)
|
|
16
|
+
* #74 adds the data plane:
|
|
17
|
+
* - `POST /rack/storage` — inline bytes / presigned URLs for a claimed
|
|
18
|
+
* lease's objects (scope-validated, review F5) + the output PUT targets
|
|
19
|
+
* - `POST /rack/logs` — batched stdout/stderr → LogStore
|
|
20
|
+
*
|
|
21
|
+
* #214 moves the guest onto e3-core's own execution:
|
|
22
|
+
* - a lease is refused to an agent older than `LEASE_MIN_AGENT`;
|
|
23
|
+
* - the claim of a task or unit lease mints its attempt and records it
|
|
24
|
+
* `running`, stamped with the rack launch; the completion records its
|
|
25
|
+
* outcome, discards what it wrote and never committed, and wakes a run
|
|
26
|
+
* parked on it (#208);
|
|
27
|
+
* - `/rack/storage` admits exactly the lease's closure, and takes the objects
|
|
28
|
+
* its guest wrote in by checksum-bound PUTs and their commits.
|
|
29
|
+
*
|
|
30
|
+
* A rack token authenticates ONLY these routes — it is invisible to the
|
|
31
|
+
* human `jwtAuthorizer`, so it can never reach the human/admin API.
|
|
32
|
+
*/
|
|
33
|
+
import { Hono } from 'hono';
|
|
34
|
+
import { setTimeout as delay } from 'node:timers/promises';
|
|
35
|
+
import { rackBodyLimit } from './body-limit.js';
|
|
36
|
+
import { mapBounded } from './bounded.js';
|
|
37
|
+
import { BooleanType, IntegerType, StringType, compareFor, equalFor, none, some, variant } from '@elaraai/east';
|
|
38
|
+
import { isObjectHash, uuidv7 } from '@elaraai/e3-core';
|
|
39
|
+
import { RackEnrollRequestType, RackEnrollResponseType, RackLeaseRequestType, RackLeaseGrantType, RackHeartbeatRequestType, RackHeartbeatResponseType, RackExtendRequestType, RackWatchRequestType, RackWatchResponseType, RACK_WATCH_MAX_LEASES, RackCompleteRequestType, RackCompleteResponseType, RackStorageRequestType, RackStorageResponseType, RackLogsRequestType, } from '../protocol/index.js';
|
|
40
|
+
import { sendSuccess, sendError, decodeBody } from './beast2.js';
|
|
41
|
+
import { updateRack } from '../registry/rack-registry.js';
|
|
42
|
+
import { MAX_LEASE_TARGETS } from '../lease/lease-store.js';
|
|
43
|
+
import { envLayerBlobKey } from './env-publication.js';
|
|
44
|
+
import { isFunctionLease, isLeaseEventV2, leaseRun } from '../protocol/task-envelope.js';
|
|
45
|
+
import { DEFAULT_HEALTHY_WINDOW_MS, DEFAULT_VISIBILITY_MS, heartbeatCapacity, leaseRefusal } from './dispatch.js';
|
|
46
|
+
import { backendLeaseCoordinator } from '../lease/coordinator.js';
|
|
47
|
+
import { hashToken, generateRackToken, RACK_TOKEN_PREFIX, ROTATION_AGE_MS, ROTATION_REOFFER_MS, ROTATION_GRACE_MS, } from '../identity/machine-identity.js';
|
|
48
|
+
/** The most objects one `/rack/storage` request declares or commits. */
|
|
49
|
+
export const MAX_STORAGE_OBJECTS = 256;
|
|
50
|
+
/** The largest object a write target is issued for: one checksum-bound PUT. */
|
|
51
|
+
export const MAX_RACK_OBJECT_BYTES = 5 * 1024 ** 3;
|
|
52
|
+
/** The leases whose closures a route keeps read, the least recently used forgotten first. */
|
|
53
|
+
const CLOSURE_CACHE_LIMIT = 64;
|
|
54
|
+
/** Bound backend reads and signing work without serializing each object. */
|
|
55
|
+
const STORAGE_READS_AT_ONCE = 8;
|
|
56
|
+
const DEFAULT_LEASE_WINDOW_MS = 20_000;
|
|
57
|
+
/** Claim re-check cadence inside a held window. 250 ms keeps interactive
|
|
58
|
+
* function calls (#83) snappy — pickup is bounded by this, not the window —
|
|
59
|
+
* at the cost of ~4 small listClaimable queries/s per idle agent (cents/day
|
|
60
|
+
* on DynamoDB on-demand; the held API-Lambda invocation dominates cost
|
|
61
|
+
* either way). */
|
|
62
|
+
const DEFAULT_POLL_INTERVAL_MS = 250;
|
|
63
|
+
const equalString = equalFor(StringType);
|
|
64
|
+
const compareInteger = compareFor(IntegerType);
|
|
65
|
+
function unauthorized(message) {
|
|
66
|
+
return new Response(JSON.stringify({ success: false, error: { type: 'unauthorized', message } }), { status: 401, headers: { 'Content-Type': 'application/json' } });
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Machine-auth middleware for /rack/* routes: validates the bearer rack
|
|
70
|
+
* token (by at-rest hash) and re-resolves the rack's registration so a
|
|
71
|
+
* deregistered rack is rejected immediately — revocation needs no token
|
|
72
|
+
* bookkeeping.
|
|
73
|
+
*
|
|
74
|
+
* Rotation activation (D12, #90) lives here: the FIRST authenticated use
|
|
75
|
+
* of a `pending` successor promotes it to `current` and puts the token it
|
|
76
|
+
* replaces on the ROTATION_GRACE fuse — on any rack route, so activation
|
|
77
|
+
* is observed wherever the agent switches.
|
|
78
|
+
*
|
|
79
|
+
* @param identityStore - Machine-credential persistence
|
|
80
|
+
* @param settingsStore - Rack registry (registration existence = not revoked)
|
|
81
|
+
* @param now - Clock for token rotation and grace expiry
|
|
82
|
+
* @returns Machine authentication middleware
|
|
83
|
+
* @example
|
|
84
|
+
* app.use('/api/rack/*', createRackAuth(identities, registry));
|
|
85
|
+
*/
|
|
86
|
+
export function createRackAuth(identityStore, settingsStore, now = Date.now) {
|
|
87
|
+
return async (c, next) => {
|
|
88
|
+
const header = c.req.header('authorization') ?? '';
|
|
89
|
+
const token = header.startsWith('Bearer ') ? header.slice('Bearer '.length) : '';
|
|
90
|
+
if (!token.startsWith(RACK_TOKEN_PREFIX)) {
|
|
91
|
+
return unauthorized('Rack token required');
|
|
92
|
+
}
|
|
93
|
+
const tokenHash = hashToken(token);
|
|
94
|
+
const record = await identityStore.getRackToken(tokenHash);
|
|
95
|
+
if (record === null) {
|
|
96
|
+
return unauthorized('Unknown rack token');
|
|
97
|
+
}
|
|
98
|
+
if (record.graceUntil !== undefined && record.graceUntil.getTime() <= now()) {
|
|
99
|
+
return unauthorized('Superseded rack token (rotation grace elapsed)');
|
|
100
|
+
}
|
|
101
|
+
const registration = await settingsStore.getRack(record.rackId);
|
|
102
|
+
if (registration === null) {
|
|
103
|
+
return unauthorized('Rack is deregistered');
|
|
104
|
+
}
|
|
105
|
+
if (record.status === 'pending') {
|
|
106
|
+
await identityStore.promoteRackToken(record.rackId, tokenHash, new Date(now() + ROTATION_GRACE_MS));
|
|
107
|
+
}
|
|
108
|
+
c.set('rackId', record.rackId);
|
|
109
|
+
c.set('registration', registration);
|
|
110
|
+
c.set('tokenIssuedAt', record.issuedAt);
|
|
111
|
+
c.set('tokenStatus', record.status);
|
|
112
|
+
await next();
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Create the /api/rack routes: the enrollment exchange (#72), the dispatch
|
|
117
|
+
* protocol (#73), the data plane (#74), and a lease's attempt (#214).
|
|
118
|
+
*
|
|
119
|
+
* @param identityStore - Machine-credential persistence
|
|
120
|
+
* @param settingsStore - Rack registry
|
|
121
|
+
* @param leaseStore - Lease persistence (the pull queue)
|
|
122
|
+
* @param bridge - Object data-plane (presign/inline/commit)
|
|
123
|
+
* @param attempts - Where a lease's attempt is kept: its records, its log
|
|
124
|
+
* lines, and the store its output is checked whole in
|
|
125
|
+
* @param options - Long-poll/visibility tuning, and the waker
|
|
126
|
+
* @returns The rack machine API
|
|
127
|
+
* @example
|
|
128
|
+
* const app = createRackRoutes(identities, registry, leases, bridge, attempts);
|
|
129
|
+
*/
|
|
130
|
+
export function createRackRoutes(identityStore, settingsStore, leaseStore, bridge, attempts, options) {
|
|
131
|
+
const now = options?.now ?? Date.now;
|
|
132
|
+
const coordinator = options?.coordinator ?? backendLeaseCoordinator;
|
|
133
|
+
const leaseWindowMs = options?.leaseWindowMs ?? DEFAULT_LEASE_WINDOW_MS;
|
|
134
|
+
const pollIntervalMs = options?.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
|
|
135
|
+
const pollMaxMs = Math.max(pollIntervalMs, options?.pollMaxMs ?? pollIntervalMs);
|
|
136
|
+
const policyRecheckMs = Math.max(0, options?.policyRecheckMs ?? 0);
|
|
137
|
+
const visibilityMs = options?.visibilityMs ?? DEFAULT_VISIBILITY_MS;
|
|
138
|
+
const watchWindowMs = Math.max(1, Math.min(20_000, options?.watchWindowMs ?? 20_000));
|
|
139
|
+
const watchPollIntervalMs = Math.max(1, options?.watchPollIntervalMs ?? 250);
|
|
140
|
+
const envIndex = options?.envIndex;
|
|
141
|
+
const e3Version = options?.e3Version;
|
|
142
|
+
const { logs } = attempts;
|
|
143
|
+
const app = new Hono();
|
|
144
|
+
const auth = createRackAuth(identityStore, settingsStore, now);
|
|
145
|
+
app.use('/api/rack/lease', auth);
|
|
146
|
+
app.use('/api/rack/heartbeat', auth);
|
|
147
|
+
app.use('/api/rack/lease/extend', auth);
|
|
148
|
+
app.use('/api/rack/lease/watch', auth);
|
|
149
|
+
app.use('/api/rack/lease/watch', rackBodyLimit(256 * 1024));
|
|
150
|
+
app.use('/api/rack/complete', auth);
|
|
151
|
+
app.use('/api/rack/storage', auth);
|
|
152
|
+
app.use('/api/rack/logs', auth);
|
|
153
|
+
for (const route of ['enroll', 'lease', 'heartbeat', 'lease/extend', 'complete', 'storage', 'logs']) {
|
|
154
|
+
app.use(`/api/rack/${route}`, rackBodyLimit(route === 'enroll' ? 64 * 1024 : 2 * 1024 * 1024));
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* Resolve a lease the caller actively holds — the scope gate for the
|
|
158
|
+
* data-plane routes (review F5): storage/logs are per-lease capabilities,
|
|
159
|
+
* not rack-wide ones.
|
|
160
|
+
*/
|
|
161
|
+
const heldLease = async (repo, leaseId, rackId) => {
|
|
162
|
+
const lease = await leaseStore.get(repo, leaseId);
|
|
163
|
+
if (lease === null || lease.status !== 'claimed' || lease.claimedBy !== rackId)
|
|
164
|
+
return null;
|
|
165
|
+
return lease;
|
|
166
|
+
};
|
|
167
|
+
const forbidden = (message) => new Response(JSON.stringify({ success: false, error: { type: 'permission_denied', message } }), { status: 403, headers: { 'Content-Type': 'application/json' } });
|
|
168
|
+
/**
|
|
169
|
+
* Every object a held lease may read: its closure (#214), and, for one
|
|
170
|
+
* recorded as an object, that object. A closure never changes, so the
|
|
171
|
+
* hashes of the leases asked after most recently are kept.
|
|
172
|
+
*/
|
|
173
|
+
const closures = new Map();
|
|
174
|
+
const admitted = (lease) => {
|
|
175
|
+
const key = `${lease.repo} ${lease.leaseId}`;
|
|
176
|
+
let hashes = closures.get(key);
|
|
177
|
+
if (hashes === undefined) {
|
|
178
|
+
hashes = attempts.closure(lease).then((list) => new Set(list));
|
|
179
|
+
hashes.catch(() => closures.delete(key));
|
|
180
|
+
}
|
|
181
|
+
else {
|
|
182
|
+
closures.delete(key);
|
|
183
|
+
}
|
|
184
|
+
closures.set(key, hashes);
|
|
185
|
+
if (closures.size > CLOSURE_CACHE_LIMIT)
|
|
186
|
+
closures.delete(closures.keys().next().value);
|
|
187
|
+
return hashes;
|
|
188
|
+
};
|
|
189
|
+
/** Serialize registration patches with revocation. Only heartbeats refresh health. */
|
|
190
|
+
const touchRack = (rackId, patch = () => ({})) => coordinator.run('@rack', rackId, () => updateRack(settingsStore, rackId, patch));
|
|
191
|
+
// POST /api/rack/enroll — exchange a single-use enrollment token for a
|
|
192
|
+
// long-lived rack token. The enrollment token is the sole authentication.
|
|
193
|
+
app.post('/api/rack/enroll', async (c) => {
|
|
194
|
+
let request;
|
|
195
|
+
try {
|
|
196
|
+
request = (await decodeBody(c, RackEnrollRequestType));
|
|
197
|
+
}
|
|
198
|
+
catch {
|
|
199
|
+
return unauthorized('Invalid enrollment request body');
|
|
200
|
+
}
|
|
201
|
+
const consumed = await identityStore.consumeEnrollmentToken(hashToken(request.enrollmentToken), new Date(now()));
|
|
202
|
+
if (!consumed) {
|
|
203
|
+
return unauthorized('Invalid, expired, or already-used enrollment token');
|
|
204
|
+
}
|
|
205
|
+
const rackId = uuidv7();
|
|
206
|
+
const { token: rackToken, tokenHash } = generateRackToken();
|
|
207
|
+
const enrolledAt = new Date(now());
|
|
208
|
+
await identityStore.putRackToken(tokenHash, rackId, enrolledAt, 'current');
|
|
209
|
+
// Boot identity starts empty: the first heartbeat's bootId is ADOPTED
|
|
210
|
+
// as approved (the admin authorized this start by minting the token
|
|
211
|
+
// moments ago); only later restarts can differ and quarantine.
|
|
212
|
+
const registration = {
|
|
213
|
+
rackId,
|
|
214
|
+
label: request.hostInfo.label,
|
|
215
|
+
enrolledAt,
|
|
216
|
+
tiers: request.hostInfo.tiers,
|
|
217
|
+
capacity: request.hostInfo.capacity,
|
|
218
|
+
lastSeenAt: enrolledAt,
|
|
219
|
+
agentVersion: request.hostInfo.agentVersion,
|
|
220
|
+
healthy: false,
|
|
221
|
+
lastBootId: none,
|
|
222
|
+
approvedBootId: none,
|
|
223
|
+
};
|
|
224
|
+
await settingsStore.putRack(registration);
|
|
225
|
+
return sendSuccess(RackEnrollResponseType, { rackId, rackToken });
|
|
226
|
+
});
|
|
227
|
+
// POST /api/rack/lease — long-poll claim of the next task matching the
|
|
228
|
+
// agent's tiers. 204 when the window closes with nothing claimable.
|
|
229
|
+
app.post('/api/rack/lease', async (c) => {
|
|
230
|
+
const rackId = c.get('rackId');
|
|
231
|
+
let tiers;
|
|
232
|
+
let bootId;
|
|
233
|
+
try {
|
|
234
|
+
const request = (await decodeBody(c, RackLeaseRequestType));
|
|
235
|
+
tiers = request.tiers;
|
|
236
|
+
bootId = request.bootId;
|
|
237
|
+
}
|
|
238
|
+
catch {
|
|
239
|
+
return sendError(RackLeaseGrantType, variant('internal', { message: 'Invalid lease request body' }));
|
|
240
|
+
}
|
|
241
|
+
// Converge the ADVERTISED tiers into the registration (found live: a
|
|
242
|
+
// re-install with widened --tiers left the registry on the enrollment-
|
|
243
|
+
// time tier set forever — heartbeats never carry tiers, so routing and
|
|
244
|
+
// the fleet UI kept refusing c/full work the agent could run). The
|
|
245
|
+
// tier list the agent POLLS with is exactly what it can execute.
|
|
246
|
+
if (await touchRack(rackId, () => ({ tiers })) === null)
|
|
247
|
+
return unauthorized('Rack is deregistered');
|
|
248
|
+
// #214: an agent older than LEASE_MIN_AGENT cannot run a v2 lease: it is
|
|
249
|
+
// refused, and told to upgrade. The registration's version is refreshed
|
|
250
|
+
// by every heartbeat; the dispatch routes no work at such a rack either
|
|
251
|
+
const refusal = leaseRefusal(c.get('registration').agentVersion);
|
|
252
|
+
if (refusal !== null) {
|
|
253
|
+
return sendError(RackLeaseGrantType, variant('internal', { message: refusal }));
|
|
254
|
+
}
|
|
255
|
+
const deadline = Date.now() + leaseWindowMs;
|
|
256
|
+
let waitMs = pollIntervalMs;
|
|
257
|
+
let policyCheckedAt = Number.NEGATIVE_INFINITY;
|
|
258
|
+
let requireStartApproval = false;
|
|
259
|
+
const signal = options?.signal ? AbortSignal.any([c.req.raw.signal, options.signal]) : c.req.raw.signal;
|
|
260
|
+
for (;;) {
|
|
261
|
+
if (signal.aborted)
|
|
262
|
+
return c.body(null, 204);
|
|
263
|
+
const currentRack = await settingsStore.getRack(rackId);
|
|
264
|
+
if (currentRack === null)
|
|
265
|
+
return unauthorized('Rack is deregistered');
|
|
266
|
+
// Start-approval quarantine (D12): only the APPROVED boot receives
|
|
267
|
+
// work, judged against the request's own bootId — no heartbeat
|
|
268
|
+
// ordering can leak a grant to an unapproved restart. The check
|
|
269
|
+
// lives INSIDE the poll loop: a quarantined agent holds its window
|
|
270
|
+
// open like any idle agent (same pacing, no hot 204 loop) and an
|
|
271
|
+
// approval landing mid-window grants within the current poll interval.
|
|
272
|
+
// Only idle checks may reuse a policy; every actual claim revalidates it.
|
|
273
|
+
if (Date.now() - policyCheckedAt >= policyRecheckMs) {
|
|
274
|
+
requireStartApproval = (await settingsStore.getRackSecurityPolicy())?.requireStartApproval === true;
|
|
275
|
+
policyCheckedAt = Date.now();
|
|
276
|
+
}
|
|
277
|
+
const quarantined = requireStartApproval
|
|
278
|
+
&& !(currentRack.approvedBootId.type === 'some' && equalString(currentRack.approvedBootId.value, bootId));
|
|
279
|
+
if (!quarantined && currentRack.healthy && compareInteger(currentRack.capacity, 0n) > 0
|
|
280
|
+
&& currentRack.lastSeenAt.getTime() >= now() - DEFAULT_HEALTHY_WINDOW_MS) {
|
|
281
|
+
const candidates = await leaseStore.listClaimable(tiers, now(), 10);
|
|
282
|
+
for (const candidate of candidates) {
|
|
283
|
+
const grant = await coordinator.run(candidate.repo, candidate.leaseId, async () => {
|
|
284
|
+
const current = await leaseStore.get(candidate.repo, candidate.leaseId);
|
|
285
|
+
if (current === null)
|
|
286
|
+
return null;
|
|
287
|
+
// A lease written before #214 is no work this build runs
|
|
288
|
+
if (!isLeaseEventV2(current.event))
|
|
289
|
+
return null;
|
|
290
|
+
// A drain, revocation or policy change can land during the queue read
|
|
291
|
+
// or while waiting for this lease's fence. Never grant from cached permission.
|
|
292
|
+
const [claimRack, security] = await Promise.all([
|
|
293
|
+
settingsStore.getRack(rackId), settingsStore.getRackSecurityPolicy(),
|
|
294
|
+
]);
|
|
295
|
+
if (claimRack === null)
|
|
296
|
+
return unauthorized('Rack is deregistered');
|
|
297
|
+
if (signal.aborted || !claimRack.healthy || compareInteger(claimRack.capacity, 0n) <= 0
|
|
298
|
+
|| claimRack.lastSeenAt.getTime() < now() - DEFAULT_HEALTHY_WINDOW_MS
|
|
299
|
+
|| leaseRefusal(claimRack.agentVersion) !== null
|
|
300
|
+
|| (security?.requireStartApproval === true
|
|
301
|
+
&& !(claimRack.approvedBootId.type === 'some' && equalString(claimRack.approvedBootId.value, bootId))))
|
|
302
|
+
return null;
|
|
303
|
+
if (options?.canClaim !== undefined && !(await options.canClaim(claimRack, current)))
|
|
304
|
+
return null;
|
|
305
|
+
// A task or unit lease's claim runs an attempt, which the cloud
|
|
306
|
+
// records: the rack cannot (#214)
|
|
307
|
+
const attempt = isFunctionLease(candidate.event) ? undefined : { executionId: uuidv7(), startedAtMs: now() };
|
|
308
|
+
const claimKind = current.status === 'pending' ? 'initial' : 'reclaimed';
|
|
309
|
+
const claimed = await leaseStore.claim(candidate.repo, candidate.leaseId, rackId, now() + visibilityMs, attempt);
|
|
310
|
+
if (claimed !== null) {
|
|
311
|
+
if (attempt !== undefined) {
|
|
312
|
+
// A rack that went quiet past the lease's visibility runs its
|
|
313
|
+
// attempt no more: this claim's takes over
|
|
314
|
+
if (current.attempt !== undefined) {
|
|
315
|
+
await attempts.stopped(current, 'interrupted', `its rack ${current.claimedBy ?? ''} went quiet past its lease's visibility, and rack ${rackId} took the lease up`)
|
|
316
|
+
.catch((err) => console.warn(`rack lease ${candidate.leaseId}: its earlier attempt was not recorded interrupted: ${messageOf(err)}`));
|
|
317
|
+
}
|
|
318
|
+
await attempts.running(claimed, bootId);
|
|
319
|
+
}
|
|
320
|
+
await options?.onLeaseSignal?.({ kind: 'claimed', repo: claimed.repo, leaseId: claimed.leaseId, rackId });
|
|
321
|
+
const response = sendSuccess(RackLeaseGrantType, {
|
|
322
|
+
repo: claimed.repo,
|
|
323
|
+
workspace: claimed.workspace,
|
|
324
|
+
leaseId: claimed.leaseId,
|
|
325
|
+
tier: claimed.tier,
|
|
326
|
+
computeSize: claimed.computeSize ?? 'serverless',
|
|
327
|
+
eventJson: JSON.stringify(claimed.event),
|
|
328
|
+
visibilityDeadline: new Date(claimed.visibilityDeadlineMs),
|
|
329
|
+
});
|
|
330
|
+
// Source wall-clock queue time only; never mix clocks across the network.
|
|
331
|
+
response.headers.set('x-e3-rack-claim-kind', claimKind);
|
|
332
|
+
const queueMs = (claimed.claimedAtMs ?? NaN) - claimed.createdAtMs;
|
|
333
|
+
if (claimKind === 'initial' && Number.isSafeInteger(queueMs) && queueMs >= 0) {
|
|
334
|
+
response.headers.set('x-e3-rack-queue-ms', String(queueMs));
|
|
335
|
+
}
|
|
336
|
+
return response;
|
|
337
|
+
}
|
|
338
|
+
return null;
|
|
339
|
+
});
|
|
340
|
+
if (grant !== null)
|
|
341
|
+
return grant;
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
if (Date.now() + waitMs >= deadline) {
|
|
345
|
+
return c.body(null, 204);
|
|
346
|
+
}
|
|
347
|
+
await delay(waitMs, undefined, { signal }).catch(() => { });
|
|
348
|
+
waitMs = Math.min(pollMaxMs, Math.round(waitMs * 1.5));
|
|
349
|
+
}
|
|
350
|
+
});
|
|
351
|
+
// POST /api/rack/heartbeat — liveness + which running leases to abandon,
|
|
352
|
+
// plus the D12 lifecycle: boot-identity reporting (first heartbeat after
|
|
353
|
+
// enrollment ADOPTS its bootId as approved — the mint authorized that
|
|
354
|
+
// start), start-approval status, and token-rotation offers.
|
|
355
|
+
app.post('/api/rack/heartbeat', async (c) => {
|
|
356
|
+
const rackId = c.get('rackId');
|
|
357
|
+
let heartbeat;
|
|
358
|
+
let capacity;
|
|
359
|
+
try {
|
|
360
|
+
heartbeat = (await decodeBody(c, RackHeartbeatRequestType));
|
|
361
|
+
capacity = heartbeatCapacity(heartbeat);
|
|
362
|
+
}
|
|
363
|
+
catch {
|
|
364
|
+
return sendError(RackHeartbeatResponseType, variant('internal', { message: 'Invalid heartbeat body' }));
|
|
365
|
+
}
|
|
366
|
+
const registration = await touchRack(rackId, (current) => ({
|
|
367
|
+
healthy: heartbeat.healthy,
|
|
368
|
+
capacity,
|
|
369
|
+
lastSeenAt: new Date(now()),
|
|
370
|
+
agentVersion: heartbeat.agentVersion,
|
|
371
|
+
lastBootId: some(heartbeat.bootId),
|
|
372
|
+
approvedBootId: current.approvedBootId.type === 'none' && current.lastBootId.type === 'none'
|
|
373
|
+
? some(heartbeat.bootId) : current.approvedBootId,
|
|
374
|
+
}));
|
|
375
|
+
if (registration === null)
|
|
376
|
+
return unauthorized('Rack is deregistered');
|
|
377
|
+
const { approvedBootId } = registration;
|
|
378
|
+
// A lease is dead to this agent when it is terminal-for-someone-else:
|
|
379
|
+
// cancelled, superseded, gone, or reclaimed by another rack after expiry.
|
|
380
|
+
const running = await mapBounded(heartbeat.runningLeases, STORAGE_READS_AT_ONCE, ref => leaseStore.get(ref.repo, ref.leaseId));
|
|
381
|
+
const stop = heartbeat.runningLeases.filter((_ref, i) => {
|
|
382
|
+
const lease = running[i];
|
|
383
|
+
return lease === null || lease.status === 'cancelled' || lease.status === 'superseded'
|
|
384
|
+
|| (lease.status === 'claimed' && lease.claimedBy !== rackId);
|
|
385
|
+
});
|
|
386
|
+
const security = await settingsStore.getRackSecurityPolicy();
|
|
387
|
+
const pendingApproval = security?.requireStartApproval === true &&
|
|
388
|
+
(approvedBootId.type !== 'some' || !equalString(approvedBootId.value, heartbeat.bootId));
|
|
389
|
+
// Rotation offer (D12): only the rack's CURRENT token ages into an
|
|
390
|
+
// offer — a pending successor was just activated by the middleware
|
|
391
|
+
// (fresh clock), and a grace token proves the agent already holds a
|
|
392
|
+
// newer one. Offers repeat with a FRESH successor at most every
|
|
393
|
+
// ROTATION_REOFFER until first use activates one.
|
|
394
|
+
let rotatedToken = none;
|
|
395
|
+
if (c.get('tokenStatus') === 'current' &&
|
|
396
|
+
now() - c.get('tokenIssuedAt').getTime() > ROTATION_AGE_MS) {
|
|
397
|
+
const tokens = await identityStore.listRackTokens(rackId);
|
|
398
|
+
const pending = tokens.find((t) => t.status === 'pending');
|
|
399
|
+
const staleOffer = pending !== undefined && now() - pending.issuedAt.getTime() > ROTATION_REOFFER_MS;
|
|
400
|
+
if (pending === undefined || staleOffer) {
|
|
401
|
+
if (pending !== undefined)
|
|
402
|
+
await identityStore.deleteRackToken(pending.tokenHash);
|
|
403
|
+
const successor = generateRackToken();
|
|
404
|
+
await identityStore.putRackToken(successor.tokenHash, rackId, new Date(now()), 'pending');
|
|
405
|
+
rotatedToken = some(successor.token);
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
return sendSuccess(RackHeartbeatResponseType, { stop, rotatedToken, pendingApproval });
|
|
409
|
+
});
|
|
410
|
+
// Separate cancellation from health/rotation writes. This request stays
|
|
411
|
+
// open while work is held, and returns promptly after a terminal transition.
|
|
412
|
+
// It never claims work, extends visibility, or exposes another lease's data.
|
|
413
|
+
app.post('/api/rack/lease/watch', async (c) => {
|
|
414
|
+
const rackId = c.get('rackId');
|
|
415
|
+
let refs;
|
|
416
|
+
try {
|
|
417
|
+
refs = (await decodeBody(c, RackWatchRequestType)).runningLeases;
|
|
418
|
+
if (refs.length === 0 || refs.length > RACK_WATCH_MAX_LEASES
|
|
419
|
+
|| refs.some(ref => [ref.repo, ref.leaseId].some(value => !value || Buffer.byteLength(value) > 1024 || /[\x00-\x1f\x7f]/.test(value)))
|
|
420
|
+
|| new Set(refs.map(ref => JSON.stringify([ref.repo, ref.leaseId]))).size !== refs.length)
|
|
421
|
+
throw new Error();
|
|
422
|
+
}
|
|
423
|
+
catch {
|
|
424
|
+
return c.text('Invalid cancellation watch', 400);
|
|
425
|
+
}
|
|
426
|
+
const signal = options?.signal ? AbortSignal.any([c.req.raw.signal, options.signal]) : c.req.raw.signal;
|
|
427
|
+
const deadline = Date.now() + watchWindowMs;
|
|
428
|
+
for (;;) {
|
|
429
|
+
if (signal.aborted)
|
|
430
|
+
return c.body(null, 204);
|
|
431
|
+
if (await settingsStore.getRack(rackId) === null)
|
|
432
|
+
return unauthorized('Rack is deregistered');
|
|
433
|
+
const states = await mapBounded(refs, STORAGE_READS_AT_ONCE, ref => leaseStore.get(ref.repo, ref.leaseId));
|
|
434
|
+
const stop = refs.filter((_ref, i) => {
|
|
435
|
+
const lease = states[i];
|
|
436
|
+
return lease == null || lease.claimedBy !== rackId || lease.status !== 'claimed';
|
|
437
|
+
});
|
|
438
|
+
if (stop.length)
|
|
439
|
+
return sendSuccess(RackWatchResponseType, { stop });
|
|
440
|
+
const remaining = deadline - Date.now();
|
|
441
|
+
if (remaining <= 0)
|
|
442
|
+
return c.body(null, 204);
|
|
443
|
+
await delay(Math.min(watchPollIntervalMs, remaining), undefined, { signal }).catch(() => { });
|
|
444
|
+
}
|
|
445
|
+
});
|
|
446
|
+
// POST /api/rack/lease/extend — visibility refresh (the running lease's
|
|
447
|
+
// own heartbeat). False when the lease is no longer this rack's.
|
|
448
|
+
app.post('/api/rack/lease/extend', async (c) => {
|
|
449
|
+
const rackId = c.get('rackId');
|
|
450
|
+
try {
|
|
451
|
+
const request = (await decodeBody(c, RackExtendRequestType));
|
|
452
|
+
const extended = await coordinator.run(request.repo, request.leaseId, () => leaseStore.extend(request.repo, request.leaseId, rackId, now() + visibilityMs));
|
|
453
|
+
if (extended)
|
|
454
|
+
await options?.onLeaseSignal?.({ kind: 'extended', repo: request.repo, leaseId: request.leaseId });
|
|
455
|
+
return sendSuccess(BooleanType, extended);
|
|
456
|
+
}
|
|
457
|
+
catch {
|
|
458
|
+
return sendError(BooleanType, variant('internal', { message: 'Invalid extend body' }));
|
|
459
|
+
}
|
|
460
|
+
});
|
|
461
|
+
// POST /api/rack/complete — terminal result. Accepted-and-discarded: a
|
|
462
|
+
// late completion after supersede/cancel/reclaim is a 200 telling the
|
|
463
|
+
// agent to drop local state, never a retryable error. A success's output
|
|
464
|
+
// is in the store FIRST, every object it names (#214), so a completion only
|
|
465
|
+
// ever records an output hash that resolves. The attempt's outcome is
|
|
466
|
+
// recorded before the lease ends, so no reader finds the lease ended and
|
|
467
|
+
// the attempt still running; then what the lease wrote and never committed
|
|
468
|
+
// is discarded, and a run parked on it is woken (#208).
|
|
469
|
+
app.post('/api/rack/complete', async (c) => {
|
|
470
|
+
const rackId = c.get('rackId');
|
|
471
|
+
let request;
|
|
472
|
+
let result;
|
|
473
|
+
try {
|
|
474
|
+
request = (await decodeBody(c, RackCompleteRequestType));
|
|
475
|
+
result = JSON.parse(request.resultJson);
|
|
476
|
+
if (typeof result.taskName !== 'string' || (result.status !== 'success' && result.status !== 'failed')) {
|
|
477
|
+
throw new Error('malformed result');
|
|
478
|
+
}
|
|
479
|
+
if (result.state !== undefined && !['success', 'failed', 'error'].includes(result.state))
|
|
480
|
+
throw new Error('invalid state');
|
|
481
|
+
if (result.state !== undefined && (result.state === 'success') !== (result.status === 'success'))
|
|
482
|
+
throw new Error('inconsistent outcome');
|
|
483
|
+
if (result.exitCode !== undefined && !Number.isSafeInteger(result.exitCode))
|
|
484
|
+
throw new Error('invalid exit code');
|
|
485
|
+
if (result.peakBytes !== undefined && (!Number.isSafeInteger(result.peakBytes) || result.peakBytes < 0))
|
|
486
|
+
throw new Error('invalid peak');
|
|
487
|
+
if (result.duration !== undefined && (!Number.isFinite(result.duration) || result.duration < 0))
|
|
488
|
+
throw new Error('invalid duration');
|
|
489
|
+
// Inline bytes determine their own hash below; an agent's claimed hash
|
|
490
|
+
// has no authority, even when it is malformed.
|
|
491
|
+
const inlineSuccess = result.status === 'success' && request.inlineOutput.type === 'some';
|
|
492
|
+
if (!inlineSuccess && result.outputHash !== undefined && (typeof result.outputHash !== 'string' || !isObjectHash(result.outputHash)))
|
|
493
|
+
throw new Error('invalid output hash');
|
|
494
|
+
if (result.cancelled !== undefined && typeof result.cancelled !== 'boolean')
|
|
495
|
+
throw new Error('invalid cancellation');
|
|
496
|
+
if (result.error !== undefined && typeof result.error !== 'string')
|
|
497
|
+
throw new Error('invalid error');
|
|
498
|
+
}
|
|
499
|
+
catch {
|
|
500
|
+
return sendError(RackCompleteResponseType, variant('internal', { message: 'Invalid completion body' }));
|
|
501
|
+
}
|
|
502
|
+
return coordinator.run(request.repo, request.leaseId, async () => {
|
|
503
|
+
// Land the output before any lease transition. Only the lease holder
|
|
504
|
+
// may commit; a fenced-out agent's completion falls through to the
|
|
505
|
+
// state machine below, which discards it without touching storage.
|
|
506
|
+
const held = await heldLease(request.repo, request.leaseId, rackId);
|
|
507
|
+
// A task or unit lease's attempt, which the cloud records (#214)
|
|
508
|
+
const attempt = held !== null && !isFunctionLease(held.event) ? held.attempt : undefined;
|
|
509
|
+
if (held !== null && result.status === 'success') {
|
|
510
|
+
if (request.inlineOutput.type === 'some') {
|
|
511
|
+
// Small output: server computes the hash — the agent's is ignored.
|
|
512
|
+
result.outputHash = await bridge.writeInline(request.repo, request.inlineOutput.value);
|
|
513
|
+
}
|
|
514
|
+
else if (request.outputUploadId.type === 'some') {
|
|
515
|
+
if (result.outputHash === undefined) {
|
|
516
|
+
return sendError(RackCompleteResponseType, variant('internal', { message: 'outputUploadId without outputHash' }));
|
|
517
|
+
}
|
|
518
|
+
// A whole output, staged: taken in once the store's copy of it hashes
|
|
519
|
+
// to its hash (#210)
|
|
520
|
+
const verified = await bridge.commitStaged(request.repo, result.outputHash, request.outputUploadId.value);
|
|
521
|
+
if (!verified) {
|
|
522
|
+
// Tampered/corrupt upload: reject — the lease stays claimed, so
|
|
523
|
+
// the agent may retry; otherwise visibility expiry → cloud.
|
|
524
|
+
return sendError(RackCompleteResponseType, variant('internal', { message: 'Output verification failed' }));
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
if (attempt !== undefined) {
|
|
528
|
+
const outputHash = result.outputHash;
|
|
529
|
+
if (outputHash === undefined || !isObjectHash(outputHash)) {
|
|
530
|
+
return sendError(RackCompleteResponseType, variant('internal', { message: 'A success names its output\'s hash' }));
|
|
531
|
+
}
|
|
532
|
+
// Every object the output names is in the store, committed by the
|
|
533
|
+
// lease or held already, and re-referenced until the loop roots it
|
|
534
|
+
let whole;
|
|
535
|
+
try {
|
|
536
|
+
whole = await attempts.verifyOutput(held, outputHash);
|
|
537
|
+
}
|
|
538
|
+
catch (err) {
|
|
539
|
+
return sendError(RackCompleteResponseType, variant('internal', { message: `The output could not be checked: ${messageOf(err)}` }));
|
|
540
|
+
}
|
|
541
|
+
if (!whole) {
|
|
542
|
+
return sendError(RackCompleteResponseType, variant('internal', {
|
|
543
|
+
message: `Output ${outputHash} is not whole in the store: commit every object it names before completing`,
|
|
544
|
+
}));
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
if (held !== null && attempt !== undefined) {
|
|
549
|
+
try {
|
|
550
|
+
await logs.flush?.(held.repo, held.taskHash, held.inputsHash, attempt.executionId);
|
|
551
|
+
await attempts.outcome(held, result);
|
|
552
|
+
}
|
|
553
|
+
catch (err) {
|
|
554
|
+
// The lease stays claimed: the agent completes it again
|
|
555
|
+
return sendError(RackCompleteResponseType, variant('internal', { message: `The attempt's outcome was not recorded: ${messageOf(err)}` }));
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
const disposition = await leaseStore.complete(request.repo, request.leaseId, rackId, result);
|
|
559
|
+
if (held !== null) {
|
|
560
|
+
// What the lease wrote and nothing committed leaves no version at a
|
|
561
|
+
// content address (#214)
|
|
562
|
+
if ((held.targets?.length ?? 0) > 0) {
|
|
563
|
+
await bridge.discardWrites(request.repo, request.leaseId, held.targets).catch((err) => {
|
|
564
|
+
console.warn(`rack lease ${request.leaseId}: the versions it wrote and never committed were not discarded: ${messageOf(err)}`);
|
|
565
|
+
});
|
|
566
|
+
}
|
|
567
|
+
// A run parked on the attempt runs its next cycle at once (#208): the
|
|
568
|
+
// waker finds its outcome recorded
|
|
569
|
+
const run = attempt !== undefined ? leaseRun(held.event) : undefined;
|
|
570
|
+
if (run !== undefined && options?.wake !== undefined) {
|
|
571
|
+
await options.wake(request.repo, run).catch((err) => {
|
|
572
|
+
console.warn(`rack lease ${request.leaseId}: could not wake run ${run.id}, which its park's backstop wakes: ${messageOf(err)}`);
|
|
573
|
+
});
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
await options?.onLeaseSignal?.({ kind: 'completed', repo: request.repo, leaseId: request.leaseId, disposition });
|
|
577
|
+
return sendSuccess(RackCompleteResponseType, { disposition: variant(disposition, null) });
|
|
578
|
+
});
|
|
579
|
+
});
|
|
580
|
+
// POST /api/rack/storage — data-plane descriptors for a HELD lease. The
|
|
581
|
+
// request's `need` set must be within the lease's closure (F5, #214): the
|
|
582
|
+
// objects its guest reads, as the cloud computed them when it granted the
|
|
583
|
+
// lease. Anything else is a hard 403 — a compromised agent must not walk
|
|
584
|
+
// the repo's object store. The objects its guest wrote go in by PUTs at
|
|
585
|
+
// their content addresses, bound to their SHA-256 and the lease, which
|
|
586
|
+
// their commits take in.
|
|
587
|
+
app.post('/api/rack/storage', async (c) => {
|
|
588
|
+
const rackId = c.get('rackId');
|
|
589
|
+
let request;
|
|
590
|
+
try {
|
|
591
|
+
request = (await decodeBody(c, RackStorageRequestType));
|
|
592
|
+
}
|
|
593
|
+
catch {
|
|
594
|
+
return sendError(RackStorageResponseType, variant('internal', { message: 'Invalid storage request body' }));
|
|
595
|
+
}
|
|
596
|
+
return coordinator.run(request.repo, request.leaseId, async () => {
|
|
597
|
+
const lease = await heldLease(request.repo, request.leaseId, rackId);
|
|
598
|
+
if (lease === null) {
|
|
599
|
+
return forbidden('No such lease held by this rack');
|
|
600
|
+
}
|
|
601
|
+
if (!isLeaseEventV2(lease.event)) {
|
|
602
|
+
return forbidden(`Lease ${lease.leaseId} is no work this deployment runs`);
|
|
603
|
+
}
|
|
604
|
+
if (request.need.length > 0) {
|
|
605
|
+
let allowed;
|
|
606
|
+
try {
|
|
607
|
+
allowed = await admitted(lease);
|
|
608
|
+
}
|
|
609
|
+
catch (err) {
|
|
610
|
+
return sendError(RackStorageResponseType, variant('internal', { message: `The lease's closure could not be read: ${messageOf(err)}` }));
|
|
611
|
+
}
|
|
612
|
+
for (const hash of request.need) {
|
|
613
|
+
if (!allowed.has(hash)) {
|
|
614
|
+
return forbidden(`Hash ${hash} is not part of lease ${lease.leaseId}`);
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
const objects = await mapBounded(request.need, STORAGE_READS_AT_ONCE, async (hash) => {
|
|
619
|
+
const descriptor = await bridge.readDescriptor(request.repo, hash);
|
|
620
|
+
return descriptor.kind === 'inline'
|
|
621
|
+
? variant('inline', { hash, data: descriptor.data })
|
|
622
|
+
: variant('url', { hash, url: descriptor.url });
|
|
623
|
+
});
|
|
624
|
+
// A whole output, staged (#210)
|
|
625
|
+
let output = none;
|
|
626
|
+
if (request.output.type === 'some') {
|
|
627
|
+
output = some(await bridge.stagedTarget(request.repo, lease.workspace, request.output.value.hash, request.output.value.size));
|
|
628
|
+
}
|
|
629
|
+
// #94 Slice B: published env layers for the lease's OWN environment.
|
|
630
|
+
// Scope (F5): the requested hash must equal the lease's declared env —
|
|
631
|
+
// the index/manifest are deployment-global, so this check is the only
|
|
632
|
+
// thing between a rack credential and every published environment.
|
|
633
|
+
let env = none;
|
|
634
|
+
if (request.envManifest.type === 'some') {
|
|
635
|
+
const envHash = request.envManifest.value;
|
|
636
|
+
if (lease.event.environment !== envHash) {
|
|
637
|
+
return forbidden(`Environment ${envHash} is not part of lease ${lease.leaseId}`);
|
|
638
|
+
}
|
|
639
|
+
const record = envIndex !== undefined && e3Version !== undefined
|
|
640
|
+
? await envIndex.get(envHash, e3Version)
|
|
641
|
+
: null;
|
|
642
|
+
const manifestJson = record === null ? null : await bridge.readEnvManifest(record.manifestKey);
|
|
643
|
+
if (manifestJson === null) {
|
|
644
|
+
return sendError(RackStorageResponseType, variant('internal', {
|
|
645
|
+
message: `Environment ${envHash} has no published layers (build it, or the deployment predates publication)`,
|
|
646
|
+
}));
|
|
647
|
+
}
|
|
648
|
+
const manifest = JSON.parse(manifestJson);
|
|
649
|
+
const layers = await mapBounded(manifest.layers, STORAGE_READS_AT_ONCE, async (layer) => ({
|
|
650
|
+
digest: layer.digest,
|
|
651
|
+
url: await bridge.presignEnvLayer(envLayerBlobKey(layer.digest)),
|
|
652
|
+
sizeBytes: BigInt(layer.sizeBytes),
|
|
653
|
+
}));
|
|
654
|
+
env = some({ manifestJson, layers });
|
|
655
|
+
}
|
|
656
|
+
// #214: a PUT target for each object the guest wrote, at its content
|
|
657
|
+
// address; the keys issued are recorded on the lease before the answer,
|
|
658
|
+
// so its end finds every version it may have made
|
|
659
|
+
const issued = new Set(lease.targets ?? []);
|
|
660
|
+
if (request.outputs.length > 0 || request.commits.length > 0) {
|
|
661
|
+
if (isFunctionLease(lease.event)) {
|
|
662
|
+
return forbidden(`Lease ${lease.leaseId} is a function call's, which writes nothing to the store`);
|
|
663
|
+
}
|
|
664
|
+
if (request.outputs.length > MAX_STORAGE_OBJECTS || request.commits.length > MAX_STORAGE_OBJECTS) {
|
|
665
|
+
return sendError(RackStorageResponseType, variant('internal', { message: `A request declares or commits at most ${MAX_STORAGE_OBJECTS} objects` }));
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
// Validate and reserve the complete batch before issuing any capability.
|
|
669
|
+
// A rejected batch must leave no untracked upload for a later cleanup.
|
|
670
|
+
for (const { hash, size } of [...request.outputs, ...request.commits]) {
|
|
671
|
+
if (!isObjectHash(hash) || size < 0n || size > BigInt(MAX_RACK_OBJECT_BYTES)) {
|
|
672
|
+
return sendError(RackStorageResponseType, variant('internal', {
|
|
673
|
+
message: `Output ${hash} is not an object this lease can write: a SHA-256, of at most ${MAX_RACK_OBJECT_BYTES} bytes`,
|
|
674
|
+
}));
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
const fresh = [...new Set(request.outputs.map(({ hash }) => hash))].filter((hash) => !issued.has(hash));
|
|
678
|
+
if (issued.size + fresh.length > MAX_LEASE_TARGETS) {
|
|
679
|
+
return sendError(RackStorageResponseType, variant('internal', { message: `The lease writes more than ${MAX_LEASE_TARGETS} objects` }));
|
|
680
|
+
}
|
|
681
|
+
if (fresh.length > 0 && !await leaseStore.recordTargets(request.repo, lease.leaseId, rackId, fresh)) {
|
|
682
|
+
return forbidden('No such lease held by this rack');
|
|
683
|
+
}
|
|
684
|
+
for (const hash of fresh)
|
|
685
|
+
issued.add(hash);
|
|
686
|
+
const targets = await mapBounded(request.outputs, STORAGE_READS_AT_ONCE, async ({ hash, size }) => {
|
|
687
|
+
const target = await bridge.writeTarget(request.repo, lease.leaseId, hash, Number(size));
|
|
688
|
+
return {
|
|
689
|
+
hash,
|
|
690
|
+
target: target === null
|
|
691
|
+
? variant('held', null)
|
|
692
|
+
: variant('put', { url: target.url, headers: new Map(Object.entries(target.headers)) }),
|
|
693
|
+
};
|
|
694
|
+
});
|
|
695
|
+
// #214: an object PUT through a target is taken in once the version it
|
|
696
|
+
// made is checked: never a body read
|
|
697
|
+
const committed = [];
|
|
698
|
+
for (const { hash, size, version } of request.commits) {
|
|
699
|
+
const ok = issued.has(hash) && await bridge.commitWrite(request.repo, lease.leaseId, rackId, hash, Number(size), version);
|
|
700
|
+
committed.push({ hash, committed: ok });
|
|
701
|
+
}
|
|
702
|
+
return sendSuccess(RackStorageResponseType, { objects, output, env, targets, committed });
|
|
703
|
+
});
|
|
704
|
+
});
|
|
705
|
+
// POST /api/rack/logs — batched stdout/stderr for a HELD lease → LogStore
|
|
706
|
+
// under its attempt's execution id (visible in the task UI live, #214).
|
|
707
|
+
app.post('/api/rack/logs', async (c) => {
|
|
708
|
+
const rackId = c.get('rackId');
|
|
709
|
+
let request;
|
|
710
|
+
try {
|
|
711
|
+
request = (await decodeBody(c, RackLogsRequestType));
|
|
712
|
+
}
|
|
713
|
+
catch {
|
|
714
|
+
return sendError(BooleanType, variant('internal', { message: 'Invalid logs body' }));
|
|
715
|
+
}
|
|
716
|
+
return coordinator.run(request.repo, request.leaseId, async () => {
|
|
717
|
+
const lease = await heldLease(request.repo, request.leaseId, rackId);
|
|
718
|
+
if (lease === null) {
|
|
719
|
+
return forbidden('No such lease held by this rack');
|
|
720
|
+
}
|
|
721
|
+
const executionId = lease.attempt?.executionId;
|
|
722
|
+
if (executionId === undefined) {
|
|
723
|
+
// Refused for good, so the rack posts it no more
|
|
724
|
+
return forbidden(`Lease ${lease.leaseId} runs no attempt whose log is kept: a function call's output rides its completion`);
|
|
725
|
+
}
|
|
726
|
+
try {
|
|
727
|
+
try {
|
|
728
|
+
for (const chunk of request.chunks) {
|
|
729
|
+
await logs.append(request.repo, lease.taskHash, lease.inputsHash, executionId, chunk.stream.type, chunk.data);
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
finally {
|
|
733
|
+
// A sink that buffers stores the batch before the rack is answered: a
|
|
734
|
+
// serverless host is frozen once it answers, and the rack's next post
|
|
735
|
+
// may reach another host
|
|
736
|
+
await logs.flush?.(request.repo, lease.taskHash, lease.inputsHash, executionId);
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
catch (err) {
|
|
740
|
+
// Told its lines were not stored, a rack posts them again while it
|
|
741
|
+
// holds the lease. Its batches carry one stream each, so a re-post
|
|
742
|
+
// repeats no line of another stream.
|
|
743
|
+
return sendError(BooleanType, variant('internal', {
|
|
744
|
+
message: `Logs not stored: ${err instanceof Error ? err.message : String(err)}`,
|
|
745
|
+
}));
|
|
746
|
+
}
|
|
747
|
+
return sendSuccess(BooleanType, true);
|
|
748
|
+
});
|
|
749
|
+
});
|
|
750
|
+
return app;
|
|
751
|
+
}
|
|
752
|
+
function messageOf(err) {
|
|
753
|
+
return err instanceof Error ? err.message : String(err);
|
|
754
|
+
}
|
|
755
|
+
//# sourceMappingURL=rack-routes.js.map
|