@twinsearth/w2m-dsh-plugin 0.6.2 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +1057 -0
- package/README.md +9 -2
- package/bin/w2m-localside.mjs +0 -0
- package/bin/w2m-rabbit.mjs +6 -0
- package/bin/w2m-stun.mjs +0 -0
- package/deploy/server/README.md +109 -0
- package/deploy/server/deploy-w2m.sh +161 -0
- package/deploy/server/w2m-localside.service +31 -0
- package/deploy/server/w2m-rabbit.service +26 -0
- package/deploy/server/w2m-stun.service +26 -0
- package/docs/ACCOUNT-FLEET-DESIGN.md +369 -0
- package/package.json +33 -22
- package/src/chatops/account.mjs +361 -0
- package/src/chatops/actions.mjs +227 -0
- package/src/chatops/approval-broker.mjs +320 -0
- package/src/chatops/auth.mjs +376 -0
- package/src/chatops/bridge.mjs +344 -0
- package/src/chatops/channel.mjs +192 -0
- package/src/chatops/commands.mjs +409 -0
- package/src/chatops/config-route.mjs +332 -0
- package/src/chatops/device-signing.mjs +218 -0
- package/src/chatops/durability.mjs +202 -0
- package/src/chatops/file-safety.mjs +282 -0
- package/src/chatops/fleet-scope.mjs +176 -0
- package/src/chatops/fleet-wiring.mjs +199 -0
- package/src/chatops/fleet.mjs +272 -0
- package/src/chatops/handoff.mjs +258 -0
- package/src/chatops/ilink-protocol.mjs +165 -0
- package/src/chatops/ilink-transport.mjs +168 -0
- package/src/chatops/ilink.mjs +423 -0
- package/src/chatops/index.mjs +429 -0
- package/src/chatops/outbound-queue.mjs +282 -0
- package/src/chatops/policy.mjs +356 -0
- package/src/chatops/run-command.mjs +308 -0
- package/src/plugin/tools.mjs +1 -1
- package/src/relay/server.mjs +96 -0
- package/src/relay/state.mjs +2233 -2011
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wiring fleet scoping into the relay's decisions (v0.7.7).
|
|
3
|
+
*
|
|
4
|
+
* ## What this file is, and what it is not
|
|
5
|
+
*
|
|
6
|
+
* `fleet-scope.mjs` answers *"which devices may this request reach"*. This answers the two
|
|
7
|
+
* questions the relay needs answered **before** it can ask that one:
|
|
8
|
+
*
|
|
9
|
+
* 1. **What fleet is this requester in?** A relay receives a device token, not a fleet. The token
|
|
10
|
+
* maps to a device, and the device has a `fleet_id` — or does not, because it paired before
|
|
11
|
+
* fleets existed.
|
|
12
|
+
* 2. **What happens to a device that has no fleet?**
|
|
13
|
+
*
|
|
14
|
+
* ## The migration question, and why the answer is not "refuse"
|
|
15
|
+
*
|
|
16
|
+
* The shared relay holds four devices that paired under v0.6.3. None of them has a `fleet_id`.
|
|
17
|
+
* Refusing fleetless requests would take the whole fleet down at the moment of deployment — for a
|
|
18
|
+
* safety feature, that is the wrong trade.
|
|
19
|
+
*
|
|
20
|
+
* So there are two modes, and the relay's operator chose between them by configuring a fleet:
|
|
21
|
+
*
|
|
22
|
+
* | relay has | fleetless device | reaches |
|
|
23
|
+
* |---|---|---|
|
|
24
|
+
* | **no fleet configured** | legacy | every device, as before |
|
|
25
|
+
* | **a fleet configured** | `fleet_id: null` | **nothing** |
|
|
26
|
+
*
|
|
27
|
+
* That second row is the one that matters. Once an operator says *"this relay is for fleet X"*, a
|
|
28
|
+
* device with no fleet is not silently granted the run of it — it reaches nothing, and
|
|
29
|
+
* `scopeFleetId` says why. The transition is therefore **opt-in and visible**, rather than a
|
|
30
|
+
* background behaviour change that arrives with a version bump.
|
|
31
|
+
*
|
|
32
|
+
* A device with a `fleet_id` always reaches its own fleet and nothing else, in both modes. That is
|
|
33
|
+
* the isolation, and it does not depend on the relay's configuration.
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
import { selectTargets, SCOPE_REFUSAL } from './fleet-scope.mjs';
|
|
37
|
+
|
|
38
|
+
/** Why a request reached nothing, before target selection even runs. */
|
|
39
|
+
export const WIRING_REFUSAL = Object.freeze({
|
|
40
|
+
/** The token belongs to no device this relay knows. */
|
|
41
|
+
UNKNOWN_TOKEN: 'unknown_token',
|
|
42
|
+
/** The relay is scoped to a fleet and this requester is not in one. */
|
|
43
|
+
REQUESTER_HAS_NO_FLEET: 'requester_has_no_fleet',
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Which fleet the requester acts as.
|
|
48
|
+
*
|
|
49
|
+
* The value comes from **the device record**, which the relay wrote at pairing time from the
|
|
50
|
+
* pairing code — never from anything in the request. A request that could name its own fleet
|
|
51
|
+
* would be able to reach any fleet, and every signature would faithfully attest to it.
|
|
52
|
+
*
|
|
53
|
+
* @param {object} input
|
|
54
|
+
* @param {{machine_id?: string, fleet_id?: string|null}|null} input.device The requester's record.
|
|
55
|
+
* @param {string|null} [input.relayFleetId] The fleet this relay is scoped to, if the operator set one.
|
|
56
|
+
* @returns {{ok: true, fleetId: string|null, legacy: boolean}
|
|
57
|
+
* | {ok: false, reason: string, text: string}}
|
|
58
|
+
*/
|
|
59
|
+
export function scopeFleetId({ device, relayFleetId = null }) {
|
|
60
|
+
if (device === null || device === undefined || typeof device !== 'object') {
|
|
61
|
+
return { ok: false, reason: WIRING_REFUSAL.UNKNOWN_TOKEN, text: '这个 token 不属于本 relay 已知的任何设备。' };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const deviceFleet = typeof device.fleet_id === 'string' && device.fleet_id !== '' ? device.fleet_id : null;
|
|
65
|
+
|
|
66
|
+
// Already grouped: it reaches its own fleet, whatever the relay is configured for.
|
|
67
|
+
if (deviceFleet !== null) return { ok: true, fleetId: deviceFleet, legacy: false };
|
|
68
|
+
|
|
69
|
+
// Not grouped, and the relay has not been claimed for a fleet: the pre-v0.7.7 behaviour, which
|
|
70
|
+
// is what keeps a deployment from being an outage. `legacy: true` is reported so a caller can
|
|
71
|
+
// log it rather than having to infer it.
|
|
72
|
+
if (relayFleetId === null || relayFleetId === '') return { ok: true, fleetId: null, legacy: true };
|
|
73
|
+
|
|
74
|
+
// Not grouped, and the relay *is* claimed. A fleetless device is not handed the run of someone
|
|
75
|
+
// else's fleet — it reaches nothing, and says so.
|
|
76
|
+
return {
|
|
77
|
+
ok: false,
|
|
78
|
+
reason: WIRING_REFUSAL.REQUESTER_HAS_NO_FLEET,
|
|
79
|
+
text: '这个 relay 已绑定到一条舰队,而这台设备不属于任何舰队,因此它不能派发到任何地方。',
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* The devices a request may reach, given a device list and a requester.
|
|
85
|
+
*
|
|
86
|
+
* The single entry point the relay needs. It applies `scopeFleetId`, then `selectTargets`, and
|
|
87
|
+
* then — only in legacy mode — falls back to the unfiltered list, because that is exactly what
|
|
88
|
+
* the pre-v0.7.7 code did and a migration must not change behaviour for devices that were never
|
|
89
|
+
* asked to change.
|
|
90
|
+
*
|
|
91
|
+
* ## The case a first version got wrong
|
|
92
|
+
*
|
|
93
|
+
* `POST /v1/task` authenticates with the **operator token**, and an operator is not a device:
|
|
94
|
+
* `authorizeTaskDispatch` returns `{ role: 'operator', machine_id: null }`. So `requester` is
|
|
95
|
+
* routinely absent on the most common path, and an earlier version of this function refused it as
|
|
96
|
+
* `unknown_token` — which turned every operator-token dispatch into an error and broke 74 relay
|
|
97
|
+
* tests at once.
|
|
98
|
+
*
|
|
99
|
+
* An **absent** requester and an **unknown** one are therefore different things:
|
|
100
|
+
*
|
|
101
|
+
* * absent, on an unscoped relay → the legacy rule, which is what an operator has always had;
|
|
102
|
+
* * absent, on a scoped relay → refused, because there is no fleet to attribute the request to
|
|
103
|
+
* and handing it the run of the fleet is the silent grant this work exists to remove;
|
|
104
|
+
* * unknown (a machine id that is not in the device table) → refused, always, because a token
|
|
105
|
+
* that maps to nothing is a broken caller rather than a legitimate operator.
|
|
106
|
+
*
|
|
107
|
+
* @param {object} input
|
|
108
|
+
* @param {Array<object>} input.devices Every device the relay holds.
|
|
109
|
+
* @param {object|null|undefined} input.requester The requester's device record, or absent for an
|
|
110
|
+
* operator-token dispatch.
|
|
111
|
+
* @param {string[]} [input.targetMachines]
|
|
112
|
+
* @param {string|null} [input.relayFleetId]
|
|
113
|
+
* @param {boolean} [input.requireOnline]
|
|
114
|
+
*/
|
|
115
|
+
export function targetsFor({ devices, requester, targetMachines, relayFleetId = null, requireOnline = true }) {
|
|
116
|
+
const relayScoped = typeof relayFleetId === 'string' && relayFleetId !== '';
|
|
117
|
+
|
|
118
|
+
// An operator-token caller has no device at all, which is a legitimate identity rather than a
|
|
119
|
+
// broken one. On an unscoped relay it gets exactly the pre-v0.7.7 rule.
|
|
120
|
+
if (requester === null || requester === undefined) {
|
|
121
|
+
if (!relayScoped) return legacyTargets({ devices, targetMachines, requireOnline, reason: 'operator' });
|
|
122
|
+
return {
|
|
123
|
+
ok: false,
|
|
124
|
+
reason: WIRING_REFUSAL.REQUESTER_HAS_NO_FLEET,
|
|
125
|
+
text: '这个 relay 已绑定到一条舰队,而这个请求没有可以归属的设备身份,因此不能派发。',
|
|
126
|
+
legacy: false,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const scope = scopeFleetId({ device: requester, relayFleetId });
|
|
131
|
+
if (!scope.ok) return { ok: false, reason: scope.reason, text: scope.text, legacy: false };
|
|
132
|
+
|
|
133
|
+
if (scope.legacy) return legacyTargets({ devices, targetMachines, requireOnline, reason: 'fleetless' });
|
|
134
|
+
|
|
135
|
+
const result = selectTargets({ devices, fleetId: scope.fleetId, targetMachines, requireOnline });
|
|
136
|
+
return result.ok ? { ...result, legacy: false } : { ...result, legacy: false };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** The pre-v0.7.7 rule, in one place so both callers cannot drift apart. */
|
|
140
|
+
function legacyTargets({ devices, targetMachines, requireOnline, reason }) {
|
|
141
|
+
const all = Array.isArray(devices) ? devices : [];
|
|
142
|
+
const chosen = Array.isArray(targetMachines) && targetMachines.length > 0
|
|
143
|
+
? all.filter((d) => targetMachines.includes(d.machine_id))
|
|
144
|
+
: all;
|
|
145
|
+
const offline = chosen.filter((d) => d.online === false).map((d) => d.machine_id);
|
|
146
|
+
const targets = requireOnline ? chosen.filter((d) => d.online !== false) : chosen;
|
|
147
|
+
if (targets.length === 0) {
|
|
148
|
+
return {
|
|
149
|
+
ok: false,
|
|
150
|
+
reason: SCOPE_REFUSAL.NO_DEVICE_IN_FLEET,
|
|
151
|
+
text: offline.length > 0 ? '没有在线设备。' : 'relay 上没有任何设备。',
|
|
152
|
+
legacy: true,
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
return { ok: true, targets, skippedOffline: offline, legacy: true, legacyReason: reason };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* The `fleet_id` a pairing code admits to, in the shape `state.mjs` stores.
|
|
160
|
+
*
|
|
161
|
+
* Returns `null` for a code that predates fleets, which is what keeps an existing relay working:
|
|
162
|
+
* a code issued before this release pairs a device into no fleet, and the relay's own mode decides
|
|
163
|
+
* what that means.
|
|
164
|
+
*/
|
|
165
|
+
export function fleetForPairingCode(codeRecord) {
|
|
166
|
+
if (codeRecord === null || typeof codeRecord !== 'object') return null;
|
|
167
|
+
const fleet = codeRecord.fleet_id;
|
|
168
|
+
return typeof fleet === 'string' && fleet !== '' ? fleet : null;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* The device fields that carry fleet membership, for a record the relay is about to write.
|
|
173
|
+
*
|
|
174
|
+
* Kept as a function so the "the fleet came from the code" rule lives in one place: a caller that
|
|
175
|
+
* builds a device literal by hand is the way a device ends up choosing its own fleet.
|
|
176
|
+
*/
|
|
177
|
+
export function deviceFleetFields({ codeRecord, requestedFleet }) {
|
|
178
|
+
const fromCode = fleetForPairingCode(codeRecord);
|
|
179
|
+
if (fromCode !== null) return { fleet_id: fromCode, fleet_source: 'pairing_code' };
|
|
180
|
+
if (typeof requestedFleet === 'string' && requestedFleet !== '') {
|
|
181
|
+
// Reachable only when the relay has no fleet of its own and an operator passed one explicitly.
|
|
182
|
+
// Recorded with its provenance so a fleet that arrived from a request is visible as such in
|
|
183
|
+
// `devices.json` rather than looking identical to one that came from a code.
|
|
184
|
+
return { fleet_id: requestedFleet, fleet_source: 'request' };
|
|
185
|
+
}
|
|
186
|
+
return { fleet_id: null, fleet_source: null };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* A one-line description of the scoping decision, for a log.
|
|
191
|
+
*
|
|
192
|
+
* Deliberately includes the legacy flag: an operator reading logs during a migration needs to see
|
|
193
|
+
* *which* rule applied, and "4 targets" alone does not say whether isolation is on.
|
|
194
|
+
*/
|
|
195
|
+
export function describeScope({ scope, targetCount }) {
|
|
196
|
+
if (!scope.ok) return `scoped: refused (${scope.reason})`;
|
|
197
|
+
if (scope.legacy) return `scoped: legacy (no fleet on this relay), ${targetCount} target(s)`;
|
|
198
|
+
return `scoped: fleet ${scope.fleetId}, ${targetCount} target(s)`;
|
|
199
|
+
}
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Running a fleet task and reading its verdict, callable from the chat layer (v0.7.2).
|
|
3
|
+
*
|
|
4
|
+
* ## Why this is a separate module
|
|
5
|
+
*
|
|
6
|
+
* `w2m_run` and `w2m_wait` are DSH *tools*: their bodies live inside the plugin's `ctx.tools`
|
|
7
|
+
* registration and are shaped for a model to call — a schema, a card, an error type the host
|
|
8
|
+
* knows how to render. The chat bridge needs the same work with none of that: an argv in, a
|
|
9
|
+
* verdict and a sentence out, no tool registry involved.
|
|
10
|
+
*
|
|
11
|
+
* The alternative was to have the bridge call the tools. It cannot. A tool call needs
|
|
12
|
+
* `@deepseek-ai/dsh-tools`, which exists **only inside `app.asar`** — a separate process cannot
|
|
13
|
+
* complete one, and an in-process call would have to fabricate the host's own calling
|
|
14
|
+
* convention. So the fleet work is expressed once, here, against the relay's HTTP API, and the
|
|
15
|
+
* tool and the chat bridge are both callers.
|
|
16
|
+
*
|
|
17
|
+
* ## The two things a live relay taught this file
|
|
18
|
+
*
|
|
19
|
+
* The first version guessed, and both guesses were wrong in ways the unit tests could not see —
|
|
20
|
+
* the same failure mode as `ilink.mjs` in v0.7.0, reproduced one layer up:
|
|
21
|
+
*
|
|
22
|
+
* 1. **`GET /v1/tasks/<id>` needs the `device_token`, not the `operator_token`.** With the
|
|
23
|
+
* operator token it answers `401 {"code":"UNAUTHORIZED","message":"missing or invalid
|
|
24
|
+
* device_token"}`. Dispatching and requesting take *different* credentials, so both are held.
|
|
25
|
+
* 2. **The verdict is not at the top level.** It is `body.aggregate.status`, and the per-machine
|
|
26
|
+
* detail is `body.leases[]` — whose machine name is `machine_name`, not `machine`. A reader
|
|
27
|
+
* looking for `body.verdict` finds `undefined` forever while the task completes perfectly,
|
|
28
|
+
* which is exactly what happened: the agent ran the command, delivered the result, and the
|
|
29
|
+
* poll loop reported `verdict=undefined` thirty times.
|
|
30
|
+
*
|
|
31
|
+
* ## What it deliberately does not do
|
|
32
|
+
*
|
|
33
|
+
* **It does not wait forever.** A chat reply has a human on the other end, and a task with one
|
|
34
|
+
* dead machine in the fleet never reaches a terminal state. Every wait is bounded and reports
|
|
35
|
+
* `timeout` as a verdict rather than hanging — which is a real answer ("it is still running")
|
|
36
|
+
* and not an error.
|
|
37
|
+
*
|
|
38
|
+
* **It does not invent a verdict.** The relay's own word is passed through, and an unrecognised
|
|
39
|
+
* one is reported as unrecognised instead of being mapped onto the nearest familiar value.
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
/** Relay paths, from the plugin's own tool implementations. */
|
|
43
|
+
export const RELAY = Object.freeze({
|
|
44
|
+
TASK: '/v1/task',
|
|
45
|
+
TASK_STATUS: (id) => `/v1/tasks/${encodeURIComponent(id)}`,
|
|
46
|
+
TASK_REPORT: (id) => `/v1/tasks/${encodeURIComponent(id)}/report`,
|
|
47
|
+
DEVICES: '/v1/devices',
|
|
48
|
+
PAIR: '/v1/pair',
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
/** Terminal verdicts the relay reports. Passed through, never re-derived. */
|
|
52
|
+
export const TERMINAL = Object.freeze([
|
|
53
|
+
'consistent',
|
|
54
|
+
'divergent',
|
|
55
|
+
'divergent-platform',
|
|
56
|
+
'failed',
|
|
57
|
+
'partial',
|
|
58
|
+
'unverifiable',
|
|
59
|
+
'timeout',
|
|
60
|
+
'cancelled',
|
|
61
|
+
'degraded',
|
|
62
|
+
]);
|
|
63
|
+
|
|
64
|
+
export function isTerminal(verdict) {
|
|
65
|
+
return typeof verdict === 'string' && TERMINAL.includes(verdict);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The verdict in a status or report body, wherever the relay put it.
|
|
70
|
+
*
|
|
71
|
+
* A status read nests it under `aggregate`; a report puts it at the top level. Both are checked
|
|
72
|
+
* because both are real, and the shape difference is the relay's, not a mistake to normalise
|
|
73
|
+
* away silently.
|
|
74
|
+
*/
|
|
75
|
+
export function verdictOf(body) {
|
|
76
|
+
if (body === null || typeof body !== 'object') return null;
|
|
77
|
+
const candidate = body.aggregate?.status ?? body.status ?? body.verdict ?? null;
|
|
78
|
+
return typeof candidate === 'string' && candidate !== '' ? candidate : null;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** The per-machine rows in a status or report body. `leases` on a status, `machines` on a report. */
|
|
82
|
+
export function rowsOf(body) {
|
|
83
|
+
if (body === null || typeof body !== 'object') return [];
|
|
84
|
+
for (const key of ['machines', 'leases', 'results']) {
|
|
85
|
+
if (Array.isArray(body[key])) return body[key];
|
|
86
|
+
}
|
|
87
|
+
return [];
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Create the executor.
|
|
92
|
+
*
|
|
93
|
+
* @param {object} options
|
|
94
|
+
* @param {string} options.rabbitUrl
|
|
95
|
+
* @param {string|null} [options.token] The operator token. **Required to dispatch.**
|
|
96
|
+
* @param {string|null} [options.deviceToken] The device token. **Required to read a task.**
|
|
97
|
+
* Taken from the identity file when not given, because an install already has one.
|
|
98
|
+
* @param {number} [options.timeoutMs] Per HTTP request.
|
|
99
|
+
* @param {typeof fetch} [options.fetch]
|
|
100
|
+
* @param {() => number} [options.now]
|
|
101
|
+
* @param {(ms: number) => Promise<void>} [options.sleep]
|
|
102
|
+
* @param {(event: string, detail?: object) => void} [options.audit]
|
|
103
|
+
*/
|
|
104
|
+
export function createFleetExecutor(options) {
|
|
105
|
+
const rabbitUrl = options.rabbitUrl;
|
|
106
|
+
if (typeof rabbitUrl !== 'string' || rabbitUrl === '') {
|
|
107
|
+
throw new TypeError('createFleetExecutor requires a rabbitUrl');
|
|
108
|
+
}
|
|
109
|
+
const token = options.token ?? null;
|
|
110
|
+
const deviceToken = options.deviceToken ?? null;
|
|
111
|
+
const timeoutMs = options.timeoutMs ?? 30_000;
|
|
112
|
+
const doFetch = options.fetch ?? globalThis.fetch;
|
|
113
|
+
const now = options.now ?? (() => Date.now());
|
|
114
|
+
const sleep = options.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
115
|
+
const audit = options.audit ?? (() => {});
|
|
116
|
+
|
|
117
|
+
/** One relay call. Resolves for every HTTP status: a refusal is an answer, not an exception. */
|
|
118
|
+
async function relay(pathname, { method = 'GET', body, accept = 'application/json', credential = 'operator' } = {}) {
|
|
119
|
+
const controller = new AbortController();
|
|
120
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
121
|
+
const url = new URL(pathname, rabbitUrl.endsWith('/') ? rabbitUrl : `${rabbitUrl}/`).toString();
|
|
122
|
+
const headers = { accept };
|
|
123
|
+
// Measured: dispatch authenticates with the operator token, task reads with the device token.
|
|
124
|
+
// Sending the wrong one is a 401 whose message names the credential it wanted.
|
|
125
|
+
const bearer = credential === 'device' ? deviceToken : token;
|
|
126
|
+
if (bearer) headers.authorization = `Bearer ${bearer}`;
|
|
127
|
+
if (body !== undefined) headers['content-type'] = 'application/json';
|
|
128
|
+
try {
|
|
129
|
+
const response = await doFetch(url, {
|
|
130
|
+
method,
|
|
131
|
+
headers,
|
|
132
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
133
|
+
signal: controller.signal,
|
|
134
|
+
});
|
|
135
|
+
const text = await response.text();
|
|
136
|
+
let json = null;
|
|
137
|
+
try {
|
|
138
|
+
json = JSON.parse(text);
|
|
139
|
+
} catch {
|
|
140
|
+
json = null;
|
|
141
|
+
}
|
|
142
|
+
return { status: response.status, ok: response.ok, json, text };
|
|
143
|
+
} catch (error) {
|
|
144
|
+
const cause = error?.cause;
|
|
145
|
+
const detail = cause ? `${cause.code ?? ''}${cause.code ? ' ' : ''}${cause.message ?? cause}`.trim() : '';
|
|
146
|
+
return { status: 0, ok: false, json: null, text: '', error: `${error?.message ?? error}${detail ? ` [${detail}]` : ''}` };
|
|
147
|
+
} finally {
|
|
148
|
+
clearTimeout(timer);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Why the relay refused, in the caller's terms. */
|
|
153
|
+
function refusal(result) {
|
|
154
|
+
const code = result.json?.error?.code ?? null;
|
|
155
|
+
const message = result.json?.error?.message ?? result.error ?? result.text.slice(0, 200);
|
|
156
|
+
if (result.status === 401 || code === 'OPERATOR_REQUIRED') {
|
|
157
|
+
// The relay names the credential it wanted; the two cases need different fixes and one of
|
|
158
|
+
// them is a bug in this file, so they are not merged.
|
|
159
|
+
if (String(message).includes('device_token')) {
|
|
160
|
+
return {
|
|
161
|
+
reason: 'device_token_required',
|
|
162
|
+
text: '读取任务需要 device token(不是 operator token)。这台机器还没配对,或者身份文件里没有 token。',
|
|
163
|
+
code,
|
|
164
|
+
message,
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
return {
|
|
168
|
+
reason: 'operator_required',
|
|
169
|
+
text: '这个 relay 要求 operator token 才能派发任务,而发过去的那个没被接受。设备 token 无法派发任务。',
|
|
170
|
+
code,
|
|
171
|
+
message,
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
if (code === 'NO_ONLINE_DEVICE') {
|
|
175
|
+
return { reason: 'no_online_device', text: '现在没有机器在线,先在各机器上启动 agent。', code, message };
|
|
176
|
+
}
|
|
177
|
+
return { reason: 'refused', text: `relay 拒绝了请求(HTTP ${result.status}${code ? ` ${code}` : ''}):${message}`, code, message };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Dispatch one task.
|
|
182
|
+
*
|
|
183
|
+
* @param {string[]} argv
|
|
184
|
+
* @param {object} [extra] Any further relay fields (mode, write, cwd_rel, …).
|
|
185
|
+
*/
|
|
186
|
+
async function run(argv, extra = {}) {
|
|
187
|
+
if (!Array.isArray(argv) || argv.length === 0) {
|
|
188
|
+
return { ok: false, reason: 'bad_argv', text: '需要一条命令,例如 /run node --test' };
|
|
189
|
+
}
|
|
190
|
+
const result = await relay(RELAY.TASK, {
|
|
191
|
+
method: 'POST',
|
|
192
|
+
body: { command_argv: argv, mode: extra.mode ?? 'replicate', ...extra },
|
|
193
|
+
credential: 'operator',
|
|
194
|
+
});
|
|
195
|
+
if (!result.ok) {
|
|
196
|
+
const bad = refusal(result);
|
|
197
|
+
audit('fleet/refused', { status: result.status, ...bad });
|
|
198
|
+
return { ok: false, ...bad };
|
|
199
|
+
}
|
|
200
|
+
const taskId = result.json?.task_id ?? result.json?.id ?? null;
|
|
201
|
+
if (typeof taskId !== 'string' || taskId === '') {
|
|
202
|
+
return { ok: false, reason: 'no_task_id', text: 'relay 接受了请求但没有返回 task_id,无法跟踪结果。' };
|
|
203
|
+
}
|
|
204
|
+
audit('fleet/dispatched', { task_id: taskId, argv });
|
|
205
|
+
return { ok: true, taskId, raw: result.json };
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** One status read. Reads with the device token, which is what the relay accepts here. */
|
|
209
|
+
async function status(taskId) {
|
|
210
|
+
const result = await relay(RELAY.TASK_STATUS(taskId), { credential: 'device' });
|
|
211
|
+
if (!result.ok) return { ok: false, ...refusal(result) };
|
|
212
|
+
return { ok: true, verdict: verdictOf(result.json), rows: rowsOf(result.json), raw: result.json };
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Wait for a terminal verdict, or give up and say so.
|
|
217
|
+
*
|
|
218
|
+
* `timeout` is returned as a verdict rather than thrown: "it is still running" is a real
|
|
219
|
+
* answer to give someone on a phone, and it is not a failure of the request.
|
|
220
|
+
*/
|
|
221
|
+
async function wait(taskId, { maxMs = 120_000, pollMs = 1500 } = {}) {
|
|
222
|
+
const deadline = now() + maxMs;
|
|
223
|
+
let last = null;
|
|
224
|
+
while (now() < deadline) {
|
|
225
|
+
const read = await status(taskId);
|
|
226
|
+
if (!read.ok) return read;
|
|
227
|
+
last = read;
|
|
228
|
+
if (isTerminal(read.verdict)) {
|
|
229
|
+
return { ok: true, verdict: read.verdict, rows: read.rows, body: read.raw, terminal: true };
|
|
230
|
+
}
|
|
231
|
+
await sleep(pollMs);
|
|
232
|
+
}
|
|
233
|
+
return {
|
|
234
|
+
ok: true,
|
|
235
|
+
verdict: 'timeout',
|
|
236
|
+
rows: last?.rows ?? [],
|
|
237
|
+
body: last?.raw ?? null,
|
|
238
|
+
terminal: false,
|
|
239
|
+
note: 'still running when the wait expired',
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** The per-machine report. Also reads with the device token. */
|
|
244
|
+
async function report(taskId) {
|
|
245
|
+
const result = await relay(RELAY.TASK_REPORT(taskId), { credential: 'device' });
|
|
246
|
+
if (!result.ok) return { ok: false, ...refusal(result) };
|
|
247
|
+
return { ok: true, verdict: verdictOf(result.json), rows: rowsOf(result.json), raw: result.json };
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** The machines the relay currently holds. */
|
|
251
|
+
async function devices() {
|
|
252
|
+
// Measured: /v1/devices wants the device token too, on this relay build. The operator token
|
|
253
|
+
// still authorises the dispatch; the reads all want the device identity.
|
|
254
|
+
const result = await relay(RELAY.DEVICES, { credential: 'device' });
|
|
255
|
+
if (!result.ok) return { ok: false, ...refusal(result) };
|
|
256
|
+
const list = Array.isArray(result.json?.devices) ? result.json.devices : [];
|
|
257
|
+
return { ok: true, devices: list };
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
return {
|
|
261
|
+
run,
|
|
262
|
+
wait,
|
|
263
|
+
status,
|
|
264
|
+
report,
|
|
265
|
+
devices,
|
|
266
|
+
relay,
|
|
267
|
+
/** Whether a task read is possible at all. Said up front rather than as a 401 later. */
|
|
268
|
+
get canReadTasks() {
|
|
269
|
+
return typeof deviceToken === 'string' && deviceToken !== '';
|
|
270
|
+
},
|
|
271
|
+
};
|
|
272
|
+
}
|