@edgehero/pi-dispatch 1.10.3 → 2.1.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/.env.example +303 -150
- package/README.md +52 -0
- package/deploy/com.pi-dispatch.worker.plist +10 -4
- package/deploy/docker-compose.yml +49 -16
- package/deploy/egress-proxy.conf +32 -2
- package/deploy/nssm-install.cmd +12 -6
- package/deploy/pi-dispatch-egress-out.network +10 -0
- package/deploy/pi-dispatch-egress-proxy.container +50 -0
- package/deploy/pi-dispatch-netns-keeper.container +80 -0
- package/deploy/pi-dispatch-netns-keeper.network +18 -0
- package/deploy/pi-dispatch-valkey.container +51 -0
- package/deploy/pi-dispatch-valkey.network +16 -0
- package/deploy/receiver.service +6 -0
- package/deploy/worker-env-wrapper.cmd +12 -1
- package/deploy/worker-env-wrapper.sh +63 -37
- package/deploy/worker.service +18 -8
- package/package.json +15 -5
- package/src/azure-host.mjs +19 -0
- package/src/azure-identity.mjs +18 -2
- package/src/backend-conformance.mjs +71 -18
- package/src/backend-local.mjs +637 -21
- package/src/backend-podman.mjs +1168 -0
- package/src/backend-registry.mjs +86 -3
- package/src/backends.mjs +489 -37
- package/src/branch.mjs +7 -2
- package/src/cancel-cli.mjs +174 -0
- package/src/cancel-state.mjs +125 -0
- package/src/cli.mjs +188 -90
- package/src/config.mjs +503 -43
- package/src/connection.mjs +374 -8
- package/src/container-spec.mjs +102 -7
- package/src/daemon-facts.mjs +167 -0
- package/src/deployment-venue.mjs +158 -0
- package/src/docker-run.mjs +146 -15
- package/src/doctor.mjs +4756 -394
- package/src/egress-conf-copy.mjs +166 -0
- package/src/egress-proxy-state.mjs +151 -0
- package/src/egress.mjs +456 -25
- package/src/entry.mjs +27 -0
- package/src/env-allowlist.mjs +245 -40
- package/src/env-file.mjs +1869 -33
- package/src/exit-code.mjs +15 -0
- package/src/flow-gate.mjs +5 -3
- package/src/forgejo-host.mjs +19 -0
- package/src/forgejo-identity.mjs +21 -2
- package/src/get-token.mjs +67 -18
- package/src/git-dirty.mjs +9 -1
- package/src/git-hardening.mjs +33 -0
- package/src/github-app-setup.mjs +29 -12
- package/src/github-prompt.mjs +4 -1
- package/src/gitlab-host.mjs +19 -0
- package/src/gitlab-identity.mjs +19 -2
- package/src/host-pi.mjs +19 -3
- package/src/host-registry.mjs +29 -2
- package/src/identity.mjs +29 -4
- package/src/image-preflight.mjs +46 -11
- package/src/image-ref.mjs +21 -0
- package/src/index.mjs +363 -13
- package/src/init.mjs +197 -38
- package/src/job-user.mjs +252 -0
- package/src/json-duplicates.mjs +204 -0
- package/src/live-probes.mjs +1020 -0
- package/src/materialize.mjs +4 -11
- package/src/netns-keeper.mjs +264 -0
- package/src/on-failure.mjs +119 -0
- package/src/outbox.mjs +7 -0
- package/src/packages.mjs +2 -2
- package/src/podman-stack.mjs +1304 -0
- package/src/prepare-github.mjs +6 -6
- package/src/prepare-local.mjs +51 -17
- package/src/prepare.mjs +27 -6
- package/src/pricing.mjs +9 -5
- package/src/processor.mjs +506 -26
- package/src/provider-key.mjs +66 -0
- package/src/provider-steering.mjs +185 -0
- package/src/queue.mjs +35 -8
- package/src/redact.mjs +84 -0
- package/src/reserved-env.mjs +7 -3
- package/src/retention-sweep.mjs +178 -0
- package/src/run-container.mjs +181 -14
- package/src/run-history.mjs +105 -16
- package/src/runtime-observations.mjs +1152 -0
- package/src/runtime-settings.mjs +13 -8
- package/src/sandbox-cli.mjs +100 -95
- package/src/sandbox-store.mjs +612 -45
- package/src/sandbox.mjs +1459 -37
- package/src/schedules.mjs +16 -3
- package/src/secret-profiles.mjs +2 -1
- package/src/secrets.mjs +24 -6
- package/src/service-env.mjs +247 -0
- package/src/service.mjs +618 -28
- package/src/session-store.mjs +678 -53
- package/src/start.mjs +1348 -326
- package/src/subscriptions.mjs +7 -3
- package/src/transient.mjs +240 -0
- package/src/triggers-file.mjs +71 -15
- package/src/triggers.mjs +179 -19
- package/src/up.mjs +1399 -85
- package/src/valkey-auth.mjs +529 -0
- package/src/valkey-endpoint.mjs +367 -0
- package/src/watch-closer.mjs +158 -0
package/src/runtime-settings.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import * as nodeFs from "node:fs";
|
|
2
2
|
import { dirname } from "node:path";
|
|
3
|
-
import {
|
|
3
|
+
import { ensureUnderAccountRoot } from "./config.mjs";
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* Runtime-settings overlay: the shared, durable truth between the admin extension and the worker
|
|
@@ -56,13 +56,15 @@ function isIntInRange(value, min, max) {
|
|
|
56
56
|
}
|
|
57
57
|
|
|
58
58
|
/**
|
|
59
|
-
* The absolute path of the settings overlay
|
|
60
|
-
*
|
|
61
|
-
*
|
|
59
|
+
* The absolute path of the settings overlay: `PI_SETTINGS_FILE` wins, an unset or empty value falls back
|
|
60
|
+
* to the durable default.
|
|
61
|
+
*
|
|
62
|
+
* RE-EXPORTED, not re-derived. It used to be a second copy of `config.mjs`'s expression, and two copies
|
|
63
|
+
* of a defaulting rule are how a panel and a worker come to read different files. This module keeps the
|
|
64
|
+
* name because `@edgehero/pi-dispatch/runtime-settings` is the import path the admin and the export-map
|
|
65
|
+
* probe already use; config.mjs owns the rule because it owns the default the rule falls back to.
|
|
62
66
|
*/
|
|
63
|
-
export
|
|
64
|
-
return env.PI_SETTINGS_FILE || defaultSettingsFile();
|
|
65
|
-
}
|
|
67
|
+
export { settingsFilePath } from "./config.mjs";
|
|
66
68
|
|
|
67
69
|
/**
|
|
68
70
|
* Validate a parsed overlay against the key contract, returning the sanitized overlay (known keys only)
|
|
@@ -201,9 +203,12 @@ export function writeOverlay(path, candidate, { fs = nodeFs, log = () => {} } =
|
|
|
201
203
|
if (result.invalid) return { invalid: result.invalid };
|
|
202
204
|
|
|
203
205
|
try {
|
|
206
|
+
// Issue #464: on an account with no home the overlay's default lives in the per-account temp root, which is made
|
|
207
|
+
// this account's (0700) or refused, as the worker's boot does, before a file that outranks .env is written there.
|
|
208
|
+
ensureUnderAccountRoot(dirname(path), { fs: { mkdirSync: fs.mkdirSync, lstatSync: fs.lstatSync ?? nodeFs.lstatSync, statSync: fs.statSync ?? nodeFs.statSync, chmodSync: fs.chmodSync ?? nodeFs.chmodSync } });
|
|
204
209
|
fs.mkdirSync(dirname(path), { recursive: true });
|
|
205
210
|
} catch (err) {
|
|
206
|
-
return { invalid: `settings dir unwritable (${err?.code ?? "mkdir-error"})` };
|
|
211
|
+
return { invalid: err?.piDispatchConfig ? `settings dir refused: ${err.message}` : `settings dir unwritable (${err?.code ?? "mkdir-error"})` };
|
|
207
212
|
}
|
|
208
213
|
|
|
209
214
|
const tmp = `${path}.tmp`;
|
package/src/sandbox-cli.mjs
CHANGED
|
@@ -1,29 +1,9 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
2
|
import { parseArgs } from "node:util";
|
|
3
|
-
import { DEFAULT_BACKEND, backendFor } from "./backends.mjs";
|
|
4
3
|
import { loadConfig } from "./config.mjs";
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
import { listSandboxes, pinSandbox } from "./sandbox-store.mjs";
|
|
9
|
-
|
|
10
|
-
/**
|
|
11
|
-
* Would this deployment's venues put a job's retained directory out of this command's reach? Exported ONLY
|
|
12
|
-
* so it can be driven.
|
|
13
|
-
*
|
|
14
|
-
* The refusal it answers is unreachable today: the table holds one backend and it is local, so
|
|
15
|
-
* `PI_BACKENDS` cannot name a remote venue. An unreachable rule with no test is a rule a mutation pass
|
|
16
|
-
* deletes in silence -- which is precisely what happened to the trigger-side remote refusal one slice ago,
|
|
17
|
-
* so this one is a pure predicate over an explicit config from the start.
|
|
18
|
-
*
|
|
19
|
-
* TRUE when any blessed venue is remote, not merely when the DEFAULT is: a job that ran there has a
|
|
20
|
-
* retained directory this host never had, and `pi-dispatch sandbox <jobId>` takes a job id rather than a
|
|
21
|
-
* venue, so it cannot know which one it is being asked about until the directory is already missing.
|
|
22
|
-
*/
|
|
23
|
-
export function sandboxUnreachableFrom(config) {
|
|
24
|
-
const names = config?.backends ?? [config?.defaultBackend ?? DEFAULT_BACKEND];
|
|
25
|
-
return names.some((n) => backendFor(n)?.remote !== false);
|
|
26
|
-
}
|
|
4
|
+
import { basename } from "node:path";
|
|
5
|
+
import { SANDBOX_OPEN_GRACE_MS, launchSandbox, listRunningSandboxes, openSandbox, parsePublish, sandboxContainerName, sandboxLauncher, sandboxVenueOf, sandboxVenueRefusal, sandboxVenues } from "./sandbox.mjs";
|
|
6
|
+
import { listSandboxes, pinSandbox, sandboxDeadline } from "./sandbox-store.mjs";
|
|
27
7
|
|
|
28
8
|
/**
|
|
29
9
|
* `pi-dispatch sandbox` -- re-open a finished run's sandbox as an interactive shell
|
|
@@ -46,6 +26,10 @@ export async function runSandbox(argv = [], { env = process.env, deps = {} } = {
|
|
|
46
26
|
// The docker spawn used for this session's egress network, seamed like `launch` so the tests never
|
|
47
27
|
// touch a daemon. Not used when PI_EGRESS=0.
|
|
48
28
|
spawnNetwork = spawn,
|
|
29
|
+
// Issue #341: which uid the shell runs as. Seamed so the tests never ask a daemon.
|
|
30
|
+
resolveJobUser,
|
|
31
|
+
// Issue #452, gate round 2: the keeper check an egress-armed podman open asks first. Seamed like `resolveJobUser`.
|
|
32
|
+
keeperCheck,
|
|
49
33
|
now = () => Date.now(),
|
|
50
34
|
} = deps;
|
|
51
35
|
|
|
@@ -69,7 +53,21 @@ export async function runSandbox(argv = [], { env = process.env, deps = {} } = {
|
|
|
69
53
|
// A docker that cannot be reached costs a column, never the command: `listRunningSandboxes` throws so
|
|
70
54
|
// the REAPER can tell "none" from "could not ask", and these callers only draw a marker. Asked only
|
|
71
55
|
// where it is used, and never before the arguments are known good -- a typo should not shell out.
|
|
72
|
-
|
|
56
|
+
//
|
|
57
|
+
// EVERY runtime a sandbox can open on here (issue #429), each asked on its own and each degrading on its own: a
|
|
58
|
+
// podman that cannot be asked must not blank docker's column, and the reverse. `running({ bin })` is the same seam
|
|
59
|
+
// `openSandbox` asks with, so one fake answers both.
|
|
60
|
+
const liveSandboxes = async () => {
|
|
61
|
+
const live = new Set();
|
|
62
|
+
for (const venue of sandboxVenues(config.backends)) {
|
|
63
|
+
for (const id of await Promise.resolve()
|
|
64
|
+
.then(() => running({ bin: sandboxLauncher(venue).bin }))
|
|
65
|
+
.catch(() => [])) {
|
|
66
|
+
live.add(id);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return live;
|
|
70
|
+
};
|
|
73
71
|
|
|
74
72
|
if (values.list) {
|
|
75
73
|
return renderList({ config, live: await liveSandboxes(), out, now });
|
|
@@ -85,11 +83,6 @@ export async function runSandbox(argv = [], { env = process.env, deps = {} } = {
|
|
|
85
83
|
return fail(err, "`pi-dispatch sandbox` needs a terminal — it opens an interactive shell, so it cannot run from a pipe, a script without a TTY, or CI");
|
|
86
84
|
}
|
|
87
85
|
|
|
88
|
-
// `listRunningSandboxes` yields ids, already sanitized, so this compares like with like.
|
|
89
|
-
if ((await liveSandboxes()).has(sanitizeJobId(jobId))) {
|
|
90
|
-
return fail(err, `a sandbox for ${jobId} is already running — attach to it with \`docker attach ${sandboxContainerName(jobId)}\`, or exit it first`);
|
|
91
|
-
}
|
|
92
|
-
|
|
93
86
|
let publish;
|
|
94
87
|
try {
|
|
95
88
|
publish = parsePublish(values.publish ?? []);
|
|
@@ -97,74 +90,63 @@ export async function runSandbox(argv = [], { env = process.env, deps = {} } = {
|
|
|
97
90
|
return fail(err, error.message);
|
|
98
91
|
}
|
|
99
92
|
|
|
100
|
-
// #227.
|
|
101
|
-
//
|
|
102
|
-
//
|
|
103
|
-
//
|
|
104
|
-
//
|
|
105
|
-
//
|
|
106
|
-
//
|
|
107
|
-
|
|
108
|
-
//
|
|
109
|
-
//
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
err,
|
|
113
|
-
`pi-dispatch sandbox opens a shell on THIS host's docker daemon against the job's retained directory, so it cannot reach a job that ran in a remote venue (PI_BACKENDS: ${config.backends.join(", ")}). Run it on the host that ran the job.`,
|
|
114
|
-
);
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
const resolved = resolveSandbox({
|
|
93
|
+
// #227, #277. Everything from here to the shell is `openSandbox`, shared with the admin panel: every
|
|
94
|
+
// refusal (the per-job venue refusal included, which replaced a deployment-wide one this command used to
|
|
95
|
+
// apply on its own), the already-running refusal, this session's egress network and its teardown. The
|
|
96
|
+
// panel assembled the same session from parts and dropped the network; one function is what stops that.
|
|
97
|
+
// The run's venue's CLI, learned when the session is known to be openable, so the lines after the shell name the
|
|
98
|
+
// runtime that ran it (`podman attach`, not `docker attach`). Stays "docker" only for a failure before that point,
|
|
99
|
+
// which never reaches those lines.
|
|
100
|
+
let runtime = "docker";
|
|
101
|
+
// The container's name as `resolveSandbox` built it, off the run's directory (a run retained before #446's escape
|
|
102
|
+
// keeps its old one); the id's own spelling only before that point.
|
|
103
|
+
let containerName = sandboxContainerName(jobId);
|
|
104
|
+
const result = await openSandbox({
|
|
118
105
|
jobId,
|
|
119
106
|
sandboxDir: config.sandboxDir,
|
|
120
107
|
retentionHours: config.sandboxRetentionHours,
|
|
121
108
|
publish,
|
|
122
|
-
});
|
|
123
|
-
if (resolved.refused) return fail(err, resolved.message);
|
|
124
|
-
|
|
125
|
-
// Pin BEFORE the shell, not after: the operator asked to keep this one, and a session that ends in a
|
|
126
|
-
// crashed terminal or a closed laptop lid must not be the reason the pin never landed.
|
|
127
|
-
if (values.pin) {
|
|
128
|
-
const pinned = pinSandbox({ sandboxDir: config.sandboxDir, jobId, pinDays: config.sandboxPinDays, now });
|
|
129
|
-
if (pinned.pinned) out(`pinned ${jobId} until ${pinned.keepUntil} (${config.sandboxPinDays}d)\n`);
|
|
130
|
-
else err(`warning: could not pin ${jobId}: ${pinned.reason}\n`);
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
// REQ-EGRESS-ALLOWLIST: this session's own network, exactly like a job's, named off its own container
|
|
134
|
-
// so the reaper's `pi-job-` filter never touches it -- a worker restart must not tear the network out
|
|
135
|
-
// from under a shell an operator is sitting in.
|
|
136
|
-
const network = config.egress ? networkNameFor(resolved.name) : null;
|
|
137
|
-
const args = buildSandboxRunArgs({
|
|
138
|
-
image: resolved.manifest.image,
|
|
139
|
-
name: resolved.name,
|
|
140
|
-
workspace: resolved.manifest.workspace,
|
|
141
|
-
jobDir: resolved.manifest.dir,
|
|
142
|
-
publish,
|
|
143
109
|
// env-internal TERM: the operator's own terminal type, forwarded so the sandbox shell renders the
|
|
144
110
|
// way their terminal does. Nothing a deployment declares.
|
|
145
111
|
term: env.TERM,
|
|
146
112
|
idleSeconds: config.sandboxIdleMinutes * 60,
|
|
147
|
-
|
|
148
|
-
|
|
113
|
+
egress: { armed: config.egress, proxy: config.egressProxy },
|
|
114
|
+
running,
|
|
115
|
+
launch,
|
|
116
|
+
spawnNetwork,
|
|
117
|
+
...(resolveJobUser ? { resolveJobUser } : {}),
|
|
118
|
+
...(keeperCheck ? { keeperCheck } : {}),
|
|
119
|
+
// Issue #452, gate round 4: a teardown the detach gate refused leaves the session's network, said here with its reason.
|
|
120
|
+
onNetworkKept: (network, reason) => err(`warning: the egress network ${network} was kept (${reason}): detaching the running proxy from it now could cut the proxy's route out on a rootless Podman 4.x whose rootless network keeper does not hold (issue #458); start the keeper, and the worker's retention sweep removes it\n`),
|
|
121
|
+
// Issue #429: which venues open here, and what a podman sandbox's observations must show, as the worker reads them.
|
|
122
|
+
blessed: config.backends,
|
|
123
|
+
backendFloor: config.backendFloor,
|
|
124
|
+
now,
|
|
125
|
+
// Pin BEFORE the shell, not after: the operator asked to keep this one, and a session that ends in a crashed
|
|
126
|
+
// terminal or a closed laptop lid must not be the reason the pin never landed. And since issue #446 before
|
|
127
|
+
// ANY runtime call, inside `openSandbox` straight after `resolveSandbox`, where a pin that fails refuses the open
|
|
128
|
+
// instead of printing a warning above a shell whose workspace the sweep may take.
|
|
129
|
+
pin: values.pin
|
|
130
|
+
? () => {
|
|
131
|
+
const pinned = pinSandbox({ sandboxDir: config.sandboxDir, jobId, pinDays: config.sandboxPinDays, now });
|
|
132
|
+
if (pinned.pinned) out(`pinned ${jobId} until ${pinned.keepUntil} (${config.sandboxPinDays}d)\n`);
|
|
133
|
+
return pinned;
|
|
134
|
+
}
|
|
135
|
+
: null,
|
|
136
|
+
beforeLaunch: ({ resolved, runtime: cli }) => {
|
|
137
|
+
runtime = cli;
|
|
138
|
+
containerName = resolved.name;
|
|
139
|
+
out(`opening ${resolved.name} — image ${resolved.manifest.image}, workspace ${resolved.manifest.workspace}\n`);
|
|
140
|
+
out("no credentials are set in this container. exit the shell to dispose of it.\n");
|
|
141
|
+
if (publish.length > 0) out(`published: ${publish.filter((f) => f !== "-p").join(", ")}\n`);
|
|
142
|
+
},
|
|
149
143
|
});
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
if (
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
// proxy fails at `docker run` with docker's own message, in front of an operator at a terminal, which
|
|
157
|
-
// is the one place a late failure is cheap.
|
|
158
|
-
if (network && !(await createJobNetwork(spawnNetwork, { network, proxy: config.egressProxy }))) {
|
|
159
|
-
return fail(err, `could not create the egress network ${network} -- is the proxy running? \`docker compose -f deploy/docker-compose.yml --profile egress up -d\``);
|
|
160
|
-
}
|
|
161
|
-
try {
|
|
162
|
-
const { code, error } = await launch({ args });
|
|
163
|
-
if (error) return fail(err, `could not start docker: ${error.message}`);
|
|
164
|
-
return code ?? 0;
|
|
165
|
-
} finally {
|
|
166
|
-
if (network) await removeJobNetwork(spawnNetwork, { network, proxy: config.egressProxy });
|
|
167
|
-
}
|
|
144
|
+
if (result.refused) return fail(err, result.message);
|
|
145
|
+
if (result.error) return fail(err, `could not start ${runtime}: ${result.error.message}`);
|
|
146
|
+
// Issue #446: a run lost from under a session after it started is said when the shell exits, never acted on then.
|
|
147
|
+
if (result.lost) err(`note: ${result.message}\n`);
|
|
148
|
+
if (result.detached) out(`detached: ${containerName} is still running with its egress network, which is left in place after it exits -- \`${runtime} attach ${containerName}\` to return\n`);
|
|
149
|
+
return result.code ?? 0;
|
|
168
150
|
}
|
|
169
151
|
|
|
170
152
|
/**
|
|
@@ -187,19 +169,42 @@ function renderList({ config, live, out, now }) {
|
|
|
187
169
|
for (const row of rows) {
|
|
188
170
|
const id = String(row.jobId ?? "?").padEnd(width);
|
|
189
171
|
const kind = String(row.kind ?? "?").padEnd(8);
|
|
190
|
-
|
|
172
|
+
// A run this host cannot re-open is still listed (it is still retained and still swept), but not as
|
|
173
|
+
// time left on something re-openable (#277): the list answers "what can I open", and the venue says why not.
|
|
174
|
+
// A venue this command CAN open that this environment's PI_BACKENDS leaves out (issue #429) says which variable,
|
|
175
|
+
// because it is the one cause here an operator fixes in their own shell.
|
|
176
|
+
// Judged on the venue the refusal itself read (`sandboxVenueOf`): a manifest with no `backend` key is a `local`
|
|
177
|
+
// run from before attribution, so in a shell whose PI_BACKENDS leaves out `local` its cause is that variable,
|
|
178
|
+
// not a missing record.
|
|
179
|
+
const venue = sandboxVenueOf(row);
|
|
180
|
+
const state = sandboxVenueRefusal({ jobId: row.jobId, manifest: row, blessed: config.backends })
|
|
181
|
+
? typeof venue === "string" && venue !== ""
|
|
182
|
+
? sandboxLauncher(venue)
|
|
183
|
+
? `not here (PI_BACKENDS lacks ${venue})`
|
|
184
|
+
: `not here (ran on ${venue})`
|
|
185
|
+
: "not openable (no venue recorded)"
|
|
186
|
+
: // The directory's own name, which is what the runtime reports (a run retained before the escape keeps its old one).
|
|
187
|
+
live.has(basename(row.dir))
|
|
188
|
+
? "RUNNING"
|
|
189
|
+
: remaining(row, config.sandboxRetentionHours, now());
|
|
191
190
|
out(`${id} ${kind} ${state}\n`);
|
|
192
191
|
}
|
|
193
192
|
return 0;
|
|
194
193
|
}
|
|
195
194
|
|
|
196
|
-
/**
|
|
195
|
+
/**
|
|
196
|
+
* How long this one has left, from the manifest's own timestamps -- never from mtime, which a live sandbox moves.
|
|
197
|
+
* Through `sandboxDeadline` (issue #446), the sweep's own order, so a run the worker retained with a shorter window
|
|
198
|
+
* than this shell's PI_SANDBOX_RETENTION_HOURS shows the worker's deadline, which is the one that deletes it.
|
|
199
|
+
*/
|
|
197
200
|
function remaining(row, retentionHours, at) {
|
|
198
|
-
const
|
|
199
|
-
if (
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
201
|
+
const { until, source } = sandboxDeadline(row, retentionHours);
|
|
202
|
+
if (until === null) return "expired";
|
|
203
|
+
// Said, not counted down to "0h" (gate round 2): past the deadline, or inside the opener's grace, a plain open is
|
|
204
|
+
// refused, and the list is where an operator decides what to type.
|
|
205
|
+
if (until <= at) return "past its window (open with --pin)";
|
|
206
|
+
if (until - at <= SANDBOX_OPEN_GRACE_MS) return "within the grace (open with --pin)";
|
|
207
|
+
return source === "pin" ? `pinned, ${humanise(until - at)} left` : `${humanise(until - at)} left`;
|
|
203
208
|
}
|
|
204
209
|
|
|
205
210
|
function humanise(ms) {
|