cawdev-cli 1.0.6-beta → 1.0.8-beta
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/lib/finish-check.mjs +178 -0
- package/lib/roadmap-format.mjs +34 -7
- package/mcp/README.md +7 -2
- package/mcp/server.mjs +88 -11
- package/package.json +1 -1
- package/runner/README.md +83 -4
- package/runner/attach.mjs +83 -23
- package/runner/cawdev.mjs +2 -2
- package/runner/configure.mjs +96 -18
- package/runner/input.mjs +5 -1
- package/runner/restart.mjs +191 -0
- package/runner/runner.mjs +203 -12
- package/runner/sign-in.mjs +11 -0
package/runner/runner.mjs
CHANGED
|
@@ -48,6 +48,11 @@ import {
|
|
|
48
48
|
} from './auto-workspaces.mjs';
|
|
49
49
|
import { agentOfCommand, needsEverything } from '../lib/agents.mjs';
|
|
50
50
|
import { cawdevToolNames, codexArgs, translateCodexEvent } from '../lib/codex.mjs';
|
|
51
|
+
import {
|
|
52
|
+
drivingPath, isAlive, killedByRestart, leftBehind, memoryMaxOf, probeScope, readDriving,
|
|
53
|
+
restartSummary, scoped, snapshotOf, writeDriving,
|
|
54
|
+
} from './restart.mjs';
|
|
55
|
+
import { COMMITS_ITS_WORK, settleBranch } from '../lib/finish-check.mjs';
|
|
51
56
|
|
|
52
57
|
// --- configuration -----------------------------------------------------------
|
|
53
58
|
|
|
@@ -420,6 +425,12 @@ async function readConfig() {
|
|
|
420
425
|
* switched on, never on by default.
|
|
421
426
|
*/
|
|
422
427
|
autoWorkspaces: autoWorkspacesOf(file.autoWorkspaces),
|
|
428
|
+
/**
|
|
429
|
+
* R339: the memory ceiling each agent session is spawned under, as
|
|
430
|
+
* systemd reads it (`6G`, `75%`) — Linux only, see restart.mjs. Absent is
|
|
431
|
+
* none. Raw here; `main` probes whether this machine can apply it.
|
|
432
|
+
*/
|
|
433
|
+
sessionMemoryMax: file.sessionMemoryMax ?? null,
|
|
423
434
|
skillCache: file.skillCache ?? DEFAULTS.skillCache,
|
|
424
435
|
skillPrepareSeconds: file.skillPrepareSeconds ?? DEFAULTS.skillPrepareSeconds,
|
|
425
436
|
idleSeconds: file.idleSeconds ?? DEFAULTS.idleSeconds,
|
|
@@ -6755,13 +6766,22 @@ async function walkLifecycle(config, run, runToken, cwd, baseCommit, workspace,
|
|
|
6755
6766
|
if (decision.allowed) {
|
|
6756
6767
|
break;
|
|
6757
6768
|
}
|
|
6769
|
+
if (decision.expired) {
|
|
6770
|
+
// R339. Unread is not refused: re-running the stage would spend a
|
|
6771
|
+
// second stage on a plan nobody has looked at yet.
|
|
6772
|
+
const why = `Stopped at the ${stage.stage} gate: ${decision.reason}. Nothing was `
|
|
6773
|
+
+ 'refused — start the run again when somebody can read it.';
|
|
6774
|
+
await skipRest(config, run, lifecycle, at + 1, why);
|
|
6775
|
+
await finish(config, run, 'FINISHED', why, cwd);
|
|
6776
|
+
return;
|
|
6777
|
+
}
|
|
6758
6778
|
if (attempt === 1) {
|
|
6759
6779
|
const why = `Stopped at the ${stage.stage} gate: ${decision.reason ?? 'refused twice.'}`;
|
|
6760
6780
|
// The same trail R122 came from: a run that ends here never reaches the
|
|
6761
6781
|
// stages after it, and stages left PENDING on a run that is over read
|
|
6762
6782
|
// as a lifecycle still going.
|
|
6763
6783
|
await skipRest(config, run, lifecycle, at + 1, why);
|
|
6764
|
-
await finish(config, run, 'FINISHED', why);
|
|
6784
|
+
await finish(config, run, 'FINISHED', why, cwd);
|
|
6765
6785
|
return;
|
|
6766
6786
|
}
|
|
6767
6787
|
// Refused once: the reason is the point of refusing, so it goes into the
|
|
@@ -6798,7 +6818,7 @@ async function walkLifecycle(config, run, runToken, cwd, baseCommit, workspace,
|
|
|
6798
6818
|
const current = await api(config, `/api/projects/${run.projectSlug}/runs/${run.id}`)
|
|
6799
6819
|
.catch(() => null);
|
|
6800
6820
|
if (current?.live) {
|
|
6801
|
-
await finish(config, run, 'FINISHED', last?.text ?? 'The lifecycle finished.');
|
|
6821
|
+
await finish(config, run, 'FINISHED', last?.text ?? 'The lifecycle finished.', cwd);
|
|
6802
6822
|
}
|
|
6803
6823
|
await settleActions(config, run, cwd);
|
|
6804
6824
|
}
|
|
@@ -6858,6 +6878,11 @@ async function awaitGate(config, run, runToken, stage, text) {
|
|
|
6858
6878
|
}
|
|
6859
6879
|
|
|
6860
6880
|
log(` waiting at the ${stage.stage} gate`);
|
|
6881
|
+
// R339: no deadline of its own by default. A gate is a person reading the
|
|
6882
|
+
// plan, the platform never expires one, and a run waiting on it is never
|
|
6883
|
+
// swept — a timer here turned "not read yet" into a refusal and re-ran the
|
|
6884
|
+
// stage, which is spend nobody asked for. An older platform that does
|
|
6885
|
+
// expire it still answers EXPIRED, which is handled below.
|
|
6861
6886
|
const deadline = Date.now() + gateTimeoutSeconds() * 1000;
|
|
6862
6887
|
while (Date.now() < deadline) {
|
|
6863
6888
|
const remaining = Math.ceil((deadline - Date.now()) / 1000);
|
|
@@ -6869,6 +6894,12 @@ async function awaitGate(config, run, runToken, stage, text) {
|
|
|
6869
6894
|
// the transport — a 204 and a `{state: 'PENDING'}` both mean "not yet",
|
|
6870
6895
|
// and depending on which one the platform chose would be depending on
|
|
6871
6896
|
// something nobody promised.
|
|
6897
|
+
if (decision?.state === 'EXPIRED') {
|
|
6898
|
+
// A platform from before R339. Nobody said no, so this is not a
|
|
6899
|
+
// refusal to re-run the stage over: the run stops at the gate.
|
|
6900
|
+
log(` the ${stage.stage} gate expired unread`);
|
|
6901
|
+
return { allowed: false, expired: true, reason: 'nobody read it in time' };
|
|
6902
|
+
}
|
|
6872
6903
|
if (decision && decision.state && decision.state !== 'PENDING') {
|
|
6873
6904
|
const allowed = decision.state === 'ALLOWED';
|
|
6874
6905
|
log(` the ${stage.stage} gate was ${allowed ? 'allowed' : 'refused'}`);
|
|
@@ -6879,13 +6910,29 @@ async function awaitGate(config, run, runToken, stage, text) {
|
|
|
6879
6910
|
return { allowed: false, reason: `waiting failed (${failure.message})` };
|
|
6880
6911
|
}
|
|
6881
6912
|
}
|
|
6882
|
-
return { allowed: false, reason: 'nobody answered in time' };
|
|
6913
|
+
return { allowed: false, expired: true, reason: 'nobody answered in time' };
|
|
6883
6914
|
}
|
|
6884
6915
|
|
|
6885
|
-
/**
|
|
6916
|
+
/**
|
|
6917
|
+
* How long a gate waits before the daemon gives up on it: for ever, unless a
|
|
6918
|
+
* test says otherwise — R339. The platform decides when nobody is coming.
|
|
6919
|
+
*/
|
|
6886
6920
|
function gateTimeoutSeconds() {
|
|
6887
6921
|
const configured = Number(process.env.CAWDEV_GATE_TIMEOUT_SECONDS);
|
|
6888
|
-
return Number.isFinite(configured) && configured > 0 ? configured :
|
|
6922
|
+
return Number.isFinite(configured) && configured > 0 ? configured : Infinity;
|
|
6923
|
+
}
|
|
6924
|
+
|
|
6925
|
+
/**
|
|
6926
|
+
* An agent session, spawned under the machine's memory ceiling when it has
|
|
6927
|
+
* one — R339. `systemd-run --scope` execs in place, so the pid, the group and
|
|
6928
|
+
* the pipes are the agent's own. `cawdevCommand` is what the next daemon reads
|
|
6929
|
+
* off the driving note to tell this session's orphan from a reused pid.
|
|
6930
|
+
*/
|
|
6931
|
+
function spawnScoped(config, command, args, options) {
|
|
6932
|
+
const line = scoped(command, args, config.sessionScope);
|
|
6933
|
+
const child = spawn(line.command, line.args, options);
|
|
6934
|
+
child.cawdevCommand = command;
|
|
6935
|
+
return child;
|
|
6889
6936
|
}
|
|
6890
6937
|
|
|
6891
6938
|
async function spawnAgent(config, run, runToken, cwd, baseCommit, workspace, resume,
|
|
@@ -7306,7 +7353,7 @@ async function spawnAgent(config, run, runToken, cwd, baseCommit, workspace, res
|
|
|
7306
7353
|
log(` spawning: ${agentCmd} ${args.join(' ')} (prompt on stdin)`);
|
|
7307
7354
|
|
|
7308
7355
|
return new Promise((resolvePromise) => {
|
|
7309
|
-
const child =
|
|
7356
|
+
const child = spawnScoped(config, agentCmd, args, {
|
|
7310
7357
|
cwd,
|
|
7311
7358
|
// Its own process group, so cancelling can take down the whole tree
|
|
7312
7359
|
// rather than leaving orphaned children behind.
|
|
@@ -7922,7 +7969,7 @@ async function spawnAgent(config, run, runToken, cwd, baseCommit, workspace, res
|
|
|
7922
7969
|
const summary = signal
|
|
7923
7970
|
? `The agent was terminated (${signal}).`
|
|
7924
7971
|
: `The agent exited with code ${code} without reporting. ${lastText}`.trim();
|
|
7925
|
-
await finish(config, run, code === 0 ? 'FINISHED' : 'FAILED', summary);
|
|
7972
|
+
await finish(config, run, code === 0 ? 'FINISHED' : 'FAILED', summary, cwd);
|
|
7926
7973
|
}
|
|
7927
7974
|
// R112. A STAGE ending is not the RUN ending. The loop decides that —
|
|
7928
7975
|
// it is the only thing that knows whether there is another stage — and
|
|
@@ -8418,7 +8465,7 @@ async function spawnAntigravity(config, run, runToken, cwd, baseCommit, resume,
|
|
|
8418
8465
|
let childTotals = null;
|
|
8419
8466
|
let err = '';
|
|
8420
8467
|
let buffer = '';
|
|
8421
|
-
const child =
|
|
8468
|
+
const child = spawnScoped(config, agyCmd, args, {
|
|
8422
8469
|
cwd,
|
|
8423
8470
|
// Its own process group — see the same choice on the Claude Code path.
|
|
8424
8471
|
detached: true,
|
|
@@ -8567,7 +8614,7 @@ async function spawnAntigravity(config, run, runToken, cwd, baseCommit, resume,
|
|
|
8567
8614
|
.catch(() => null);
|
|
8568
8615
|
if (current?.live && !stage) {
|
|
8569
8616
|
await finish(config, run, code === 0 && result?.status === 'SUCCESS' ? 'FINISHED' : 'FAILED',
|
|
8570
|
-
text);
|
|
8617
|
+
text, cwd);
|
|
8571
8618
|
}
|
|
8572
8619
|
// R112: not between stages — see the same line on the Claude Code path.
|
|
8573
8620
|
if (!stage) {
|
|
@@ -8757,7 +8804,7 @@ async function spawnCodex(config, run, runToken, cwd, baseCommit, resume, projec
|
|
|
8757
8804
|
let lastSaid = null;
|
|
8758
8805
|
let err = '';
|
|
8759
8806
|
let buffer = '';
|
|
8760
|
-
const child =
|
|
8807
|
+
const child = spawnScoped(config, codexCmd, args, {
|
|
8761
8808
|
cwd,
|
|
8762
8809
|
// Its own process group — see the same choice on the Claude Code path.
|
|
8763
8810
|
detached: true,
|
|
@@ -8871,7 +8918,7 @@ async function spawnCodex(config, run, runToken, cwd, baseCommit, resume, projec
|
|
|
8871
8918
|
const current = await api(config, `/api/projects/${run.projectSlug}/runs/${run.id}`)
|
|
8872
8919
|
.catch(() => null);
|
|
8873
8920
|
if (current?.live && !stage) {
|
|
8874
|
-
await finish(config, run, code === 0 && ended && !failed ? 'FINISHED' : 'FAILED', text);
|
|
8921
|
+
await finish(config, run, code === 0 && ended && !failed ? 'FINISHED' : 'FAILED', text, cwd);
|
|
8875
8922
|
}
|
|
8876
8923
|
// R112: not between stages — see the same line on the Claude Code path.
|
|
8877
8924
|
if (!stage) {
|
|
@@ -8930,7 +8977,32 @@ function reportStageUsage(config, run, stageName, totals) {
|
|
|
8930
8977
|
log(` could not record what the ${stageName} stage used: ${failure.message}`));
|
|
8931
8978
|
}
|
|
8932
8979
|
|
|
8933
|
-
|
|
8980
|
+
/**
|
|
8981
|
+
* Ends a run on the platform.
|
|
8982
|
+
*
|
|
8983
|
+
* R345: a coding run reaches FINISHED only committed and pushed. Given the
|
|
8984
|
+
* checkout, a FINISHED for a profile that commits its work settles the branch
|
|
8985
|
+
* first — commits what the session left (unless it looks like a credential),
|
|
8986
|
+
* pushes without force — and becomes FAILED, with the reason in the summary
|
|
8987
|
+
* and the checkout held for Carry on (R80), when that cannot be done. Before
|
|
8988
|
+
* this, R325's run finished with a file modified and nothing pushed, and the
|
|
8989
|
+
* next run in that checkout refused to start on top of it.
|
|
8990
|
+
*/
|
|
8991
|
+
async function finish(config, run, state, summary, cwd = null) {
|
|
8992
|
+
if (state === 'FINISHED' && cwd && run.branch && COMMITS_ITS_WORK.has(run.profile)) {
|
|
8993
|
+
const settled = await settleBranch(cwd, run.branch, {
|
|
8994
|
+
push: pushBranch,
|
|
8995
|
+
label: run.entryRef ?? 'cawdev',
|
|
8996
|
+
}).catch((failure) => ({ ok: false, reason: `Checking the branch failed: ${failure.message}` }));
|
|
8997
|
+
if (!settled.ok) {
|
|
8998
|
+
log(` not finished: ${settled.reason}`);
|
|
8999
|
+
state = 'FAILED';
|
|
9000
|
+
summary = `${settled.reason}\n\n${summary ?? ''}`.trim();
|
|
9001
|
+
} else if (settled.note) {
|
|
9002
|
+
log(` ${settled.note}`);
|
|
9003
|
+
summary = `${summary ?? ''}\n\n${settled.note}`.trim();
|
|
9004
|
+
}
|
|
9005
|
+
}
|
|
8934
9006
|
await api(config, `/api/projects/${run.projectSlug}/runs/${run.id}/transition`, {
|
|
8935
9007
|
method: 'POST',
|
|
8936
9008
|
body: { state, summary },
|
|
@@ -8939,6 +9011,101 @@ async function finish(config, run, state, summary) {
|
|
|
8939
9011
|
|
|
8940
9012
|
// --- cancellation ------------------------------------------------------------
|
|
8941
9013
|
|
|
9014
|
+
/**
|
|
9015
|
+
* What the daemon before this one was driving when it died — R339.
|
|
9016
|
+
*
|
|
9017
|
+
* Read off the note it kept (restart.mjs), and only when its writer is dead:
|
|
9018
|
+
* a live writer is a second daemon on this runner id. Each run the platform
|
|
9019
|
+
* still believes in is failed at once as DAEMON_RESTARTED, in words that name
|
|
9020
|
+
* the cause — where the stale sweep would have said, five minutes later, that
|
|
9021
|
+
* the runner "stopped driving it". A session the old daemon left running
|
|
9022
|
+
* unattended is stopped first: two sessions writing one checkout is worse
|
|
9023
|
+
* than either.
|
|
9024
|
+
*/
|
|
9025
|
+
async function reportWhatARestartKilled(config) {
|
|
9026
|
+
const path = drivingPath(config.runnerId);
|
|
9027
|
+
const previous = await readDriving(path);
|
|
9028
|
+
const behind = leftBehind(previous, process.pid, isAlive);
|
|
9029
|
+
for (const left of behind) {
|
|
9030
|
+
const run = await api(config, `/api/projects/${left.projectSlug}/runs/${left.id}`)
|
|
9031
|
+
.catch((failure) => {
|
|
9032
|
+
log(`could not read ${left.id} after the restart: ${failure.message}`);
|
|
9033
|
+
return null;
|
|
9034
|
+
});
|
|
9035
|
+
if (!killedByRestart(run, config.runnerId)) {
|
|
9036
|
+
continue;
|
|
9037
|
+
}
|
|
9038
|
+
const orphanStopped = await stopOrphan(left);
|
|
9039
|
+
log(`run ${left.id} was being driven when this daemon last died (pid ${previous.pid}); `
|
|
9040
|
+
+ 'reporting it');
|
|
9041
|
+
await api(config, `/api/projects/${left.projectSlug}/runs/${left.id}/transition`, {
|
|
9042
|
+
method: 'POST',
|
|
9043
|
+
body: {
|
|
9044
|
+
state: 'FAILED',
|
|
9045
|
+
failureReason: 'DAEMON_RESTARTED',
|
|
9046
|
+
summary: restartSummary({
|
|
9047
|
+
name: config.name, previousPid: previous.pid, pid: process.pid, orphanStopped,
|
|
9048
|
+
stopped: previous.stopped ?? null,
|
|
9049
|
+
}),
|
|
9050
|
+
},
|
|
9051
|
+
}).catch((failure) => log(` could not report it: ${failure.message}`));
|
|
9052
|
+
}
|
|
9053
|
+
await writeDriving(path, snapshotOf(process.pid, running))
|
|
9054
|
+
.catch((failure) => log(`could not write ${path}: ${failure.message}`));
|
|
9055
|
+
}
|
|
9056
|
+
|
|
9057
|
+
/**
|
|
9058
|
+
* The old daemon's session, if it outlived it — and only if the pid still
|
|
9059
|
+
* runs the command it was spawned with, because after a reboot that number
|
|
9060
|
+
* may be somebody else's process.
|
|
9061
|
+
*/
|
|
9062
|
+
async function stopOrphan(left) {
|
|
9063
|
+
if (!left.childPid || !left.command || !isAlive(left.childPid)) {
|
|
9064
|
+
return false;
|
|
9065
|
+
}
|
|
9066
|
+
const command = await new Promise((resolveCommand) => {
|
|
9067
|
+
const ps = spawn('ps', ['-o', 'command=', '-p', String(left.childPid)],
|
|
9068
|
+
{ stdio: ['ignore', 'pipe', 'ignore'] });
|
|
9069
|
+
let out = '';
|
|
9070
|
+
ps.stdout.on('data', (chunk) => (out += chunk));
|
|
9071
|
+
ps.on('error', () => resolveCommand(''));
|
|
9072
|
+
ps.on('close', () => resolveCommand(out));
|
|
9073
|
+
});
|
|
9074
|
+
if (!command.includes(left.command)) {
|
|
9075
|
+
return false;
|
|
9076
|
+
}
|
|
9077
|
+
try {
|
|
9078
|
+
process.kill(-left.childPid, 'SIGTERM');
|
|
9079
|
+
log(` stopped the session it left running (pid ${left.childPid})`);
|
|
9080
|
+
return true;
|
|
9081
|
+
} catch (failure) {
|
|
9082
|
+
log(` could not stop the session it left running: ${failure.message}`);
|
|
9083
|
+
return false;
|
|
9084
|
+
}
|
|
9085
|
+
}
|
|
9086
|
+
|
|
9087
|
+
/**
|
|
9088
|
+
* Keeps the note current: rewritten whenever the set of runs being driven
|
|
9089
|
+
* changes, checked every second. A second is the window in which a death
|
|
9090
|
+
* goes unreported — and the stale sweep still catches that one.
|
|
9091
|
+
*/
|
|
9092
|
+
function keepTheDrivingNote(config) {
|
|
9093
|
+
const path = drivingPath(config.runnerId);
|
|
9094
|
+
let written = null;
|
|
9095
|
+
const timer = setInterval(() => {
|
|
9096
|
+
const snapshot = snapshotOf(process.pid, running);
|
|
9097
|
+
const text = JSON.stringify(snapshot);
|
|
9098
|
+
if (text === written) return;
|
|
9099
|
+
written = text;
|
|
9100
|
+
writeDriving(path, snapshot).catch((failure) => {
|
|
9101
|
+
written = null;
|
|
9102
|
+
log(`could not write ${path}: ${failure.message}`);
|
|
9103
|
+
});
|
|
9104
|
+
}, 1000);
|
|
9105
|
+
timer.unref?.();
|
|
9106
|
+
return timer;
|
|
9107
|
+
}
|
|
9108
|
+
|
|
8942
9109
|
/**
|
|
8943
9110
|
* A cancelled run reaches the runner on its next poll, and the child's whole
|
|
8944
9111
|
* process group goes with it — an agent that spawns a build should not leave it
|
|
@@ -9191,6 +9358,24 @@ async function main() {
|
|
|
9191
9358
|
// than buried under a session that then fails for a reason it explains.
|
|
9192
9359
|
await probePermissionPrompt(config);
|
|
9193
9360
|
|
|
9361
|
+
// R339. Whether this machine can put each session under a memory ceiling,
|
|
9362
|
+
// and what an earlier daemon on this runner left behind when it died —
|
|
9363
|
+
// both before anything is claimed: a run reported now is one the queue
|
|
9364
|
+
// must not hand the checkout of to somebody else first.
|
|
9365
|
+
const memoryMax = memoryMaxOf(config.sessionMemoryMax);
|
|
9366
|
+
if (config.sessionMemoryMax && !memoryMax) {
|
|
9367
|
+
log(`sessionMemoryMax "${config.sessionMemoryMax}" is not a size systemd reads (6G, 512M, 75%); ignored`);
|
|
9368
|
+
}
|
|
9369
|
+
const probed = await probeScope(memoryMax);
|
|
9370
|
+
config.sessionScope = probed.scope;
|
|
9371
|
+
if (probed.scope) {
|
|
9372
|
+
log(`each session runs under MemoryMax=${probed.scope.memoryMax} (systemd --${probed.scope.manager} scope)`);
|
|
9373
|
+
} else if (probed.why) {
|
|
9374
|
+
log(`sessionMemoryMax not applied: ${probed.why}`);
|
|
9375
|
+
}
|
|
9376
|
+
await reportWhatARestartKilled(config);
|
|
9377
|
+
const drivingNote = keepTheDrivingNote(config);
|
|
9378
|
+
|
|
9194
9379
|
// What this daemon SAYS it is, on every hello — R52's socket, R288's rule
|
|
9195
9380
|
// that it describes the current state. One object, sent as-is by
|
|
9196
9381
|
// `serveControl` and mutated by the SIGHUP handler below, so a terminal that
|
|
@@ -9478,6 +9663,12 @@ async function main() {
|
|
|
9478
9663
|
}
|
|
9479
9664
|
log('stopping; leaving any live run to the platform’s staleness sweep');
|
|
9480
9665
|
stopping = true;
|
|
9666
|
+
// R339. Said in the note before the sessions go, so the next daemon
|
|
9667
|
+
// tells a stop somebody asked for from a kill nobody did.
|
|
9668
|
+
clearInterval(drivingNote);
|
|
9669
|
+
await writeDriving(drivingPath(config.runnerId),
|
|
9670
|
+
{ ...snapshotOf(process.pid, running), stopped: signal })
|
|
9671
|
+
.catch(() => undefined);
|
|
9481
9672
|
clearInterval(heartbeat);
|
|
9482
9673
|
clearInterval(gitSurvey);
|
|
9483
9674
|
clearInterval(workspaceRequests);
|
package/runner/sign-in.mjs
CHANGED
|
@@ -26,6 +26,9 @@ export class Session {
|
|
|
26
26
|
this.url = String(url).replace(/\/+$/, '');
|
|
27
27
|
this.cookies = new Map();
|
|
28
28
|
this.email = null;
|
|
29
|
+
/** Called once when a signed-in session is answered 401 — expired, or
|
|
30
|
+
* revoked on the platform. The terminal offers `/login` from there. */
|
|
31
|
+
this.onExpired = null;
|
|
29
32
|
}
|
|
30
33
|
|
|
31
34
|
get signedIn() {
|
|
@@ -63,6 +66,14 @@ export class Session {
|
|
|
63
66
|
});
|
|
64
67
|
this.#remember(response);
|
|
65
68
|
const text = await response.text();
|
|
69
|
+
if (response.status === 401 && this.email !== null) {
|
|
70
|
+
// The cookie stopped meaning anything: expired, signed out elsewhere, or
|
|
71
|
+
// the account revoked. Until this every action failed one at a time with
|
|
72
|
+
// a bare "HTTP 401" while the footer still named the person — so the
|
|
73
|
+
// state is put right HERE, where every request passes, and said once.
|
|
74
|
+
this.email = null;
|
|
75
|
+
this.onExpired?.();
|
|
76
|
+
}
|
|
66
77
|
if (!response.ok) {
|
|
67
78
|
const failure = new Error(messageIn(text) ?? `${method} ${path} failed: HTTP ${response.status}`);
|
|
68
79
|
failure.status = response.status;
|