@or3/cloud 0.1.66 → 0.1.68
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 +85 -9
- package/assets/Caddyfile +4 -3
- package/assets/dashboard-operator.mjs +211 -25
- package/dist/cli.mjs +1376 -150
- package/package.json +6 -5
package/README.md
CHANGED
|
@@ -47,10 +47,14 @@ Run these from the deployment directory created by `init`:
|
|
|
47
47
|
```sh
|
|
48
48
|
npx @or3/cloud doctor
|
|
49
49
|
npx @or3/cloud verify
|
|
50
|
+
npx @or3/cloud verify --read-only
|
|
50
51
|
npx @or3/cloud backup
|
|
52
|
+
npx @or3/cloud backup list
|
|
53
|
+
npx @or3/cloud update --dry-run
|
|
51
54
|
npx @or3/cloud update
|
|
52
55
|
npx @or3/cloud rollback --yes
|
|
53
56
|
npx @or3/cloud recover
|
|
57
|
+
npx @or3/cloud recover --dry-run
|
|
54
58
|
npx @or3/cloud status
|
|
55
59
|
npx @or3/cloud logs --tail 200
|
|
56
60
|
npx @or3/cloud start
|
|
@@ -94,15 +98,87 @@ command holds one deployment-wide lease; do not delete `.or3-cloud` lock or
|
|
|
94
98
|
recovery files by hand.
|
|
95
99
|
Update recovery reads the authenticated pre-update snapshot recorded in
|
|
96
100
|
`backupPath`/`backupId`; restore and rollback recovery use their separate
|
|
97
|
-
`previousBackupPath`/`previousBackupId` snapshot. Recovery
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
101
|
+
`previousBackupPath`/`previousBackupId` snapshot. Recovery now separates the
|
|
102
|
+
two outcomes explicitly:
|
|
103
|
+
|
|
104
|
+
- `npx @or3/cloud recover --dry-run` explains the observed source/target, phase,
|
|
105
|
+
available evidence, the permitted actions, and each action's data-loss
|
|
106
|
+
boundary without changing anything.
|
|
107
|
+
- `npx @or3/cloud recover --finish` commits an existing live target only when
|
|
108
|
+
the recorded target-ready milestone is proven (replacement completed, exact
|
|
109
|
+
image, configuration and assets unchanged since the milestone, authenticated
|
|
110
|
+
rollback snapshot, deep health). It preserves writes made after replacement.
|
|
111
|
+
- `npx @or3/cloud recover --restore --yes` is the explicit destructive choice;
|
|
112
|
+
it names the snapshot and discards writes made after it.
|
|
113
|
+
|
|
114
|
+
Plain `npx @or3/cloud recover` finishes only proven non-destructive work and
|
|
115
|
+
otherwise prints the assessment; it never silently falls back to a restore.
|
|
116
|
+
`recover --finish` is idempotent — repeating it after completion reports the
|
|
117
|
+
recorded outcome without replaying replacement or deleting the rollback point.
|
|
118
|
+
While any incomplete operation is recorded, `start` and `restart` refuse rather
|
|
119
|
+
than resurrect an ambiguous deployment; `stop` remains available, and
|
|
120
|
+
diagnostics, logs, and read-only verification stay usable throughout.
|
|
121
|
+
An update commits its terminal state (target identity, rollback reference,
|
|
122
|
+
receipt, and the absence of a pending operation) in one atomic write **before**
|
|
123
|
+
optional retention and operation-mirror cleanup. If that housekeeping fails,
|
|
124
|
+
the update still reports complete with a maintenance warning, exits 0, and
|
|
125
|
+
never recreates a pending operation or restores old data. A crash during the
|
|
126
|
+
final write is resolved on retry from durable state, not guessed from health.
|
|
127
|
+
The same rule covers restore, rollback, and recovery completion: a cleanup
|
|
128
|
+
failure after a terminal commit is a warning and never reopens the operation.
|
|
129
|
+
|
|
130
|
+
Once an update has started the target (the point at which it may accept
|
|
131
|
+
writes), a failed health check is **not** restored automatically. The journal is
|
|
132
|
+
preserved and the operator must choose `recover --finish` (when a proven
|
|
133
|
+
target-ready milestone exists) or `recover --restore --yes` (the explicit
|
|
134
|
+
data-loss choice). `recover --restore --yes` works even for a target-ready
|
|
135
|
+
journal, so the advertised escape path is always available.
|
|
136
|
+
|
|
137
|
+
This release is the compatibility **bridge**: it reads managed state schema 1
|
|
138
|
+
and 2 but writes schema 1. A release explicitly qualified to write schema 2
|
|
139
|
+
sets `or3Cloud.stateSchema` in its package metadata, and only then may migrate a
|
|
140
|
+
settled schema-1 deployment — and only when its source is at or above the
|
|
141
|
+
declared `dashboardUpdateMinimumSourceVersion` bridge. A bridge CLI refuses to
|
|
142
|
+
mutate state written by a newer schema and directs the owner to the compatible
|
|
143
|
+
exact-target CLI. An unknown future schema is refused before any mutation.
|
|
144
|
+
|
|
145
|
+
`npx @or3/cloud status --json` emits a bounded public projection: it never
|
|
146
|
+
serializes credential-reset recovery payloads, raw configuration, or secret
|
|
147
|
+
values. Status reports the digest of the image actually running the `or3`
|
|
148
|
+
service, and it still reports readable state when the managed `.env` is
|
|
149
|
+
unreadable instead of failing through Compose. `logs`, `verify --read-only`, and
|
|
150
|
+
`backup list` are observation-only. Read-only verification reports checks it
|
|
151
|
+
deliberately skips (login/storage probes, SQLite inspection, log scanning) as
|
|
152
|
+
`deferred`; only an actual failed check is nonzero.
|
|
153
|
+
|
|
154
|
+
`update --dry-run` and the real `update` share one assessment: execution runs the
|
|
155
|
+
same checks under the lease and refuses before any pull or data change when a
|
|
156
|
+
blocker (including an unreadable required asset) is present. `backup prune`
|
|
157
|
+
inventories the store once and revalidates only the entry it is about to delete,
|
|
158
|
+
so large histories are not rehashed quadratically.
|
|
159
|
+
|
|
160
|
+
With `--json`, `update` and `recover` write exactly one
|
|
161
|
+
`or3-operation-result` object to stdout for every terminal path (success, no-op,
|
|
162
|
+
blocked, or failure) and route all progress to stderr. Post-commit maintenance
|
|
163
|
+
warnings (a failed mirror delete, deferred or failed retention, a failed handoff
|
|
164
|
+
schedule) are persisted into the authoritative receipt, so a reload or the
|
|
165
|
+
dashboard still sees them.
|
|
166
|
+
`backup list` classifies every backup-store entry instead of skipping or
|
|
167
|
+
aborting on the unusual ones: `verified`, `legacy-unsigned` (no
|
|
168
|
+
`manifest.auth`), `legacy-adoption` (`adopt-source-*`), `unsupported` (a newer
|
|
169
|
+
manifest schema), `invalid`, and `unreadable`. Only `verified` entries count as
|
|
170
|
+
authenticated restore points; the rest are preserved and clearly labelled.
|
|
171
|
+
`backup list --json` returns the same classification as one parseable object
|
|
172
|
+
and still exits successfully when a store contains unsupported or damaged
|
|
173
|
+
entries. `backup prune` protects the rollback point, the pending update
|
|
174
|
+
snapshot, and every restore/rollback source; it defers automatic pruning when a
|
|
175
|
+
suspect entry is present and reports a specific blocked result instead of
|
|
176
|
+
deleting anything. `--force --yes` bypasses the suspect-entry deferral but can
|
|
177
|
+
never remove a protected recovery source. Retention never signs, migrates, or
|
|
178
|
+
deletes historical archives, and restore, recovery, and export still require
|
|
179
|
+
valid deployment authentication. To obtain a trusted restore point from a
|
|
180
|
+
store that only contains legacy history, create a new backup of the current
|
|
181
|
+
healthy deployment.
|
|
106
182
|
Backup artifacts are allocated in that journal before archiving, fully
|
|
107
183
|
revalidated before success is reported, and safely removed by `recover` if a
|
|
108
184
|
hard interruption leaves an incomplete artifact. A standalone backup and a
|
package/assets/Caddyfile
CHANGED
|
@@ -8,9 +8,10 @@
|
|
|
8
8
|
X-Frame-Options "DENY"
|
|
9
9
|
Referrer-Policy "strict-origin-when-cross-origin"
|
|
10
10
|
Permissions-Policy "camera=(), microphone=(), geolocation=()"
|
|
11
|
-
# Nuxt needs same-origin scripts plus inline hydration styles/scripts
|
|
12
|
-
#
|
|
13
|
-
|
|
11
|
+
# Nuxt needs same-origin scripts plus inline hydration styles/scripts. The
|
|
12
|
+
# browser exchanges OpenRouter PKCE authorization codes directly with the
|
|
13
|
+
# OpenRouter API, so that origin must remain available to connect-src.
|
|
14
|
+
Content-Security-Policy "default-src 'self'; base-uri 'self'; object-src 'none'; frame-ancestors 'none'; form-action 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self' https://openrouter.ai; media-src 'self' blob:; worker-src 'self' blob:; frame-src 'none'; upgrade-insecure-requests"
|
|
14
15
|
}
|
|
15
16
|
|
|
16
17
|
encode zstd gzip
|
|
@@ -24,6 +24,13 @@ const expectedWorkflow = '.github/workflows/release-cloud.yml';
|
|
|
24
24
|
const stableVersion = /^\d+\.\d+\.\d+$/;
|
|
25
25
|
const requestIdPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
26
26
|
const activePhases = new Set(['queued', 'running']);
|
|
27
|
+
// Protocol 1 responses are retained verbatim for older app readers; protocol 2
|
|
28
|
+
// is served from the versioned /v2/* routes. The operator accepts updater
|
|
29
|
+
// packages declaring either protocol, so an old protocol-1 operator still
|
|
30
|
+
// rejects a protocol-2 package and forces the documented bridge upgrade.
|
|
31
|
+
const supportedDashboardUpdateProtocols = new Set([1, 2]);
|
|
32
|
+
const currentProtocolVersion = 2;
|
|
33
|
+
const supportedStateSchemas = new Set([1, 2]);
|
|
27
34
|
const maxRegistryBytes = 256 * 1024;
|
|
28
35
|
const maxAttestationBytes = 1024 * 1024;
|
|
29
36
|
const maxProcessOutputBytes = 64 * 1024;
|
|
@@ -32,6 +39,7 @@ const operatorHandoffTimeoutMs = 2 * 60 * 1000;
|
|
|
32
39
|
let updateClaimed = false;
|
|
33
40
|
const checkAttempts = [];
|
|
34
41
|
const startAttempts = [];
|
|
42
|
+
const previewAttempts = [];
|
|
35
43
|
let auditQueue = Promise.resolve();
|
|
36
44
|
|
|
37
45
|
export function consumeRateLimit(attempts, now, limit, windowMs) {
|
|
@@ -239,7 +247,7 @@ async function release(requestedVersion) {
|
|
|
239
247
|
if (
|
|
240
248
|
metadata?.name !== packageName
|
|
241
249
|
|| !stableVersion.test(version)
|
|
242
|
-
|| metadata?.or3Cloud?.dashboardUpdateProtocol
|
|
250
|
+
|| !supportedDashboardUpdateProtocols.has(metadata?.or3Cloud?.dashboardUpdateProtocol)
|
|
243
251
|
|| typeof minimumSourceVersion !== 'string'
|
|
244
252
|
|| !stableVersion.test(minimumSourceVersion)
|
|
245
253
|
|| typeof operatorImageDigest !== 'string'
|
|
@@ -268,7 +276,9 @@ async function release(requestedVersion) {
|
|
|
268
276
|
async function managedState() {
|
|
269
277
|
try {
|
|
270
278
|
const state = JSON.parse(await readFile(statePath, 'utf8'));
|
|
271
|
-
|
|
279
|
+
// Bridge/schema-2 readers observe both formats; a future schema is refused
|
|
280
|
+
// before any mutation instead of being misread as schema 1.
|
|
281
|
+
if (!state || typeof state !== 'object' || !supportedStateSchemas.has(state.schemaVersion)) throw new Error();
|
|
272
282
|
return state;
|
|
273
283
|
} catch {
|
|
274
284
|
throw new Error('The managed deployment state is unavailable or invalid.');
|
|
@@ -283,7 +293,31 @@ async function assertUpdateReady() {
|
|
|
283
293
|
return state;
|
|
284
294
|
}
|
|
285
295
|
|
|
286
|
-
|
|
296
|
+
/**
|
|
297
|
+
* A completed application commit with an unfinished privileged handoff must
|
|
298
|
+
* not start another dashboard mutation. Release checks remain available so an
|
|
299
|
+
* operator can still observe the state and the guidance.
|
|
300
|
+
*/
|
|
301
|
+
async function assertHandoffReady() {
|
|
302
|
+
const state = await managedState();
|
|
303
|
+
const handoff = state.lastReceipt?.operatorHandoff;
|
|
304
|
+
if (handoff === 'pending' || handoff === 'needs-attention') {
|
|
305
|
+
throw new Error('The previous update finished, but its dashboard operator handoff needs reconciliation. Run `npx @or3/cloud recover --finish` on the host before starting another dashboard update.');
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/** Bounded latest terminal receipt from the CLI, exposed only on protocol 2. */
|
|
310
|
+
async function readLastReceipt() {
|
|
311
|
+
try {
|
|
312
|
+
const receipt = JSON.parse(await readFile(join(cloudDirectory, 'last-operation.json'), 'utf8'));
|
|
313
|
+
if (!receipt || typeof receipt !== 'object' || receipt.schemaVersion !== 1) return undefined;
|
|
314
|
+
return receipt;
|
|
315
|
+
} catch {
|
|
316
|
+
return undefined;
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
async function status(includeReceipt = false) {
|
|
287
321
|
const version = await currentVersion();
|
|
288
322
|
const [job, checked] = await Promise.all([readJob(), readReleaseCheck()]);
|
|
289
323
|
return {
|
|
@@ -303,10 +337,11 @@ async function status() {
|
|
|
303
337
|
completedAt: job.completedAt,
|
|
304
338
|
error: job.error,
|
|
305
339
|
} : null,
|
|
340
|
+
...(includeReceipt ? { receipt: await readLastReceipt() } : {}),
|
|
306
341
|
};
|
|
307
342
|
}
|
|
308
343
|
|
|
309
|
-
async function check() {
|
|
344
|
+
async function check(includeReceipt = false) {
|
|
310
345
|
await assertUpdateReady();
|
|
311
346
|
const previous = await readReleaseCheck();
|
|
312
347
|
const checkedAt = new Date().toISOString();
|
|
@@ -330,7 +365,7 @@ async function check() {
|
|
|
330
365
|
updateAvailable: compareVersions(latest.version, current) > 0,
|
|
331
366
|
};
|
|
332
367
|
await writeReleaseCheck({ schemaVersion: 1, checkedAt, lastSuccessful });
|
|
333
|
-
return await status();
|
|
368
|
+
return await status(includeReceipt);
|
|
334
369
|
} catch (error) {
|
|
335
370
|
const message = (error instanceof Error ? error.message : String(error)).slice(0, 4096);
|
|
336
371
|
const persisted = await readReleaseCheck().catch(() => previous);
|
|
@@ -372,6 +407,8 @@ function runProcess(file, args, options) {
|
|
|
372
407
|
});
|
|
373
408
|
return new Promise((resolve, reject) => {
|
|
374
409
|
let output = '';
|
|
410
|
+
let stdout = '';
|
|
411
|
+
let stderr = '';
|
|
375
412
|
let settled = false;
|
|
376
413
|
const finish = (error, result) => {
|
|
377
414
|
if (settled) return;
|
|
@@ -403,10 +440,12 @@ function runProcess(file, args, options) {
|
|
|
403
440
|
finish(new Error('The updater command returned too much output.'));
|
|
404
441
|
}
|
|
405
442
|
};
|
|
443
|
+
child.stdout.on('data', (chunk) => { stdout += chunk.toString('utf8'); });
|
|
444
|
+
child.stderr.on('data', (chunk) => { stderr += chunk.toString('utf8'); });
|
|
406
445
|
child.stdout.on('data', append);
|
|
407
446
|
child.stderr.on('data', append);
|
|
408
447
|
child.once('error', (error) => finish(error));
|
|
409
|
-
child.once('close', (code, signal) => finish(undefined, { code, signal, output }));
|
|
448
|
+
child.once('close', (code, signal) => finish(undefined, { code, signal, output, stdout, stderr }));
|
|
410
449
|
const timer = options.timeoutMs
|
|
411
450
|
? setTimeout(() => {
|
|
412
451
|
terminate();
|
|
@@ -503,7 +542,7 @@ async function verifyInstalledUpdater(installDirectory, expectedRelease, job, pr
|
|
|
503
542
|
|| manifest?.repository?.url !== 'git+https://github.com/Saluana/or3-chat.git'
|
|
504
543
|
|| manifest?.repository?.directory !== 'packages/or3-cloud'
|
|
505
544
|
|| manifest?.bin?.or3 !== './dist/cli.mjs'
|
|
506
|
-
|| manifest?.or3Cloud?.dashboardUpdateProtocol
|
|
545
|
+
|| !supportedDashboardUpdateProtocols.has(manifest?.or3Cloud?.dashboardUpdateProtocol)
|
|
507
546
|
|| !stableVersion.test(manifest?.or3Cloud?.dashboardUpdateMinimumSourceVersion || '')
|
|
508
547
|
|| !/^sha256:[0-9a-f]{64}$/.test(manifest?.or3Cloud?.imageDigest || '')
|
|
509
548
|
|| !/^sha256:[0-9a-f]{64}$/.test(manifest?.or3Cloud?.operatorImageDigest || '')
|
|
@@ -618,10 +657,134 @@ async function runRecovery(job) {
|
|
|
618
657
|
});
|
|
619
658
|
}
|
|
620
659
|
|
|
660
|
+
/**
|
|
661
|
+
* Runs the exact verified updater in read-only preview mode and returns its
|
|
662
|
+
* parsed assessment. It only ever invokes the pinned CLI `update --dry-run`;
|
|
663
|
+
* no caller-provided command, package location, or extra argument is accepted.
|
|
664
|
+
*/
|
|
665
|
+
async function runPreview() {
|
|
666
|
+
const expectedRelease = await release();
|
|
667
|
+
return await withVerifiedUpdater(expectedRelease, { id: randomUUID() }, async (updater, installDirectory) => {
|
|
668
|
+
const previewed = await runProcess(nodeBinary, [updater.cli, 'update', '--to', expectedRelease.version, '--dry-run', '--json'], {
|
|
669
|
+
cwd: deploymentDirectory,
|
|
670
|
+
env: updaterEnvironment(installDirectory, { id: 'preview' }, updater.imageDigest),
|
|
671
|
+
maxOutputBytes: 512 * 1024,
|
|
672
|
+
timeoutMs: maxUpdateDurationMs,
|
|
673
|
+
});
|
|
674
|
+
// Exit 1 means the assessment reported blockers, which is still a valid result.
|
|
675
|
+
if (previewed.code !== 0 && previewed.code !== 1) {
|
|
676
|
+
throw new Error(`The update preview could not run: ${processDiagnostic(previewed)}`);
|
|
677
|
+
}
|
|
678
|
+
let parsed;
|
|
679
|
+
try {
|
|
680
|
+
parsed = JSON.parse(previewed.stdout);
|
|
681
|
+
} catch {
|
|
682
|
+
throw new Error('The update preview did not return a readable assessment.');
|
|
683
|
+
}
|
|
684
|
+
return { version: expectedRelease.version, assessment: parsed };
|
|
685
|
+
});
|
|
686
|
+
}
|
|
687
|
+
|
|
621
688
|
function closeAfter(server, code) {
|
|
622
689
|
setTimeout(() => server.close(() => process.exit(code)), 250).unref();
|
|
623
690
|
}
|
|
624
691
|
|
|
692
|
+
const leasePath = join(cloudDirectory, 'operation-lease');
|
|
693
|
+
|
|
694
|
+
/**
|
|
695
|
+
* Acquires the same single-writer deployment lease the CLI uses. Returns a
|
|
696
|
+
* release function, or undefined when another operation owns the lease.
|
|
697
|
+
*/
|
|
698
|
+
async function acquireDeploymentLease() {
|
|
699
|
+
await mkdir(dirname(leasePath), { recursive: true, mode: 0o700 });
|
|
700
|
+
const owner = {
|
|
701
|
+
schemaVersion: 1,
|
|
702
|
+
nonce: randomUUID(),
|
|
703
|
+
command: 'operator-handoff',
|
|
704
|
+
origin: 'cli',
|
|
705
|
+
pid: process.pid,
|
|
706
|
+
acquiredAt: new Date().toISOString(),
|
|
707
|
+
heartbeatAt: new Date().toISOString(),
|
|
708
|
+
};
|
|
709
|
+
try {
|
|
710
|
+
await mkdir(leasePath, { recursive: false, mode: 0o700 });
|
|
711
|
+
} catch (error) {
|
|
712
|
+
if (error && typeof error === 'object' && error.code === 'EEXIST') return undefined;
|
|
713
|
+
throw error;
|
|
714
|
+
}
|
|
715
|
+
try {
|
|
716
|
+
const ownerPath = join(leasePath, 'owner.json');
|
|
717
|
+
await writeFile(ownerPath, `${JSON.stringify(owner, null, 2)}\n`, { mode: 0o600 });
|
|
718
|
+
await chmod(ownerPath, 0o600);
|
|
719
|
+
} catch (error) {
|
|
720
|
+
await rm(leasePath, { recursive: true, force: true }).catch(() => undefined);
|
|
721
|
+
throw error;
|
|
722
|
+
}
|
|
723
|
+
return async () => {
|
|
724
|
+
await rm(leasePath, { recursive: true, force: true }).catch(() => undefined);
|
|
725
|
+
};
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
async function atomicWriteJson(path, value) {
|
|
729
|
+
const temporary = `${path}.${randomUUID()}.tmp`;
|
|
730
|
+
await writeFile(temporary, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
|
|
731
|
+
await chmod(temporary, 0o600);
|
|
732
|
+
await durableRename(temporary, path);
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
/**
|
|
736
|
+
* Marks the recorded terminal receipt's operator handoff verified once the
|
|
737
|
+
* successor operator is running. Runs under the deployment lease, reloads the
|
|
738
|
+
* authoritative state, and verifies the recorded operation/job identity before
|
|
739
|
+
* persisting; a concurrent CLI operation is never overwritten. Bounded and
|
|
740
|
+
* best-effort: a write failure must not roll back the healthy application.
|
|
741
|
+
*/
|
|
742
|
+
async function markHandoffVerified(jobId) {
|
|
743
|
+
let release;
|
|
744
|
+
try {
|
|
745
|
+
release = await acquireDeploymentLease();
|
|
746
|
+
} catch (error) {
|
|
747
|
+
console.error(`Could not acquire the deployment lease to record the operator handoff: ${error instanceof Error ? error.message : String(error)}`);
|
|
748
|
+
return;
|
|
749
|
+
}
|
|
750
|
+
if (!release) {
|
|
751
|
+
console.error('Could not mark the operator handoff verified: another operation owns the deployment lease. Run `npx @or3/cloud recover --finish` on the host to reconcile.');
|
|
752
|
+
return;
|
|
753
|
+
}
|
|
754
|
+
try {
|
|
755
|
+
let state;
|
|
756
|
+
try {
|
|
757
|
+
state = JSON.parse(await readFile(statePath, 'utf8'));
|
|
758
|
+
} catch {
|
|
759
|
+
return;
|
|
760
|
+
}
|
|
761
|
+
if (!state || typeof state !== 'object' || state.incompleteOperation) return;
|
|
762
|
+
const receipt = state.lastReceipt;
|
|
763
|
+
if (receipt && typeof receipt === 'object') {
|
|
764
|
+
if (receipt.operatorHandoff !== 'pending' && receipt.operatorHandoff !== 'needs-attention') return;
|
|
765
|
+
if (receipt.dashboardJobId && jobId && receipt.dashboardJobId !== jobId) return;
|
|
766
|
+
receipt.operatorHandoff = 'verified';
|
|
767
|
+
state.updatedAt = new Date().toISOString();
|
|
768
|
+
await atomicWriteJson(statePath, state);
|
|
769
|
+
}
|
|
770
|
+
const receiptPath = join(cloudDirectory, 'last-operation.json');
|
|
771
|
+
try {
|
|
772
|
+
const mirror = JSON.parse(await readFile(receiptPath, 'utf8'));
|
|
773
|
+
if (mirror && typeof mirror === 'object' && mirror.schemaVersion === 1) {
|
|
774
|
+
if (mirror.dashboardJobId && jobId && mirror.dashboardJobId !== jobId) return;
|
|
775
|
+
mirror.operatorHandoff = 'verified';
|
|
776
|
+
await atomicWriteJson(receiptPath, mirror);
|
|
777
|
+
}
|
|
778
|
+
} catch {
|
|
779
|
+
// The authoritative state write is sufficient; the mirror is convenience.
|
|
780
|
+
}
|
|
781
|
+
} catch (error) {
|
|
782
|
+
console.error(`Could not mark the operator handoff verified: ${error instanceof Error ? error.message : String(error)}`);
|
|
783
|
+
} finally {
|
|
784
|
+
await release();
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
|
|
625
788
|
async function completeOperatorHandoff(jobId, project) {
|
|
626
789
|
if (!requestIdPattern.test(jobId || '') || !/^[a-z0-9][a-z0-9_-]*$/i.test(project || '')) {
|
|
627
790
|
throw new Error('The dashboard operator handoff arguments are invalid.');
|
|
@@ -669,7 +832,10 @@ async function completeOperatorHandoff(jobId, project) {
|
|
|
669
832
|
})
|
|
670
833
|
: { code: restarted.code, output: '' };
|
|
671
834
|
lastOutput = `${lastOutput}\n${restarted.output}\n${running.output}`.trim();
|
|
672
|
-
if (restarted.code === 0 && running.code === 0 && /^[0-9a-f]{12,64}$/i.test(running.output.trim()))
|
|
835
|
+
if (restarted.code === 0 && running.code === 0 && /^[0-9a-f]{12,64}$/i.test(running.output.trim())) {
|
|
836
|
+
await markHandoffVerified(jobId);
|
|
837
|
+
return;
|
|
838
|
+
}
|
|
673
839
|
await delay(2_000);
|
|
674
840
|
}
|
|
675
841
|
throw new Error(`The dashboard operator handoff could not start its successor: ${lastOutput}`);
|
|
@@ -809,53 +975,60 @@ async function main() {
|
|
|
809
975
|
await rm(socketPath, { force: true });
|
|
810
976
|
|
|
811
977
|
const server = createServer(async (request, response) => {
|
|
978
|
+
// Protocol 2 is additive: the versioned routes are the same handlers with a
|
|
979
|
+
// protocolVersion field, while /status, /check, and /start remain byte-for-
|
|
980
|
+
// byte protocol-1 responses for existing exact-key app validators.
|
|
981
|
+
const isV2 = ['/v2/status', '/v2/check', '/v2/start', '/v2/preview'].includes(request.url);
|
|
982
|
+
const route = isV2 ? request.url.slice(3) : request.url;
|
|
983
|
+
const reply = (statusCode, payload) => send(response, statusCode, isV2 ? { protocolVersion: currentProtocolVersion, ...payload } : payload);
|
|
812
984
|
try {
|
|
813
|
-
if (request.method === 'GET' &&
|
|
814
|
-
if (request.method === 'POST' &&
|
|
985
|
+
if (request.method === 'GET' && route === '/status') return reply(200, await status(isV2));
|
|
986
|
+
if (request.method === 'POST' && route === '/check') {
|
|
815
987
|
if (!consumeRateLimit(checkAttempts, Date.now(), 30, 60_000)) {
|
|
816
988
|
await audit('check_rate_limited');
|
|
817
|
-
return
|
|
989
|
+
return reply(429, { message: 'Release checks are temporarily rate limited.' });
|
|
818
990
|
}
|
|
819
|
-
const checked = await check();
|
|
991
|
+
const checked = await check(isV2);
|
|
820
992
|
await audit('release_checked', { latestVersion: checked.latestVersion, updateAvailable: checked.updateAvailable });
|
|
821
|
-
return
|
|
993
|
+
return reply(200, checked);
|
|
822
994
|
}
|
|
823
|
-
if (request.method === 'POST' &&
|
|
995
|
+
if (request.method === 'POST' && route === '/start') {
|
|
824
996
|
if (!consumeRateLimit(startAttempts, Date.now(), 6, 60_000)) {
|
|
825
997
|
await audit('start_rate_limited');
|
|
826
|
-
return
|
|
998
|
+
return reply(429, { message: 'Update starts are temporarily rate limited.' });
|
|
827
999
|
}
|
|
828
1000
|
const input = await body(request);
|
|
829
1001
|
if (!validStartInput(input)) {
|
|
830
1002
|
await audit('start_rejected_invalid');
|
|
831
|
-
return
|
|
1003
|
+
return reply(400, { message: 'A valid update request is required.' });
|
|
832
1004
|
}
|
|
833
1005
|
if (updateClaimed) {
|
|
834
1006
|
await audit('start_rejected_busy', { requestId: input.requestId, targetVersion: input.targetVersion });
|
|
835
|
-
return
|
|
1007
|
+
return reply(409, { message: 'An update is already starting or running.' });
|
|
836
1008
|
}
|
|
837
1009
|
updateClaimed = true;
|
|
838
1010
|
try {
|
|
839
|
-
|
|
1011
|
+
await assertHandoffReady();
|
|
1012
|
+
const checked = await check(isV2);
|
|
840
1013
|
if (!checked.updateAvailable) {
|
|
841
1014
|
updateClaimed = false;
|
|
842
1015
|
await audit('start_rejected_no_update', { requestId: input.requestId, targetVersion: input.targetVersion });
|
|
843
|
-
return
|
|
1016
|
+
return reply(409, { message: 'No newer supported release is available.' });
|
|
844
1017
|
}
|
|
845
1018
|
if (input.targetVersion !== checked.latestVersion) {
|
|
846
1019
|
updateClaimed = false;
|
|
847
1020
|
await audit('start_rejected_stale_target', { requestId: input.requestId, targetVersion: input.targetVersion });
|
|
848
|
-
return
|
|
1021
|
+
return reply(409, { message: 'The requested version is no longer the verified latest release. Check again.' });
|
|
849
1022
|
}
|
|
850
1023
|
const previous = await readJob();
|
|
851
1024
|
if (previous && activePhases.has(previous.phase)) {
|
|
852
1025
|
updateClaimed = false;
|
|
853
1026
|
if (previous.id === input.requestId) {
|
|
854
1027
|
await audit('start_replayed', { jobId: previous.id, targetVersion: previous.targetVersion });
|
|
855
|
-
return
|
|
1028
|
+
return reply(202, { ...checked, job: previous });
|
|
856
1029
|
}
|
|
857
1030
|
await audit('start_rejected_busy', { requestId: input.requestId, targetVersion: input.targetVersion });
|
|
858
|
-
return
|
|
1031
|
+
return reply(409, { message: 'An update is already running.' });
|
|
859
1032
|
}
|
|
860
1033
|
const job = {
|
|
861
1034
|
id: input.requestId,
|
|
@@ -865,7 +1038,7 @@ async function main() {
|
|
|
865
1038
|
};
|
|
866
1039
|
await writeJob(job);
|
|
867
1040
|
await audit('update_accepted', { jobId: job.id, targetVersion: job.targetVersion });
|
|
868
|
-
|
|
1041
|
+
reply(202, { ...checked, job });
|
|
869
1042
|
queueMicrotask(async () => {
|
|
870
1043
|
try {
|
|
871
1044
|
job.phase = 'running';
|
|
@@ -882,9 +1055,22 @@ async function main() {
|
|
|
882
1055
|
throw error;
|
|
883
1056
|
}
|
|
884
1057
|
}
|
|
885
|
-
|
|
1058
|
+
if (request.method === 'POST' && route === '/preview') {
|
|
1059
|
+
if (!consumeRateLimit(previewAttempts, Date.now(), 10, 60_000)) {
|
|
1060
|
+
await audit('preview_rate_limited');
|
|
1061
|
+
return reply(429, { message: 'Update previews are temporarily rate limited.' });
|
|
1062
|
+
}
|
|
1063
|
+
if (updateClaimed) {
|
|
1064
|
+
return reply(409, { message: 'An update is already starting or running.' });
|
|
1065
|
+
}
|
|
1066
|
+
const checked = await check(isV2);
|
|
1067
|
+
const preview = await runPreview();
|
|
1068
|
+
await audit('update_previewed', { latestVersion: checked.latestVersion });
|
|
1069
|
+
return reply(200, { ...checked, preview });
|
|
1070
|
+
}
|
|
1071
|
+
reply(404, { message: 'Not found.' });
|
|
886
1072
|
} catch (error) {
|
|
887
|
-
|
|
1073
|
+
reply(503, { message: error instanceof Error ? error.message : 'The update operator is unavailable.' });
|
|
888
1074
|
}
|
|
889
1075
|
});
|
|
890
1076
|
|