@volter/supercode-teams 0.3.185 → 0.3.188
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/bin/teams-cli.mjs +608 -0
- package/bin/teams-supervised.mjs +5 -0
- package/bin/teams.mjs +49 -582
- package/bin/workspace/published-install.mjs +46 -0
- package/bin/workspace/release-state.mjs +82 -0
- package/bin/workspace/retain-release.mjs +186 -0
- package/bin/workspace/upgrade-supervisor.mjs +234 -0
- package/bin/workspace/upgrade-watch.mjs +5 -2
- package/bin/workspace.mjs +19 -6
- package/machine.mjs +119 -31
- package/mail/import-maildir.mjs +8 -7
- package/mail/recovery-view.mjs +36 -0
- package/mail/retire-maildir.mjs +57 -68
- package/mail/send.mjs +2 -2
- package/mail/sqlite-store.mjs +15 -5
- package/mail/upgrade-admission.mjs +44 -0
- package/mail/upgrade-readiness.mjs +59 -0
- package/mail/upgrade-state.mjs +78 -0
- package/package.json +1 -1
- package/session-index-owner.mjs +8 -1
package/machine.mjs
CHANGED
|
@@ -84,7 +84,7 @@ import { WorkspaceContexts, fromContext } from '@volter/teams/context';
|
|
|
84
84
|
import { TeamsClient } from '@volter/teams/http-client';
|
|
85
85
|
import { supercodeHome } from '@volter/teams/home';
|
|
86
86
|
import { readMailHistory, machineName } from './mail-history.mjs';
|
|
87
|
-
import { randomBytes, createHash } from 'node:crypto';
|
|
87
|
+
import { randomBytes, createHash, randomUUID } from 'node:crypto';
|
|
88
88
|
import { WebSocket } from 'ws';
|
|
89
89
|
import { TmuxTerminalHost, classifyAttentionSignal, composerHoldsDraft, composerReadyForInput } from '@volter/supercode-terminal';
|
|
90
90
|
// The ConPTY host is read from the namespace, where a terminal package too old to have it gives undefined rather
|
|
@@ -137,6 +137,18 @@ export function claudeUsageLimitWait(screen) {
|
|
|
137
137
|
return /usage limit to reset/i.test(status) && /rate-limit-options/i.test(status) && !/esc to interrupt/i.test(status);
|
|
138
138
|
}
|
|
139
139
|
|
|
140
|
+
/** Claude's /btw panel, from its native dialog receipt and live panel footer.
|
|
141
|
+
* Permission prompts, settings panels and an unknown layout do not qualify. */
|
|
142
|
+
export function claudeSideQuestionPanel(screen, activity) {
|
|
143
|
+
if (activity?.harness !== 'claude-code' || activity.evidence?.source !== 'claude_registry'
|
|
144
|
+
|| activity.evidence?.waiting_for !== 'dialog open') return false;
|
|
145
|
+
const rows = String(screen).replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, '').split('\n').map(line => line.trim()).filter(Boolean);
|
|
146
|
+
const footer = rows.slice(-3).join('\n');
|
|
147
|
+
return /^\/btw(?:\s|$)/mu.test(rows.slice(-40).join('\n'))
|
|
148
|
+
&& /\b(?:esc|escape)(?:\s+to)?\s+close\b/iu.test(footer)
|
|
149
|
+
&& composerHoldsDraft('claude-code', screen) === null;
|
|
150
|
+
}
|
|
151
|
+
|
|
140
152
|
/** The settle floor for a pane whose kind has no composer pattern: the screen
|
|
141
153
|
* must have stopped changing for this long before the pane counts as ready. */
|
|
142
154
|
const INITIAL_INPUT_SETTLE_MS = 1_000;
|
|
@@ -551,6 +563,7 @@ export class TeamsMachine extends MachineCore {
|
|
|
551
563
|
// Set by a service-run connector: how `machine.restart` ends this daemon so its service manager starts it again.
|
|
552
564
|
this.restartHandler = null;
|
|
553
565
|
this.launches = new Launches(this.p.launches);
|
|
566
|
+
this.boardOwnersPath = join(this.home, 'board-owners.json');
|
|
554
567
|
// The release this daemon runs, in its machine record (`supercode teams machine status`): its Teams package and its
|
|
555
568
|
// native. A daemon an upgrade did not restart runs the release it started with, whatever is installed, and a
|
|
556
569
|
// client a release behind or ahead of it is refused, so status says which it is (2026-10-06: version skew between
|
|
@@ -559,7 +572,7 @@ export class TeamsMachine extends MachineCore {
|
|
|
559
572
|
try {
|
|
560
573
|
const record = readJson(this.p.machine, null);
|
|
561
574
|
if (record?.pid !== process.pid) return;
|
|
562
|
-
writeJson(this.p.machine, { ...record, release: this.#release() });
|
|
575
|
+
writeJson(this.p.machine, { ...record, release: this.#release(), mail_ready: false });
|
|
563
576
|
} catch { /* its record says no release */ }
|
|
564
577
|
});
|
|
565
578
|
// the panes' stable porthole hook and the machine's stable supercode brought up to this release's at start, under
|
|
@@ -714,9 +727,10 @@ export class TeamsMachine extends MachineCore {
|
|
|
714
727
|
try {
|
|
715
728
|
requireSessionIndexRuntime();
|
|
716
729
|
this.bornAt ??= Date.now();
|
|
717
|
-
this.mailStore = new SqliteMailStore({ mailRoot: join(supercodeHome(this.env), 'mail'), machine: machineName(this.env), log: (line) => this.emit('records-log', line) });
|
|
730
|
+
this.mailStore = new SqliteMailStore({ mailRoot: join(supercodeHome(this.env), 'mail'), machine: machineName(this.env), committed: this.env.SUPERCODE_MAIL_COMMITTED === '1', startupRecovery: this.env.SUPERCODE_MAIL_STARTUP_RECOVERY === '1', log: (line) => this.emit('records-log', line) });
|
|
718
731
|
this.mailStore.acquire();
|
|
719
732
|
await this.mailStore.start();
|
|
733
|
+
await this.#restoreBoardOwners();
|
|
720
734
|
this.sessionIndex = new SessionIndexOwner({ database: this.mailStore.database, env: this.env, log: (line) => this.emit('records-log', line) });
|
|
721
735
|
this.indexViews = new SessionIndexViews({ owner: this.sessionIndex,
|
|
722
736
|
enrichLive: (query, rows) => projectLiveDescriptors(query, rows, { owner: this.sessionIndex, live: this.live, mail: this.mail }),
|
|
@@ -738,18 +752,29 @@ export class TeamsMachine extends MachineCore {
|
|
|
738
752
|
|
|
739
753
|
async afterListen() {
|
|
740
754
|
try {
|
|
755
|
+
// First activation must prove its native before any delivery. Later
|
|
756
|
+
// restarts keep serving the committed mail while native indexing starts.
|
|
757
|
+
if (this.env.SUPERCODE_UPGRADE_ACTIVATING === '1') await this.#serveRequest('harness.v1.harnesses.list', {}, 4000);
|
|
741
758
|
this.#startLive();
|
|
742
759
|
await this.#startMail();
|
|
743
|
-
this.mailReady = true;
|
|
744
|
-
} catch (error) {
|
|
745
|
-
await this.stop();
|
|
746
|
-
throw error;
|
|
747
|
-
}
|
|
748
760
|
// Web apps on this machine reach it through the app door when the person has turned it on (app-door.mjs).
|
|
749
761
|
this.appDoor = serveAppDoor({ home: this.home, open: () => { const [page, daemon] = inProcessPair(); void this.acceptLocal(daemon, { appDoor: true }); return page; }, log: (row) => this.log.append({ principal: 'app-door', cap: 'app-door', target: row.origin ?? '*', outcome: row.outcome, detail: row.detail }) });
|
|
750
762
|
this.#startHealth();
|
|
751
763
|
this.#startRecords();
|
|
752
764
|
if(Object.keys(this.inputRepairs).length)this.#watchInputRepairs();
|
|
765
|
+
const readyRecord = readJson(this.p.machine, null);
|
|
766
|
+
if (readyRecord?.pid !== process.pid) throw new Error('daemon startup lost its machine record before mail readiness');
|
|
767
|
+
writeJson(this.p.machine, { ...readyRecord, mail_ready: true, mail_recovery: this.mailStore.deferred === true,
|
|
768
|
+
...(this.env.SUPERCODE_UPGRADE_SUPERVISOR ? { upgrade_supervisor: Number(this.env.SUPERCODE_UPGRADE_SUPERVISOR) } : {}) });
|
|
769
|
+
if (!this.mailStore.deferred && this.env.SUPERCODE_UPGRADE_READY_FILE) writeJson(this.env.SUPERCODE_UPGRADE_READY_FILE, { pid: process.pid,
|
|
770
|
+
supervisor: Number(this.env.SUPERCODE_UPGRADE_SUPERVISOR),
|
|
771
|
+
release: JSON.parse(this.env.SUPERCODE_UPGRADE_RELEASE_RECEIPT), at_ms: Date.now() });
|
|
772
|
+
this.mailReady = true;
|
|
773
|
+
if (!this.mailStore.deferred) this.activateMail();
|
|
774
|
+
} catch (error) {
|
|
775
|
+
await this.stop();
|
|
776
|
+
throw error;
|
|
777
|
+
}
|
|
753
778
|
}
|
|
754
779
|
|
|
755
780
|
// ---- this machine's records (order 3 PR 3, machine-records.mjs) -------------
|
|
@@ -845,8 +870,6 @@ export class TeamsMachine extends MachineCore {
|
|
|
845
870
|
async #startMail() {
|
|
846
871
|
const mailRoot = join(supercodeHome(this.env), 'mail');
|
|
847
872
|
// the Messages API a Claude relay talks to (step 4a: the daemon's, in process; the native watch served it)
|
|
848
|
-
this.relayEndpoint = serveRelayEndpoint(this.mailStore, { log: (line) => this.emit('mail-log', line) })
|
|
849
|
-
.catch((error) => { this.emit('mail-log', `relay endpoint: not served (${error.message}); a Claude session cannot be sent mail until the daemon starts again`); return null; });
|
|
850
873
|
// the session index's rows by address (its descriptors, as the records' index subscription hands them over)
|
|
851
874
|
this.sessionDescriptors = new Map();
|
|
852
875
|
const live = this.live;
|
|
@@ -886,6 +909,30 @@ export class TeamsMachine extends MachineCore {
|
|
|
886
909
|
const reading = await this.#serveRequest('harness.v1.sessions.activity_under', { pids: [session.rootPid] }, 4_000);
|
|
887
910
|
const native = reading?.activities?.find(row => row.pid === session.rootPid)?.activity;
|
|
888
911
|
if (native?.harness !== expected.harness || native.session_id !== expected.session) return { delivered: false, reason: 'the pane no longer runs the addressed native session; no input submitted' };
|
|
912
|
+
// A lifecycle wake belongs to the board's owned session, not the /btw
|
|
913
|
+
// composer. Dismiss only that identified panel; never answer a question,
|
|
914
|
+
// approve a permission, clear a draft or interrupt the underlying turn.
|
|
915
|
+
const boardWake = messageIds?.length && messageIds.every(id => {
|
|
916
|
+
const stored = this.mailStore.findIn(address, id);
|
|
917
|
+
const envelope = stored ? this.mailStore.body(stored) : null;
|
|
918
|
+
return envelope?.kind === 'notice' && parseAddress(envelope.from)?.harness === 'board';
|
|
919
|
+
});
|
|
920
|
+
if (boardWake && claudeSideQuestionPanel(await this.host.capture(session.id, { lines: 200, preserveEscapes: true }), native)) {
|
|
921
|
+
const owner = randomUUID();
|
|
922
|
+
if (await this.host.acquireInput?.(session.id, owner)) {
|
|
923
|
+
try {
|
|
924
|
+
await new Promise(resolve => setTimeout(resolve, COMPOSER_RECHECK_MS));
|
|
925
|
+
const latest = await this.#serveRequest('harness.v1.sessions.activity_under', { pids: [session.rootPid] }, 4_000);
|
|
926
|
+
const current = latest?.activities?.find(row => row.pid === session.rootPid)?.activity;
|
|
927
|
+
const screen = await this.host.capture(session.id, { lines: 200, preserveEscapes: true });
|
|
928
|
+
if (!this.stopping && current?.session_id === expected.session && claudeSideQuestionPanel(screen, current)) {
|
|
929
|
+
await this.host.sendInput(session.id, '\u001b', { submitMode: 'single', inputOwner: owner, deadlineMs: Date.now() + 5_000 });
|
|
930
|
+
this.log.append({ principal: null, cap: 'control', target: `pane:${pane}`, outcome: 'board-side-question-dismissed', detail: JSON.stringify({ address, message_ids: messageIds }) });
|
|
931
|
+
await new Promise(resolve => setTimeout(resolve, COMPOSER_RECHECK_MS));
|
|
932
|
+
}
|
|
933
|
+
} finally { await this.host.releaseInput?.(session.id, owner); }
|
|
934
|
+
}
|
|
935
|
+
}
|
|
889
936
|
const result = await this.#paneTurn(session, pane, text, { messageIds, harness });
|
|
890
937
|
if (result.refused) throw new Error(result.refused.reason);
|
|
891
938
|
return result.waits ?? result;
|
|
@@ -911,27 +958,23 @@ export class TeamsMachine extends MachineCore {
|
|
|
911
958
|
// a relay's inbound mail its hook kept while this daemon did not answer (the front door's spool): filed now, each
|
|
912
959
|
// minute, and before each relay's inbound call
|
|
913
960
|
this.hookSpool = join(supercodeHome(this.env), 'hook-spool', 'relay-inbound');
|
|
914
|
-
this.mailHooks.drainSpool(this.hookSpool);
|
|
915
|
-
this.mailHooks.drainPoolReplies();
|
|
916
|
-
this.hookSpoolTimer = setInterval(() => this.mailHooks.drainSpool(this.hookSpool), 60_000); this.hookSpoolTimer.unref?.();
|
|
917
961
|
// the mailbox's own delivery (step 4b): each wake attempted by its session's door, or carried to its machine
|
|
918
|
-
|
|
962
|
+
const carrier = new MailCarrier({ machine, mailRoot, store: this.mailStore, live,
|
|
919
963
|
handOver: (address, id) => this.mailSend.handOver(address, id),
|
|
920
964
|
typeTurn, typeUserTurns: (address, pane) => this.mailSend.typeUserTurns(address, pane), paneOf: (session) => this.mail.paneOf(session),
|
|
921
965
|
askMachine: (to, request) => this.#askMailDoor(to, request, MAIL_SEND_MS),
|
|
922
|
-
log: (line) => this.emit('mail-log', line) })
|
|
966
|
+
log: (line) => this.emit('mail-log', line) });
|
|
923
967
|
// idle notices and final replies (step 4c): each subscribed session's activity followed through harness serve
|
|
924
|
-
|
|
968
|
+
const idleNotices = new IdleNotices({ machine, store: this.mailStore, live,
|
|
925
969
|
serveRequest: (method, params, ms) => this.#serveRequest(method, params, ms),
|
|
926
970
|
locatorOf: (address) => this.sessionDescriptors?.get(address)?.locator ?? null,
|
|
927
|
-
log: (line) => this.emit('mail-log', line) })
|
|
971
|
+
log: (line) => this.emit('mail-log', line) });
|
|
928
972
|
// native Codex questions as mail (step 4c): the poll of each launched Codex pane's own app-server, in process
|
|
929
|
-
this.nativeQuestions.start();
|
|
930
973
|
// an agent's upkeep (step 4d): folder sessions become threads, typed lines reach their CC, idle launched threads end
|
|
931
|
-
|
|
974
|
+
const agentUpkeep = new AgentUpkeep({ machine, mailRoot, store: this.mailStore, agents: this.agents, live,
|
|
932
975
|
claudeProjects: () => [...live.claudeDirs.keys()].map((dir) => join(dirname(dir), 'projects')),
|
|
933
976
|
lastMessageAt: (address) => this.sessionDescriptors?.get(address)?.updated_at_ms ?? null,
|
|
934
|
-
program: this.supercodeBin, env: this.env, log: (line) => this.emit('mail-log', line) })
|
|
977
|
+
program: this.supercodeBin, env: this.env, log: (line) => this.emit('mail-log', line) });
|
|
935
978
|
// User turns share the persisted delivery schedule; only their own raw-input door types them.
|
|
936
979
|
// an agent's verbs (3b-2): its threads, a delegation, its record
|
|
937
980
|
this.agentVerbs = new AgentVerbs({ machine, store: this.mailStore, agents: this.agents, live, supercodeBin: this.supercodeBin, env: this.env,
|
|
@@ -941,9 +984,18 @@ export class TeamsMachine extends MachineCore {
|
|
|
941
984
|
// the names sessions ran under (`<mail>/names.json`): a name an agent was given reaches the same session after it
|
|
942
985
|
// runs under another, kept from the map's changes (at most once a second)
|
|
943
986
|
let naming = null;
|
|
944
|
-
|
|
987
|
+
this.activateMail = () => {
|
|
988
|
+
this.relayEndpoint = serveRelayEndpoint(this.mailStore, { log: line => this.emit('mail-log', line) })
|
|
989
|
+
.catch(error => { this.emit('mail-log', `relay endpoint: not served (${error.message})`); return null; });
|
|
990
|
+
this.mailHooks.drainSpool(this.hookSpool);
|
|
991
|
+
this.mailHooks.drainPoolReplies();
|
|
992
|
+
this.hookSpoolTimer = setInterval(() => this.mailHooks.drainSpool(this.hookSpool), 60_000); this.hookSpoolTimer.unref?.();
|
|
993
|
+
this.carrier = carrier.start(); this.idleNotices = idleNotices.start();
|
|
994
|
+
this.nativeQuestions.start(); this.agentUpkeep = agentUpkeep.start();
|
|
995
|
+
void this.relays.sweepAtStart().catch(error => this.emit('mail-log', `relay pool: predecessors retained (${error.message})`));
|
|
996
|
+
};
|
|
945
997
|
live.on('changed', () => {
|
|
946
|
-
if (this.stopping) return;
|
|
998
|
+
if (this.stopping || !this.mailReady || this.mailStore.deferred) return;
|
|
947
999
|
if (naming) return;
|
|
948
1000
|
naming = setTimeout(() => { naming = null; try { this.mailSend.rememberNames(live.sessions()); } catch { /* kept at the next change */ } }, 1000);
|
|
949
1001
|
naming.unref?.();
|
|
@@ -1014,6 +1066,15 @@ export class TeamsMachine extends MachineCore {
|
|
|
1014
1066
|
try { activities = (await this.#serveRequest('harness.v1.sessions.activity_under', { pids: live.map((pane) => pane.pid) }, 30_000))?.activities ?? []; }
|
|
1015
1067
|
catch (error) { sessions_unread = String(error?.message ?? error).slice(0, 200); }
|
|
1016
1068
|
}
|
|
1069
|
+
const launches = new Map(this.launches.all().map(launch => [launch.pane, launch]));
|
|
1070
|
+
for (const pane of live) {
|
|
1071
|
+
const launch = launches.get(pane.pane), activity = activities.find(row => row.pid === pane.pid)?.activity;
|
|
1072
|
+
// The daemon resolved the starter at launch; the native receipt and
|
|
1073
|
+
// current process must name the same child. A pane title or argv is
|
|
1074
|
+
// never a delegation receipt.
|
|
1075
|
+
if (launch?.status === 'ready' && launch.native_session && launch.native_session === activity?.session_id && launch.kind === activity.harness)
|
|
1076
|
+
pane.starter_session = launch.by ?? null;
|
|
1077
|
+
}
|
|
1017
1078
|
this.live?.panes(live.map((pane) => ({ pane: pane.pane, pid: pane.pid,
|
|
1018
1079
|
activity: sessions_unread ? null : activities.find(row => row.pid === pane.pid)?.activity ?? null })), { current: sessions_unread == null });
|
|
1019
1080
|
// the sessions under the panes unread: the panes are read again on the section's backoff until they are (MR4)
|
|
@@ -1901,6 +1962,7 @@ export class TeamsMachine extends MachineCore {
|
|
|
1901
1962
|
// receiver named rather than addressed needs `mail:*`.
|
|
1902
1963
|
const { admitted, principal } = entry;
|
|
1903
1964
|
const request = requestOverride??stream.params?.request;
|
|
1965
|
+
if (this.mailStore.deferred && !['list', 'show', 'status', 'history'].includes(request?.op)) return refuse('mail migration is deferred; this daemon serves reads from retained originals while damaged operational records await repair');
|
|
1904
1966
|
if (request?.op === 'turn') return this.#serveTurn(entry, request, stream, accept, refuse);
|
|
1905
1967
|
if (request?.op === 'history') return this.#serveMailHistory(entry, request, stream, accept, refuse);
|
|
1906
1968
|
const to = typeof request?.to === 'string' ? request.to : '*';
|
|
@@ -2060,7 +2122,7 @@ export class TeamsMachine extends MachineCore {
|
|
|
2060
2122
|
/** The home and board a board name (an address's last part) names among the homes registered with this daemon, by the
|
|
2061
2123
|
* one rule the board names itself with (@volter/teams/board-name). */
|
|
2062
2124
|
#boardKept(name) {
|
|
2063
|
-
for (const root of Object.keys(this.boardEvents?.state?.boards ?? {})) {
|
|
2125
|
+
for (const root of new Set([...Object.keys(this.boardEvents?.state?.boards ?? {}), ...(this.boardOwners?.keys() ?? [])])) {
|
|
2064
2126
|
if (boardName(boardLabel(root, 'default')) === name) return { root, board: 'default' };
|
|
2065
2127
|
let named = [];
|
|
2066
2128
|
try { named = readdirSync(join(root, 'kanban', 'boards')); } catch { /* no named boards */ }
|
|
@@ -2303,7 +2365,7 @@ export class TeamsMachine extends MachineCore {
|
|
|
2303
2365
|
const answer = spawnSync(this.supercodeBin, ['--version'], { env: this.env, encoding: 'utf8', timeout: 5000, stdio: ['ignore', 'pipe', 'ignore'] });
|
|
2304
2366
|
native = answer.status === 0 ? answer.stdout.trim() || null : null;
|
|
2305
2367
|
} catch { /* unread */ }
|
|
2306
|
-
return { teams, native };
|
|
2368
|
+
return { teams, native, supercode: this.env.SUPERCODE_RELEASE_VERSION ?? null };
|
|
2307
2369
|
}
|
|
2308
2370
|
|
|
2309
2371
|
#portholeHook() {
|
|
@@ -2562,6 +2624,23 @@ export class TeamsMachine extends MachineCore {
|
|
|
2562
2624
|
return Number((await run('ps', ['-o', 'ppid=', '-p', String(holder)])).trim()) === pid;
|
|
2563
2625
|
}
|
|
2564
2626
|
|
|
2627
|
+
/** A restart retains the homes a verified owner registered. A saved PID is
|
|
2628
|
+
* only a candidate: the kernel must still show it holding that home's lock.
|
|
2629
|
+
* Caller admission separately proves the live connection's process. */
|
|
2630
|
+
async #restoreBoardOwners() {
|
|
2631
|
+
this.boardOwners = new Map();
|
|
2632
|
+
const saved = readJson(this.boardOwnersPath, { owners: {} });
|
|
2633
|
+
for (const [root, pid] of Object.entries(saved?.owners ?? {})) {
|
|
2634
|
+
if (isAbsolute(root) && Number.isSafeInteger(pid) && pid > 1 && await this.#holdsBoardLock(root, pid))
|
|
2635
|
+
this.boardOwners.set(root, pid);
|
|
2636
|
+
}
|
|
2637
|
+
this.#saveBoardOwners();
|
|
2638
|
+
}
|
|
2639
|
+
|
|
2640
|
+
#saveBoardOwners() {
|
|
2641
|
+
writeJson(this.boardOwnersPath, { version: 1, owners: Object.fromEntries(this.boardOwners ?? []) });
|
|
2642
|
+
}
|
|
2643
|
+
|
|
2565
2644
|
/** A filing's sender as this machine knows it, with no process and no request (mailbox.rs `sender_identity`'s rule): this filing door is local, so a sender from this machine's address is a session here
|
|
2566
2645
|
* (`local`, the same user, D186), and one claiming another machine's address is unresolved: another machine's mail
|
|
2567
2646
|
* reaches this one only through its link, so a return address is never an identity. */
|
|
@@ -2575,7 +2654,7 @@ export class TeamsMachine extends MachineCore {
|
|
|
2575
2654
|
async #runFiling(request) {
|
|
2576
2655
|
if (!this.mailSend) return { code: 1, text: 'this machine\'s daemon has not started its mail yet' };
|
|
2577
2656
|
return this.mailSend.file({ address: request.from, name: request.from_name ?? '' }, request.to, request.body ?? '', {
|
|
2578
|
-
subject: request.subject ?? null, key: request.id ?? null, wake: request.wake !== false,
|
|
2657
|
+
subject: request.subject ?? null, key: request.id ?? null, wake: request.wake !== false, notice: request.notice === true,
|
|
2579
2658
|
sender_identity: request.sender_identity && typeof request.sender_identity === 'object' ? request.sender_identity : null, typed: request.typed === true,
|
|
2580
2659
|
});
|
|
2581
2660
|
}
|
|
@@ -3633,6 +3712,9 @@ export class TeamsMachine extends MachineCore {
|
|
|
3633
3712
|
entry.admitted = fresh;
|
|
3634
3713
|
}
|
|
3635
3714
|
if (!this.mailReady || this.stopping) throw Object.assign(new Error(this.stopping ? 'this machine daemon is stopping' : 'this machine daemon is preparing its mail database; retry once migration completes'), { code: 503 });
|
|
3715
|
+
if (this.mailStore.deferred && ((method.startsWith('harness.v1.mail.') && !['list', 'show', 'statistics', 'names', 'tools', 'changes'].some(name => method === `harness.v1.mail.${name}`)) || method === 'harness.v1.agent.declare')) {
|
|
3716
|
+
throw Object.assign(new Error('mail migration is deferred; public mail reads remain available, delivery and acknowledgement await operational-state repair'), { code: 503 });
|
|
3717
|
+
}
|
|
3636
3718
|
const { admitted, principal } = entry;
|
|
3637
3719
|
const operator = admitted.class === 'operator';
|
|
3638
3720
|
const need = (cap, target) => {
|
|
@@ -3842,6 +3924,7 @@ export class TeamsMachine extends MachineCore {
|
|
|
3842
3924
|
// never needing a person at it. Only a daemon its service manager brings back may go: one run by hand would not return.
|
|
3843
3925
|
if (!operator) { this.log.append({ principal, cap: method, target: '*', outcome: 'refused' }); throw Object.assign(new Error('only an operator restarts the machine daemon'), { code: 403 }); }
|
|
3844
3926
|
if (!this.restartHandler) throw Object.assign(new Error('this daemon runs without a service manager, so nothing would start it again; restart it where it runs'), { code: 'unsupported' });
|
|
3927
|
+
await this.restartAdmission?.();
|
|
3845
3928
|
act(method, '*', 'ok', params.reason ? String(params.reason).slice(0, 200) : null);
|
|
3846
3929
|
// The answer goes out first; the service manager starts the next daemon.
|
|
3847
3930
|
setTimeout(() => this.restartHandler(params.reason ? String(params.reason) : null), 500).unref?.();
|
|
@@ -4482,7 +4565,7 @@ export class TeamsMachine extends MachineCore {
|
|
|
4482
4565
|
for (const key of ['from', 'to', 'body']) if (typeof params[key] !== 'string' || !params[key]) throw Object.assign(new Error(`a filing names its ${key}`), { code: 400 });
|
|
4483
4566
|
const sender_identity = await this.#observeSender(params.from, params.from_name ?? null, typeof params.context === 'string' && params.context ? params.context : null);
|
|
4484
4567
|
const answer = await this.#runFiling({ from: params.from, from_name: params.from_name ?? '', to: params.to, body: params.body, id: params.id ?? null,
|
|
4485
|
-
wake: params.wake !== false, subject: params.subject ?? null, sender_identity });
|
|
4568
|
+
wake: params.wake !== false, subject: params.subject ?? null, notice: params.notice === true, sender_identity });
|
|
4486
4569
|
act('mail.file', params.to, answer?.code === 0 ? 'ok' : 'failed');
|
|
4487
4570
|
return answer;
|
|
4488
4571
|
}
|
|
@@ -4795,21 +4878,26 @@ export class TeamsMachine extends MachineCore {
|
|
|
4795
4878
|
case 'harness.v1.boards.register': case 'harness.v1.boards.list': case 'harness.v1.boards.unregister': {
|
|
4796
4879
|
// A board home this machine keeps, published to its team by its connector (board-events.mjs).
|
|
4797
4880
|
if (!operator) throw Object.assign(new Error('operators only'), { code: 403 });
|
|
4798
|
-
if (
|
|
4799
|
-
if (method === 'harness.v1.boards.list') return this.boardEvents.list();
|
|
4881
|
+
if (method === 'harness.v1.boards.list') return this.boardEvents?.list() ?? [];
|
|
4800
4882
|
if (method === 'harness.v1.boards.unregister') {
|
|
4801
4883
|
if (typeof params.root !== 'string' || !params.root) throw Object.assign(new Error('name the board home (root)'), { code: 400 });
|
|
4802
|
-
|
|
4884
|
+
this.boardOwners?.delete(resolve(params.root));
|
|
4885
|
+
try { this.boardOwners?.delete(realpathSync(params.root)); } catch { /* already removed */ }
|
|
4886
|
+
this.#saveBoardOwners();
|
|
4887
|
+
return this.boardEvents?.unregister(params.root) ?? { root: resolve(params.root), published: false };
|
|
4803
4888
|
}
|
|
4804
4889
|
if (typeof params.root !== 'string' || !params.root) throw Object.assign(new Error('name the board home (root)'), { code: 400 });
|
|
4805
|
-
const registered = this.boardEvents
|
|
4890
|
+
const registered = this.boardEvents?.register(params.root) ?? { root: resolve(params.root), published: false };
|
|
4806
4891
|
// The home's board owner acts as its boards: the caller it names for a board of that home is taken from it
|
|
4807
4892
|
// (#callerOf). The owner is the process holding the home's board lock (board.lock, through its lock holder), as
|
|
4808
4893
|
// the kernel says: the registering client names its pid (its claim), and only a pid that holds the lock is
|
|
4809
4894
|
// taken. A registration from any other process publishes the home and grants nothing, and never replaces the
|
|
4810
4895
|
// owner's.
|
|
4811
4896
|
const pid = entry.principal === 'local' && !entry.appDoor ? Number(params.claim?.pid) : NaN;
|
|
4812
|
-
if (Number.isInteger(pid) && await
|
|
4897
|
+
if (Number.isInteger(pid) && (!entry.socket || await peerIs(entry.socket, pid)) && await this.#holdsBoardLock(registered.root, pid)) {
|
|
4898
|
+
(this.boardOwners ??= new Map()).set(registered.root, pid);
|
|
4899
|
+
this.#saveBoardOwners();
|
|
4900
|
+
}
|
|
4813
4901
|
act('boards.register', registered.root);
|
|
4814
4902
|
return registered;
|
|
4815
4903
|
}
|
package/mail/import-maildir.mjs
CHANGED
|
@@ -48,7 +48,7 @@ async function* logRecords(stream, start) {
|
|
|
48
48
|
else if (fragments.length) yield { bytes: assembled(), offset: position };
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
-
export async function importMaildir(database, root, { log = () => {}, legacyRoot = root } = {}) {
|
|
51
|
+
export async function importMaildir(database, root, { log = () => {}, legacyRoot = root, deferOperational = false } = {}) {
|
|
52
52
|
const began = performance.now();
|
|
53
53
|
const work = { source_reads: 0, source_bytes: 0, committed_batches: 0, max_queued_operations: 0, resumed_directories: 0, resumed_entries: 0, resumed_log_bytes: 0, elapsed_ms: 0 };
|
|
54
54
|
const complete = database.metadata('maildir_import');
|
|
@@ -85,13 +85,13 @@ export async function importMaildir(database, root, { log = () => {}, legacyRoot
|
|
|
85
85
|
if (pending.length >= 256) flush();
|
|
86
86
|
};
|
|
87
87
|
const errors = database.statement('INSERT INTO mail_import_errors(source, mailbox, detail, critical) VALUES (?, ?, ?, ?) ON CONFLICT(source) DO UPDATE SET mailbox = excluded.mailbox, detail = excluded.detail, critical = excluded.critical');
|
|
88
|
-
const failed = (path, error, mailbox = null) => {
|
|
88
|
+
const failed = (path, error, mailbox = null, { diagnostic = false } = {}) => {
|
|
89
89
|
const source = relative(root, path);
|
|
90
90
|
const parts = source.split(/[\\/]/);
|
|
91
|
-
const critical = ['agents', 'threads', 'native-questions', 'native-launches', 'relays', 'machines'
|
|
91
|
+
const critical = !diagnostic && (['agents', 'threads', 'native-questions', 'native-launches', 'relays', 'machines'].includes(parts[0])
|
|
92
92
|
|| ['claims', 'subscriptions', 'wake'].includes(parts[1])
|
|
93
93
|
|| parts[0] === 'names.json' || parts[0] === 'relay-endpoint.json'
|
|
94
|
-
|| (parts[0] === 'terminal-delivery' && parts.at(-1) === 'queue.json');
|
|
94
|
+
|| (parts[0] === 'terminal-delivery' && parts.at(-1) === 'queue.json'));
|
|
95
95
|
queue(() => errors.run(source, mailbox, String(error.message ?? error), critical ? 1 : 0));
|
|
96
96
|
result.skipped++;
|
|
97
97
|
log(`mail import: retained unreadable source ${source}: ${error.message ?? error}`);
|
|
@@ -194,7 +194,7 @@ export async function importMaildir(database, root, { log = () => {}, legacyRoot
|
|
|
194
194
|
if (!entry.isFile() || !entry.name.endsWith('.json')) continue;
|
|
195
195
|
const path = join(dir, 'claims', entry.name), id = basename(entry.name, '.json');
|
|
196
196
|
const claim = await json(path, address);
|
|
197
|
-
if (claim && !present(id)) { failed(path, 'claim has no imported message', address); continue; }
|
|
197
|
+
if (claim && !present(id)) { failed(path, 'claim has no imported message', address, { diagnostic: true }); continue; }
|
|
198
198
|
if (claim) queue(() => database.statement('INSERT INTO claims(mailbox, message, record) VALUES (?, ?, ?) ON CONFLICT(mailbox, message) DO NOTHING').run(address, id, JSON.stringify(claim)));
|
|
199
199
|
}
|
|
200
200
|
for await (const entry of entries(join(dir, 'subscriptions'))) {
|
|
@@ -384,11 +384,12 @@ export async function importMaildir(database, root, { log = () => {}, legacyRoot
|
|
|
384
384
|
result.records = database.statement('SELECT count(*) AS count FROM mail_records').get().count;
|
|
385
385
|
result.skipped = database.statement('SELECT count(*) AS count FROM mail_import_errors').get().count;
|
|
386
386
|
work.elapsed_ms = Math.round(performance.now() - began);
|
|
387
|
+
database.statement("UPDATE mail_import_errors SET critical = 0 WHERE source LIKE 'sent/%/log.jsonl' OR detail = 'claim has no imported message'").run();
|
|
387
388
|
const blocked = database.statement('SELECT source FROM mail_import_errors WHERE critical = 1 ORDER BY source').all();
|
|
388
|
-
if (blocked.length) {
|
|
389
|
+
if (blocked.length && !deferOperational) {
|
|
389
390
|
throw Object.assign(new Error(`mail migration cannot activate with ${blocked.length} unreadable operational records (${blocked.slice(0, 3).map(({ source }) => source).join(', ')}); original files are retained for repair`), { code: 'mail_import_incomplete' });
|
|
390
391
|
}
|
|
391
|
-
result.complete =
|
|
392
|
+
result.complete = blocked.length === 0;
|
|
392
393
|
database.setMetadata('maildir_import', result);
|
|
393
394
|
log(`mail import complete: ${result.messages} envelopes, ${result.records} operational records, ${result.skipped} retained unreadable sources; ${work.source_reads} source reads, ${work.source_bytes} bytes, ${work.committed_batches} batches in ${work.elapsed_ms} ms`);
|
|
394
395
|
return result;
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// When there is no earlier release to serve, retain the source stores and
|
|
2
|
+
// answer reads from a disposable private view. Never activate damaged routing
|
|
3
|
+
// records or run delivery against an incomplete migration.
|
|
4
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
5
|
+
import { mkdir, lstat, rm } from 'node:fs/promises';
|
|
6
|
+
import { dirname, join, resolve } from 'node:path';
|
|
7
|
+
import { MailDatabase } from './sqlite.mjs';
|
|
8
|
+
import { importMaildir } from './import-maildir.mjs';
|
|
9
|
+
|
|
10
|
+
export async function recoveryMailView(root, { log = () => {} } = {}) {
|
|
11
|
+
const home = dirname(root), directory = join(home, 'teams', 'service', 'mail-recovery');
|
|
12
|
+
await mkdir(directory, { recursive: true, mode: 0o700 });
|
|
13
|
+
const path = join(directory, 'mail.sqlite');
|
|
14
|
+
// The caller holds this home's mail owner lease. This is its own disposable
|
|
15
|
+
// view, never the source SQLite database or an archived original.
|
|
16
|
+
for (const suffix of ['', '-wal', '-shm']) await rm(path + suffix, { force: true });
|
|
17
|
+
const live = join(home, 'mail.sqlite');
|
|
18
|
+
try {
|
|
19
|
+
await lstat(live);
|
|
20
|
+
const reader = new DatabaseSync(live, { readOnly: true });
|
|
21
|
+
try { reader.prepare('VACUUM INTO ?').run(path); } finally { reader.close(); }
|
|
22
|
+
} catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
23
|
+
const database = new MailDatabase(path);
|
|
24
|
+
try {
|
|
25
|
+
const retired = database.metadata('maildir_retirement');
|
|
26
|
+
let input = root;
|
|
27
|
+
try { await lstat(input); }
|
|
28
|
+
catch (error) {
|
|
29
|
+
if (error.code !== 'ENOENT') throw error;
|
|
30
|
+
if (retired?.archive && dirname(resolve(retired.archive)) === resolve(home, 'mail-retired')) input = retired.archive;
|
|
31
|
+
}
|
|
32
|
+
await importMaildir(database, input, { legacyRoot: root, deferOperational: true, log });
|
|
33
|
+
log('mail migration is deferred; original stores retained, public mail reads use a private recovery view, delivery is unavailable');
|
|
34
|
+
return database;
|
|
35
|
+
} catch (error) { database.close(); throw error; }
|
|
36
|
+
}
|
package/mail/retire-maildir.mjs
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
//
|
|
1
|
+
// Import and validate while the legacy store stays at its original path. Only
|
|
2
|
+
// a complete, unchanged import can retire it. Keep originals for recovery;
|
|
3
|
+
// retiring mail is never permission to delete another release's readable data.
|
|
4
4
|
import { createReadStream } from 'node:fs';
|
|
5
|
-
import { lstat, mkdir,
|
|
5
|
+
import { lstat, mkdir, rename } from 'node:fs/promises';
|
|
6
6
|
import { createHash, randomUUID } from 'node:crypto';
|
|
7
|
-
import { dirname, isAbsolute, join,
|
|
7
|
+
import { dirname, isAbsolute, join, resolve, sep } from 'node:path';
|
|
8
8
|
import { importMaildir } from './import-maildir.mjs';
|
|
9
9
|
import { requireLegacyMaildirReady } from './migration-readiness.mjs';
|
|
10
10
|
|
|
@@ -12,95 +12,84 @@ const stat = async (path) => {
|
|
|
12
12
|
try { return await lstat(path); }
|
|
13
13
|
catch (error) { if (error.code === 'ENOENT') return null; throw error; }
|
|
14
14
|
};
|
|
15
|
+
|
|
16
|
+
export async function verifyImportedMaildir(database, root) {
|
|
17
|
+
for (const { key, value } of database.statement("SELECT key, value FROM mail_metadata WHERE key LIKE 'maildir_directory:%'").all()) {
|
|
18
|
+
const saved = JSON.parse(value), path = resolve(root, key.slice('maildir_directory:'.length));
|
|
19
|
+
if (path !== root && !path.startsWith(root + sep)) throw new Error('mail import directory receipt is outside its source');
|
|
20
|
+
const current = await stat(path);
|
|
21
|
+
const stamp = current && { device: current.dev, inode: current.ino, mtime: current.mtimeMs, ctime: current.ctimeMs };
|
|
22
|
+
if (JSON.stringify(saved.stamp) !== JSON.stringify(stamp)) throw new Error(`mail source directory changed before retirement: ${path}`);
|
|
23
|
+
}
|
|
24
|
+
const sources = database.statement('SELECT source, sha256 FROM mail_import_sources WHERE source > ? ORDER BY source LIMIT 256');
|
|
25
|
+
let cursor = '';
|
|
26
|
+
for (;;) {
|
|
27
|
+
const batch = sources.all(cursor);
|
|
28
|
+
if (!batch.length) return;
|
|
29
|
+
for (const row of batch) {
|
|
30
|
+
const path = resolve(root, row.source);
|
|
31
|
+
if (isAbsolute(row.source) || !path.startsWith(root + sep)) throw new Error('an imported source is outside its Maildir');
|
|
32
|
+
let parent = dirname(path);
|
|
33
|
+
while (parent !== root) {
|
|
34
|
+
if (!(await stat(parent))?.isDirectory()) throw new Error(`mail source directory changed before retirement: ${row.source}`);
|
|
35
|
+
parent = dirname(parent);
|
|
36
|
+
}
|
|
37
|
+
if (!(await stat(path))?.isFile()) throw new Error(`mail source changed before retirement: ${row.source}`);
|
|
38
|
+
const hash = createHash('sha256');
|
|
39
|
+
for await (const bytes of createReadStream(path)) hash.update(bytes);
|
|
40
|
+
if (hash.digest('hex') !== row.sha256) throw new Error(`mail source bytes changed before retirement: ${row.source}`);
|
|
41
|
+
}
|
|
42
|
+
cursor = batch.at(-1).source;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
15
46
|
/** Called only while this daemon holds the mail owner lease. */
|
|
16
|
-
export async function migrateMaildir(database, root, { log = () => {} } = {}) {
|
|
47
|
+
export async function migrateMaildir(database, root, { log = () => {}, committed = false } = {}) {
|
|
17
48
|
root = resolve(root);
|
|
18
49
|
let retirement = database.metadata('maildir_retirement');
|
|
19
50
|
let source = await stat(root);
|
|
20
51
|
if (!retirement) {
|
|
21
52
|
if (source && !source.isDirectory()) throw new Error(`mail migration requires a regular directory at ${root}`);
|
|
22
53
|
if (source) await requireLegacyMaildirReady(root);
|
|
23
|
-
retirement = { source: root, archive: source ? join(dirname(root), 'mail-retired', randomUUID()) : null, started_at_ms: Date.now(), complete: false };
|
|
54
|
+
retirement = { version: 2, source: root, archive: source ? join(dirname(root), 'mail-retired', randomUUID()) : null, started_at_ms: Date.now(), complete: false };
|
|
24
55
|
database.setMetadata('maildir_retirement', retirement);
|
|
25
56
|
}
|
|
26
57
|
if (retirement.source !== root) throw new Error('the mail migration source differs from its stored receipt');
|
|
27
58
|
if (retirement.complete) {
|
|
28
|
-
if (source) throw new Error(`the retired Maildir was recreated at ${root}; it is retained, and this daemon will not choose between two mail stores`);
|
|
59
|
+
if (source && !committed) throw new Error(`the retired Maildir was recreated at ${root}; it is retained, and this daemon will not choose between two mail stores`);
|
|
60
|
+
if (source) log(`committed SQLite mailbox remains serving; recreated legacy records at ${root} are retained for explicit recovery`);
|
|
29
61
|
return database.metadata('maildir_import');
|
|
30
62
|
}
|
|
31
63
|
if (!retirement.archive) {
|
|
32
64
|
if (source) throw new Error(`a legacy Maildir appeared after this home's SQLite owner started: ${root}; it is retained, and migration is refused`);
|
|
33
65
|
const imported = await importMaildir(database, root, { log });
|
|
34
|
-
database.setMetadata('maildir_retirement', { ...retirement, complete: true, completed_at_ms: Date.now(),
|
|
66
|
+
database.setMetadata('maildir_retirement', { ...retirement, complete: true, completed_at_ms: Date.now(), originals_retained: true });
|
|
35
67
|
return imported;
|
|
36
68
|
}
|
|
37
69
|
const archiveRoot = resolve(dirname(root), 'mail-retired');
|
|
38
70
|
const archive = resolve(retirement.archive);
|
|
39
71
|
if (dirname(archive) !== archiveRoot) throw new Error('the stored migration snapshot is outside this home');
|
|
40
72
|
let snapshot = await stat(archive);
|
|
41
|
-
if (source) {
|
|
42
|
-
|
|
43
|
-
|
|
73
|
+
if (source && snapshot) throw new Error(`the retired Maildir was recreated at ${root}; both stores are retained for explicit recovery`);
|
|
74
|
+
if (!source && !snapshot) throw new Error('the incomplete mail migration source is missing; originals cannot be reconstructed');
|
|
75
|
+
const input = snapshot ? archive : root;
|
|
76
|
+
if (!(snapshot ?? source).isDirectory()) throw new Error('the migration source is not a regular directory');
|
|
77
|
+
if (snapshot && retirement.inode != null && (retirement.inode !== snapshot.ino || retirement.device !== snapshot.dev)) throw new Error('the migration snapshot differs from its ownership receipt');
|
|
78
|
+
// Version-one receipts may already have moved the source before validation.
|
|
79
|
+
// Resume that snapshot, retaining it even when an operational input refuses.
|
|
80
|
+
const imported = await importMaildir(database, input, { log, legacyRoot: root });
|
|
81
|
+
if (snapshot && await stat(root)) throw new Error(`a legacy writer recreated ${root} during migration; both stores are retained`);
|
|
82
|
+
if (!snapshot) {
|
|
44
83
|
await requireLegacyMaildirReady(root);
|
|
84
|
+
await verifyImportedMaildir(database, root);
|
|
85
|
+
const current = await stat(root);
|
|
86
|
+
if (current?.ino !== source.ino || current?.dev !== source.dev) throw new Error('mail root changed before retirement');
|
|
45
87
|
await mkdir(archiveRoot, { recursive: true, mode: 0o700 });
|
|
46
88
|
await rename(root, archive);
|
|
47
89
|
snapshot = await stat(archive);
|
|
48
90
|
}
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
}
|
|
53
|
-
if (!snapshot.isDirectory()) throw new Error('the migration snapshot is not a regular directory');
|
|
54
|
-
if (retirement.inode != null && (retirement.inode !== snapshot.ino || retirement.device !== snapshot.dev)) throw new Error('the migration snapshot differs from its ownership receipt');
|
|
55
|
-
retirement = { ...retirement, inode: snapshot.ino, device: snapshot.dev };
|
|
56
|
-
database.setMetadata('maildir_retirement', retirement);
|
|
57
|
-
const imported = await importMaildir(database, archive, { log, legacyRoot: root });
|
|
58
|
-
// No ordinary operation becomes ready if an old entrypoint created a second
|
|
59
|
-
// store during import. It is never silently reimported over the first one.
|
|
60
|
-
if (await stat(root)) throw new Error(`a legacy writer recreated ${root} during migration; originals are retained and mail is not activated`);
|
|
61
|
-
const sources = database.statement(`SELECT source, sha256 FROM mail_import_sources s WHERE purged = 0 AND source > ?
|
|
62
|
-
AND NOT EXISTS (SELECT 1 FROM mail_import_errors e WHERE e.source = s.source) ORDER BY source LIMIT 256`);
|
|
63
|
-
const purged = database.statement('UPDATE mail_import_sources SET purged = 1 WHERE source = ?');
|
|
64
|
-
let removed = 0;
|
|
65
|
-
let cursor = '';
|
|
66
|
-
for (;;) {
|
|
67
|
-
const batch = sources.all(cursor);
|
|
68
|
-
if (!batch.length) break;
|
|
69
|
-
const done = [];
|
|
70
|
-
for (const row of batch) {
|
|
71
|
-
const path = resolve(archive, row.source);
|
|
72
|
-
if (isAbsolute(row.source) || !path.startsWith(archive + sep)) throw new Error('an imported source is outside its migration snapshot');
|
|
73
|
-
let parent = dirname(path), regular = true;
|
|
74
|
-
while (parent !== archive) {
|
|
75
|
-
if (!(await stat(parent))?.isDirectory()) { regular = false; break; }
|
|
76
|
-
parent = dirname(parent);
|
|
77
|
-
}
|
|
78
|
-
if (!regular) { log(`mail migration retained linked source ${row.source}`); continue; }
|
|
79
|
-
const file = await stat(path);
|
|
80
|
-
if (!file) { done.push(row.source); continue; }
|
|
81
|
-
if (!file.isFile()) { log(`mail migration retained changed source ${row.source}`); continue; }
|
|
82
|
-
const hash = createHash('sha256');
|
|
83
|
-
for await (const bytes of createReadStream(path)) hash.update(bytes);
|
|
84
|
-
if (hash.digest('hex') !== row.sha256) { log(`mail migration retained changed bytes ${row.source}`); continue; }
|
|
85
|
-
await unlink(path);
|
|
86
|
-
done.push(row.source); removed++;
|
|
87
|
-
}
|
|
88
|
-
// An interruption between unlink and this commit is safe: the next start
|
|
89
|
-
// observes the absent original and records its retirement again.
|
|
90
|
-
database.transaction(() => { for (const source of done) purged.run(source); });
|
|
91
|
-
cursor = batch.at(-1).source;
|
|
92
|
-
}
|
|
93
|
-
let retained = 0;
|
|
94
|
-
async function tidy(dir) {
|
|
95
|
-
for (const entry of await readdir(dir, { withFileTypes: true })) {
|
|
96
|
-
const path = join(dir, entry.name);
|
|
97
|
-
if (entry.isDirectory()) await tidy(path);
|
|
98
|
-
else { retained++; log(`mail migration retained diagnostic source ${relative(archive, path)}`); }
|
|
99
|
-
}
|
|
100
|
-
try { await rmdir(dir); } catch (error) { if (!['ENOTEMPTY', 'EEXIST'].includes(error.code)) throw error; }
|
|
101
|
-
}
|
|
102
|
-
await tidy(archive);
|
|
103
|
-
database.setMetadata('maildir_retirement', { ...retirement, complete: true, completed_at_ms: Date.now(), retained_sources: retained });
|
|
104
|
-
log(`mail retired: ${removed} imported source files removed; ${retained} diagnostic sources retained at ${archive}`);
|
|
91
|
+
database.setMetadata('maildir_retirement', { ...retirement, inode: snapshot.ino, device: snapshot.dev,
|
|
92
|
+
complete: true, completed_at_ms: Date.now(), originals_retained: true });
|
|
93
|
+
log(`mail retired after complete import; original files retained at ${archive}`);
|
|
105
94
|
return imported;
|
|
106
95
|
}
|
package/mail/send.mjs
CHANGED
|
@@ -732,13 +732,13 @@ export class MailSend {
|
|
|
732
732
|
* `sender_identity` is the filer's observation (this daemon's), never the request's own. Answers `{ code, message_id,
|
|
733
733
|
* new, to }`, or `{ code, text }`.
|
|
734
734
|
*/
|
|
735
|
-
file(from, to, body, { subject = null, key = null, wake = true, sender_identity = null, typed = false } = {}) {
|
|
735
|
+
file(from, to, body, { subject = null, key = null, wake = true, sender_identity = null, typed = false, notice = false } = {}) {
|
|
736
736
|
const sender = parseAddress(from.address), receiver = parseAddress(to);
|
|
737
737
|
if (!sender) return { code: EXIT_REFUSED, text: `\`${from.address}\` is not a session address; addresses look like sc:<machine>:<harness>:<session-id>` };
|
|
738
738
|
if (!receiver) return { code: EXIT_REFUSED, text: `\`${to}\` is not a session address; addresses look like sc:<machine>:<harness>:<session-id>` };
|
|
739
739
|
let id = messageIdFor(sender.address, key);
|
|
740
740
|
if (typed) id = `i-${id.replace(/^m-/, '')}`;
|
|
741
|
-
const filed = makeEnvelope({ id, from: sender.address, from_name: from.name ?? '', sender_identity, kind: typed ? 'user' : 'peer', reply_via: { mode: typed ? 'none' : 'command' }, subject: subjectLine(subject), body });
|
|
741
|
+
const filed = makeEnvelope({ id, from: sender.address, from_name: from.name ?? '', sender_identity, kind: typed ? 'user' : notice ? 'notice' : 'peer', reply_via: { mode: typed || notice ? 'none' : 'command' }, subject: subjectLine(subject), body });
|
|
742
742
|
try {
|
|
743
743
|
if (this.store.findIn(receiver.address, id)) return { code: 0, message_id: id, new: false, to: receiver.address };
|
|
744
744
|
// a typed line's record is read already: it reached the session as its composer turn, never as mail
|