omp-conductor 0.19.5 → 0.19.6
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/package.json +1 -1
- package/src/cli.ts +2 -0
- package/src/command-help.ts +18 -8
- package/src/command-manifest.ts +22 -5
- package/src/commands/context.ts +1 -0
- package/src/commands/report.ts +63 -0
- package/src/commands/restore-db.ts +1 -1
- package/src/commands/snapshot-db.ts +67 -0
- package/src/commands/upgrade-install.ts +1 -1
- package/src/commands/upgrade.ts +2 -2
- package/src/commands/verb.ts +1 -0
- package/src/commands/watch.ts +14 -2
- package/src/config.ts +17 -0
- package/src/daemon.ts +23 -3
- package/src/doctor.ts +36 -3
- package/src/fleet.ts +51 -3
- package/src/orchestrator-tick.ts +14 -3
- package/src/setup-host.ts +10 -10
- package/src/setup-wizard.ts +1 -1
- package/src/status-render.ts +10 -0
- package/src/store.ts +296 -22
- package/src/types.ts +32 -3
- package/src/upgrade-journal.ts +35 -1
- package/src/upgrade-verify.ts +27 -6
- package/src/upgrade.ts +301 -22
- package/src/verbs/actions.ts +6 -1
- package/src/verbs/server.ts +54 -20
- package/systemd/omp-conductor-recover.sh +55 -7
- package/systemd/recover-unit-test.sh +99 -6
package/src/fleet.ts
CHANGED
|
@@ -223,6 +223,46 @@ export function armState(projectName?: string): { path: string; armed: boolean }
|
|
|
223
223
|
return { path, armed: resolveArmState(path, named).armed };
|
|
224
224
|
}
|
|
225
225
|
|
|
226
|
+
export type WatchMonitoring =
|
|
227
|
+
| { monitored: true }
|
|
228
|
+
| { monitored: false; reason: string };
|
|
229
|
+
|
|
230
|
+
/** Whether a durable watch can be observed and wake an orchestrator tick (#1024). */
|
|
231
|
+
export function watchMonitoringState(
|
|
232
|
+
layers: Pick<FleetLayers, "daemon" | "ticks">,
|
|
233
|
+
): WatchMonitoring {
|
|
234
|
+
if (!layers.daemon.running) return { monitored: false, reason: "daemon not running" };
|
|
235
|
+
if (layers.ticks === "armed" || layers.ticks === "ungated") return { monitored: true };
|
|
236
|
+
if (layers.ticks === "disarmed") {
|
|
237
|
+
return {
|
|
238
|
+
monitored: false,
|
|
239
|
+
reason: "ticks disarmed — conditions may still be checked, but no tick can be woken",
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
return { monitored: false, reason: `ticks ${layers.ticks}` };
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** Cheap watch liveness read: no Herdr, Telegram, healthz, or systemd probes. */
|
|
246
|
+
export function watchMonitoringForProject(projectName: string): WatchMonitoring {
|
|
247
|
+
const daemon = livingDaemon();
|
|
248
|
+
if (daemon === undefined) return { monitored: false, reason: "daemon not running" };
|
|
249
|
+
if (daemon.project !== undefined && daemon.project !== projectName) {
|
|
250
|
+
return { monitored: false, reason: `daemon serves ${daemon.project}, not ${projectName}` };
|
|
251
|
+
}
|
|
252
|
+
const tick = resolveTickConfig(projectName);
|
|
253
|
+
const ticks: TicksLayer =
|
|
254
|
+
tick.kind === "absent"
|
|
255
|
+
? "no-heartbeat-config"
|
|
256
|
+
: tick.kind === "invalid"
|
|
257
|
+
? "invalid-heartbeat-config"
|
|
258
|
+
: tick.config.armedFile === undefined
|
|
259
|
+
? "ungated"
|
|
260
|
+
: armState(projectName).armed
|
|
261
|
+
? "armed"
|
|
262
|
+
: "disarmed";
|
|
263
|
+
return watchMonitoringState({ daemon: { running: true }, ticks });
|
|
264
|
+
}
|
|
265
|
+
|
|
226
266
|
/**
|
|
227
267
|
* Clears the arm gate for this project — and `wasArmed` is the gate the
|
|
228
268
|
* heartbeat reads, not merely one file's presence.
|
|
@@ -2174,7 +2214,7 @@ export async function collectFleetStatus(projectName?: string): Promise<FleetSta
|
|
|
2174
2214
|
telegram,
|
|
2175
2215
|
codeGraph,
|
|
2176
2216
|
brief: briefStatusLine(project),
|
|
2177
|
-
decisions: decisionStatusLine(project.name),
|
|
2217
|
+
decisions: decisionStatusLine(project.name, layers),
|
|
2178
2218
|
failureClasses: failureClassBlock(project.name),
|
|
2179
2219
|
workerPhases,
|
|
2180
2220
|
intake: intakeStatusLine(project.name),
|
|
@@ -2307,7 +2347,10 @@ function failureClassBlock(projectName: string): string | undefined {
|
|
|
2307
2347
|
* watches behind GitHub's checks is not a fleet waiting on its operator, and
|
|
2308
2348
|
* lumping them in made `decisions 3 open` read as three unanswered questions.
|
|
2309
2349
|
*/
|
|
2310
|
-
export function decisionStatusLine(
|
|
2350
|
+
export function decisionStatusLine(
|
|
2351
|
+
projectName: string,
|
|
2352
|
+
layers: Pick<FleetLayers, "daemon" | "ticks">,
|
|
2353
|
+
): string | undefined {
|
|
2311
2354
|
const path = dbPath();
|
|
2312
2355
|
if (!existsSync(path)) return undefined;
|
|
2313
2356
|
let store: Store | undefined;
|
|
@@ -2325,7 +2368,12 @@ export function decisionStatusLine(projectName: string): string | undefined {
|
|
|
2325
2368
|
const hours = Math.max(0, Math.round((Date.now() - oldest.askedAt) / 3_600_000));
|
|
2326
2369
|
line = `decisions ${questions.length} open (oldest ${hours}h)`;
|
|
2327
2370
|
}
|
|
2328
|
-
if (watches.length > 0)
|
|
2371
|
+
if (watches.length > 0) {
|
|
2372
|
+
const monitoring = watchMonitoringState(layers);
|
|
2373
|
+
line += monitoring.monitored
|
|
2374
|
+
? ` · watches ${watches.length} monitored`
|
|
2375
|
+
: ` · watches ${watches.length} durable, unmonitored (${monitoring.reason})`;
|
|
2376
|
+
}
|
|
2329
2377
|
return line;
|
|
2330
2378
|
} catch {
|
|
2331
2379
|
return undefined;
|
package/src/orchestrator-tick.ts
CHANGED
|
@@ -49,7 +49,13 @@ import { spawnSync } from "node:child_process";
|
|
|
49
49
|
import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
50
50
|
import { dirname, isAbsolute, join, resolve } from "node:path";
|
|
51
51
|
import { availabilityPrompt, interruptDisposition } from "./availability.ts";
|
|
52
|
-
import {
|
|
52
|
+
import {
|
|
53
|
+
findProject,
|
|
54
|
+
loadConfig,
|
|
55
|
+
resolveReleaseGrants,
|
|
56
|
+
resolveSharedInstallAuthority,
|
|
57
|
+
stateDir,
|
|
58
|
+
} from "./config.ts";
|
|
53
59
|
import {
|
|
54
60
|
bridgeTokenBound,
|
|
55
61
|
hasBotToken,
|
|
@@ -4393,9 +4399,14 @@ export default function orchestratorTickExtension(
|
|
|
4393
4399
|
let grants: ResolvedGrants = DENIED_RELEASE_GRANTS;
|
|
4394
4400
|
let external = true;
|
|
4395
4401
|
try {
|
|
4396
|
-
const
|
|
4402
|
+
const config = loadConfig();
|
|
4403
|
+
const project = findProject(config, configuredProject);
|
|
4404
|
+
const installAuthority = resolveSharedInstallAuthority(config.projects);
|
|
4397
4405
|
projectName = project.name;
|
|
4398
|
-
grants =
|
|
4406
|
+
grants = {
|
|
4407
|
+
...resolveReleaseGrants(project),
|
|
4408
|
+
install: installAuthority.holder ?? "human",
|
|
4409
|
+
};
|
|
4399
4410
|
external = project.escalation.orchestrator === "external";
|
|
4400
4411
|
} catch (err) {
|
|
4401
4412
|
// A missing/unreadable config cannot open a release gate. Log only when
|
package/src/setup-host.ts
CHANGED
|
@@ -780,11 +780,11 @@ export function renderHerdrUnit(runtime: ServiceRuntime): string {
|
|
|
780
780
|
* recovery.
|
|
781
781
|
*/
|
|
782
782
|
export function renderRecoverUnit(runtime: ServiceRuntime, projectName: string | undefined): string {
|
|
783
|
-
// `RECOVER_PROJECT`
|
|
784
|
-
//
|
|
785
|
-
//
|
|
786
|
-
//
|
|
787
|
-
// and
|
|
783
|
+
// `RECOVER_PROJECT` scopes the recovery action and optional re-arm. It is
|
|
784
|
+
// host-global and installed once, so a static action scope is legitimate
|
|
785
|
+
// only on a single-project host. With several projects it stays unset; the
|
|
786
|
+
// playbook independently derives one deterministic outbox owner from the
|
|
787
|
+
// live config and names every affected project in the report (#1027).
|
|
788
788
|
return [
|
|
789
789
|
"[Unit]",
|
|
790
790
|
"Description=omp-conductor fleet recovery (OnFailure handler)",
|
|
@@ -1566,11 +1566,11 @@ export function planHostRuntime(
|
|
|
1566
1566
|
const installedPath = join(unitDir, STAGED_SERVICE_NAME);
|
|
1567
1567
|
const installedHerdr = join(unitDir, DEFAULT_HERDR_UNIT);
|
|
1568
1568
|
const recoverUnitPath = join(stateDir(), RECOVER_SERVICE_NAME);
|
|
1569
|
-
// The recovery unit is host-global: one shared unit, installed once.
|
|
1570
|
-
//
|
|
1571
|
-
//
|
|
1572
|
-
//
|
|
1573
|
-
//
|
|
1569
|
+
// The recovery unit is host-global: one shared unit, installed once.
|
|
1570
|
+
// `RECOVER_PROJECT` scopes recovery action/re-arm only and is therefore
|
|
1571
|
+
// omitted on a multi-project host. Report ownership is a separate runtime
|
|
1572
|
+
// decision: the playbook reads the configured project set, chooses its first
|
|
1573
|
+
// project as outbox owner, and lists every project as affected (#1027).
|
|
1574
1574
|
const recoverProject = project === undefined || multiProject ? undefined : project.name;
|
|
1575
1575
|
const recoverUnitContent = renderRecoverUnit(runtime, recoverProject);
|
|
1576
1576
|
const recoverUnit: PlannedWrite<string> = {
|
package/src/setup-wizard.ts
CHANGED
|
@@ -617,7 +617,7 @@ const RELEASE_SHAPE_QUESTIONS: { readonly [K in (typeof RELEASE_SHAPES)[number]]
|
|
|
617
617
|
// package floor's "nobody patches the running conductor" is this grant's
|
|
618
618
|
// deny default.
|
|
619
619
|
install:
|
|
620
|
-
"install — replace this host's installed conductor: the
|
|
620
|
+
"install — replace this host's installed conductor: the discovered CLI package, omp plugin " +
|
|
621
621
|
"and Herdr plugin pinned to one published release, executed detached from the fleet",
|
|
622
622
|
};
|
|
623
623
|
|
package/src/status-render.ts
CHANGED
|
@@ -1018,6 +1018,16 @@ function formatProjectBody(
|
|
|
1018
1018
|
lines.push(...formatSalvagedRuns(s.salvagedRuns));
|
|
1019
1019
|
lines.push(...formatQuarantinedRuns(s.quarantinedRuns));
|
|
1020
1020
|
lines.push(...formatOpenReports(s.openReports));
|
|
1021
|
+
const withdrawals = s.handoffWithdrawals ?? [];
|
|
1022
|
+
if (withdrawals.length > 0) {
|
|
1023
|
+
lines.push("handoff withdrawals");
|
|
1024
|
+
for (const withdrawal of withdrawals) {
|
|
1025
|
+
lines.push(
|
|
1026
|
+
` ${withdrawal.id} ${withdrawal.target} ${new Date(withdrawal.at).toISOString()} ` +
|
|
1027
|
+
`by ${withdrawal.actor} — ${withdrawal.reason}; ${withdrawal.summary}`,
|
|
1028
|
+
);
|
|
1029
|
+
}
|
|
1030
|
+
}
|
|
1021
1031
|
lines.push(...formatDigestBacklog(s.digestBacklog));
|
|
1022
1032
|
// The mediated-verb ledger (#972). Absent from this renderer since the block
|
|
1023
1033
|
// was written (#133) — it was wired into `daemon.ts`'s copy, which was
|
package/src/store.ts
CHANGED
|
@@ -53,6 +53,8 @@ import type {
|
|
|
53
53
|
GroomingRecord,
|
|
54
54
|
GroomingVerdict,
|
|
55
55
|
HistoricalInfraCandidate,
|
|
56
|
+
HandoffWithdrawal,
|
|
57
|
+
HandoffWithdrawalResult,
|
|
56
58
|
HeldNotice,
|
|
57
59
|
InterruptCategory,
|
|
58
60
|
HeldNoticeDraft,
|
|
@@ -430,6 +432,30 @@ interface ReportRow {
|
|
|
430
432
|
sentParts: number;
|
|
431
433
|
sentPartsHash: string | null;
|
|
432
434
|
lastError: string | null;
|
|
435
|
+
withdrawnAt: number | null;
|
|
436
|
+
withdrawnBy: string | null;
|
|
437
|
+
withdrawReason: string | null;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
interface HandoffWithdrawalRow {
|
|
441
|
+
id: string;
|
|
442
|
+
project: string;
|
|
443
|
+
target: string;
|
|
444
|
+
actor: string;
|
|
445
|
+
reason: string;
|
|
446
|
+
at: number;
|
|
447
|
+
summary: string;
|
|
448
|
+
detail: string;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
interface HeldNoticeLifecycleRow {
|
|
452
|
+
id: string;
|
|
453
|
+
project: string;
|
|
454
|
+
summary: string;
|
|
455
|
+
detail: string;
|
|
456
|
+
digestedAt: number | null;
|
|
457
|
+
digestReportId: string | null;
|
|
458
|
+
withdrawnAt: number | null;
|
|
433
459
|
}
|
|
434
460
|
|
|
435
461
|
/** The `material_events` table exactly as SQLite hands it back (#274). */
|
|
@@ -940,13 +966,30 @@ CREATE TABLE IF NOT EXISTS reports (
|
|
|
940
966
|
deliveredAt INTEGER,
|
|
941
967
|
sentParts INTEGER NOT NULL DEFAULT 0,
|
|
942
968
|
sentPartsHash TEXT,
|
|
943
|
-
lastError TEXT
|
|
969
|
+
lastError TEXT,
|
|
970
|
+
withdrawnAt INTEGER,
|
|
971
|
+
withdrawnBy TEXT,
|
|
972
|
+
withdrawReason TEXT
|
|
944
973
|
);
|
|
945
974
|
CREATE UNIQUE INDEX IF NOT EXISTS reports_dedupe
|
|
946
|
-
ON reports (project, dedupeKey)
|
|
975
|
+
ON reports (project, dedupeKey)
|
|
976
|
+
WHERE dedupeKey IS NOT NULL AND state <> 'failed' AND state <> 'withdrawn';
|
|
947
977
|
CREATE INDEX IF NOT EXISTS reports_project_state
|
|
948
978
|
ON reports (project, state, nextAttemptAt);
|
|
949
979
|
|
|
980
|
+
CREATE TABLE IF NOT EXISTS handoff_withdrawals (
|
|
981
|
+
id TEXT PRIMARY KEY,
|
|
982
|
+
project TEXT NOT NULL,
|
|
983
|
+
target TEXT NOT NULL,
|
|
984
|
+
actor TEXT NOT NULL,
|
|
985
|
+
reason TEXT NOT NULL,
|
|
986
|
+
at INTEGER NOT NULL,
|
|
987
|
+
summary TEXT NOT NULL,
|
|
988
|
+
detail TEXT NOT NULL
|
|
989
|
+
);
|
|
990
|
+
CREATE INDEX IF NOT EXISTS handoff_withdrawals_project_at
|
|
991
|
+
ON handoff_withdrawals (project, at DESC);
|
|
992
|
+
|
|
950
993
|
-- Ordinary material outcomes awaiting authorship in a deferred digest (#274).
|
|
951
994
|
-- Association happens when a digest is accepted into the outbox, not when the
|
|
952
995
|
-- session happens to remember the event or when Telegram later delivers it.
|
|
@@ -979,7 +1022,10 @@ CREATE TABLE IF NOT EXISTS held_notices (
|
|
|
979
1022
|
releaseOnAvailable INTEGER NOT NULL DEFAULT 0,
|
|
980
1023
|
urgent INTEGER NOT NULL DEFAULT 0,
|
|
981
1024
|
digestedAt INTEGER,
|
|
982
|
-
digestReportId TEXT REFERENCES reports(id)
|
|
1025
|
+
digestReportId TEXT REFERENCES reports(id),
|
|
1026
|
+
withdrawnAt INTEGER,
|
|
1027
|
+
withdrawnBy TEXT,
|
|
1028
|
+
withdrawReason TEXT
|
|
983
1029
|
);
|
|
984
1030
|
CREATE INDEX IF NOT EXISTS held_notices_project_undigested
|
|
985
1031
|
ON held_notices (project, digestedAt) WHERE digestedAt IS NULL;
|
|
@@ -1562,9 +1608,25 @@ function toReport(row: ReportRow): ReportRecord {
|
|
|
1562
1608
|
if (row.sentPartsHash !== null) record.sentPartsHash = row.sentPartsHash;
|
|
1563
1609
|
if (row.deliveredAt !== null) record.deliveredAt = row.deliveredAt;
|
|
1564
1610
|
if (row.lastError !== null) record.lastError = row.lastError;
|
|
1611
|
+
if (row.withdrawnAt !== null) record.withdrawnAt = row.withdrawnAt;
|
|
1612
|
+
if (row.withdrawnBy !== null) record.withdrawnBy = row.withdrawnBy;
|
|
1613
|
+
if (row.withdrawReason !== null) record.withdrawReason = row.withdrawReason;
|
|
1565
1614
|
return record;
|
|
1566
1615
|
}
|
|
1567
1616
|
|
|
1617
|
+
function toHandoffWithdrawal(row: HandoffWithdrawalRow): HandoffWithdrawal {
|
|
1618
|
+
return {
|
|
1619
|
+
id: row.id,
|
|
1620
|
+
project: row.project,
|
|
1621
|
+
summary: row.summary,
|
|
1622
|
+
detail: row.detail,
|
|
1623
|
+
target: row.target as HandoffWithdrawal["target"],
|
|
1624
|
+
actor: row.actor,
|
|
1625
|
+
reason: row.reason,
|
|
1626
|
+
at: row.at,
|
|
1627
|
+
};
|
|
1628
|
+
}
|
|
1629
|
+
|
|
1568
1630
|
function toMaterialEvent(row: MaterialEventRow): MaterialEvent {
|
|
1569
1631
|
return {
|
|
1570
1632
|
id: row.id,
|
|
@@ -1905,20 +1967,20 @@ export function openStore(dbPath: string): Store {
|
|
|
1905
1967
|
db.exec("PRAGMA journal_mode = WAL;");
|
|
1906
1968
|
db.exec("PRAGMA foreign_keys = ON;");
|
|
1907
1969
|
db.exec(SCHEMA);
|
|
1908
|
-
//
|
|
1909
|
-
//
|
|
1910
|
-
// one-time schema so owed ledger rows can be handed off again.
|
|
1970
|
+
// Failed or explicitly withdrawn handoffs no longer own a digest dedupe key:
|
|
1971
|
+
// both leave their underlying ledger rows owed for a replacement report.
|
|
1911
1972
|
const reportDedupeIndex = db
|
|
1912
1973
|
.query<{ sql: string | null }, []>(
|
|
1913
1974
|
`SELECT sql FROM sqlite_master WHERE type = 'index' AND name = 'reports_dedupe'`,
|
|
1914
1975
|
)
|
|
1915
1976
|
.get();
|
|
1916
|
-
if (!(reportDedupeIndex?.sql ?? "").includes("state <> '
|
|
1977
|
+
if (!(reportDedupeIndex?.sql ?? "").includes("state <> 'withdrawn'")) {
|
|
1917
1978
|
db.exec(
|
|
1918
1979
|
`BEGIN IMMEDIATE;
|
|
1919
1980
|
DROP INDEX IF EXISTS reports_dedupe;
|
|
1920
1981
|
CREATE UNIQUE INDEX reports_dedupe
|
|
1921
|
-
ON reports (project, dedupeKey)
|
|
1982
|
+
ON reports (project, dedupeKey)
|
|
1983
|
+
WHERE dedupeKey IS NOT NULL AND state <> 'failed' AND state <> 'withdrawn';
|
|
1922
1984
|
COMMIT;`,
|
|
1923
1985
|
);
|
|
1924
1986
|
}
|
|
@@ -2112,6 +2174,15 @@ export function openStore(dbPath: string): Store {
|
|
|
2112
2174
|
}
|
|
2113
2175
|
// Urgent recovery notices bypass category batching only; they still wait for
|
|
2114
2176
|
// the configured availability window. Historical notices were not urgent.
|
|
2177
|
+
for (const [name, type] of [
|
|
2178
|
+
["withdrawnAt", "INTEGER"],
|
|
2179
|
+
["withdrawnBy", "TEXT"],
|
|
2180
|
+
["withdrawReason", "TEXT"],
|
|
2181
|
+
] as const) {
|
|
2182
|
+
if (!heldNoticeColumns.some((column) => column.name === name)) {
|
|
2183
|
+
db.exec(`ALTER TABLE held_notices ADD COLUMN ${name} ${type}`);
|
|
2184
|
+
}
|
|
2185
|
+
}
|
|
2115
2186
|
if (!heldNoticeColumns.some((column) => column.name === "urgent")) {
|
|
2116
2187
|
db.exec("ALTER TABLE held_notices ADD COLUMN urgent INTEGER NOT NULL DEFAULT 0");
|
|
2117
2188
|
}
|
|
@@ -2129,6 +2200,15 @@ export function openStore(dbPath: string): Store {
|
|
|
2129
2200
|
if (!reportColumns.some((column) => column.name === "sentPartsHash")) {
|
|
2130
2201
|
db.exec("ALTER TABLE reports ADD COLUMN sentPartsHash TEXT");
|
|
2131
2202
|
}
|
|
2203
|
+
for (const [name, type] of [
|
|
2204
|
+
["withdrawnAt", "INTEGER"],
|
|
2205
|
+
["withdrawnBy", "TEXT"],
|
|
2206
|
+
["withdrawReason", "TEXT"],
|
|
2207
|
+
] as const) {
|
|
2208
|
+
if (!reportColumns.some((column) => column.name === name)) {
|
|
2209
|
+
db.exec(`ALTER TABLE reports ADD COLUMN ${name} ${type}`);
|
|
2210
|
+
}
|
|
2211
|
+
}
|
|
2132
2212
|
// The kind split (#459): a row is either a question a human must answer or a
|
|
2133
2213
|
// watch the orchestrator set for itself. Databases written before the split
|
|
2134
2214
|
// never marked it, and every such row was a question — `watch` has no
|
|
@@ -3012,7 +3092,7 @@ export function openStore(dbPath: string): Store {
|
|
|
3012
3092
|
const selectReport = db.query<ReportRow, [string]>(`SELECT * FROM reports WHERE id = ?`);
|
|
3013
3093
|
const selectReportByDedupe = db.query<ReportRow, [string, string]>(
|
|
3014
3094
|
`SELECT * FROM reports
|
|
3015
|
-
WHERE project = ? AND dedupeKey = ? AND state
|
|
3095
|
+
WHERE project = ? AND dedupeKey = ? AND state NOT IN ('failed', 'withdrawn')
|
|
3016
3096
|
ORDER BY createdAt DESC, rowid DESC LIMIT 1`,
|
|
3017
3097
|
);
|
|
3018
3098
|
const selectDueReports = db.query<ReportRow, [string, number, number]>(
|
|
@@ -3021,6 +3101,81 @@ export function openStore(dbPath: string): Store {
|
|
|
3021
3101
|
ORDER BY createdAt ASC, rowid ASC
|
|
3022
3102
|
LIMIT ?`,
|
|
3023
3103
|
);
|
|
3104
|
+
const withdrawPendingReportRow = db.query<
|
|
3105
|
+
unknown,
|
|
3106
|
+
[number, string, string, number, string, string]
|
|
3107
|
+
>(
|
|
3108
|
+
`UPDATE reports
|
|
3109
|
+
SET state = 'withdrawn', withdrawnAt = ?, withdrawnBy = ?,
|
|
3110
|
+
withdrawReason = ?, updatedAt = ?, attemptId = NULL
|
|
3111
|
+
WHERE id = ? AND project = ? AND state = 'pending'`,
|
|
3112
|
+
);
|
|
3113
|
+
const selectReportNoticeIds = db.query<{ id: string }, [string, string]>(
|
|
3114
|
+
`SELECT id FROM held_notices WHERE digestReportId = ? AND project = ?`,
|
|
3115
|
+
);
|
|
3116
|
+
const unassignReportMaterialEvents = db.query<unknown, [string, string]>(
|
|
3117
|
+
`UPDATE material_events SET digestReportId = NULL
|
|
3118
|
+
WHERE digestReportId = ? AND project = ?`,
|
|
3119
|
+
);
|
|
3120
|
+
const markReportMaterialEventsPossibleRepeat = db.query<unknown, [string, string, string]>(
|
|
3121
|
+
`UPDATE material_events
|
|
3122
|
+
SET summary = CASE
|
|
3123
|
+
WHEN summary NOT LIKE 'POSSIBLE REPEAT%' THEN 'POSSIBLE REPEAT of report ' || ? || ': ' || summary
|
|
3124
|
+
ELSE summary
|
|
3125
|
+
END
|
|
3126
|
+
WHERE digestReportId = ? AND project = ?`,
|
|
3127
|
+
);
|
|
3128
|
+
const markReportHeldNoticesPossibleRepeat = db.query<unknown, [string, string, string]>(
|
|
3129
|
+
`UPDATE held_notices
|
|
3130
|
+
SET summary = CASE
|
|
3131
|
+
WHEN summary NOT LIKE 'POSSIBLE REPEAT%' THEN 'POSSIBLE REPEAT of report ' || ? || ': ' || summary
|
|
3132
|
+
ELSE summary
|
|
3133
|
+
END,
|
|
3134
|
+
detail = CASE
|
|
3135
|
+
WHEN detail NOT LIKE 'This handoff may already%' THEN
|
|
3136
|
+
'This handoff may already have reached Telegram before its outcome was lost.\n\n' || detail
|
|
3137
|
+
ELSE detail
|
|
3138
|
+
END
|
|
3139
|
+
WHERE digestReportId = ? AND project = ? AND withdrawnAt IS NULL`,
|
|
3140
|
+
);
|
|
3141
|
+
const unassignReportHeldNotices = db.query<unknown, [string, string]>(
|
|
3142
|
+
`UPDATE held_notices SET digestReportId = NULL, digestedAt = NULL
|
|
3143
|
+
WHERE digestReportId = ? AND project = ? AND withdrawnAt IS NULL`,
|
|
3144
|
+
);
|
|
3145
|
+
const selectHeldNoticeLifecycle = db.query<HeldNoticeLifecycleRow, [string]>(
|
|
3146
|
+
`SELECT id, project, summary, detail, digestedAt, digestReportId, withdrawnAt
|
|
3147
|
+
FROM held_notices WHERE id = ?`,
|
|
3148
|
+
);
|
|
3149
|
+
const withdrawHeldNoticeRow = db.query<
|
|
3150
|
+
unknown,
|
|
3151
|
+
[number, string, string, string, string]
|
|
3152
|
+
>(
|
|
3153
|
+
`UPDATE held_notices
|
|
3154
|
+
SET withdrawnAt = ?, withdrawnBy = ?, withdrawReason = ?
|
|
3155
|
+
WHERE id = ? AND project = ? AND withdrawnAt IS NULL AND (
|
|
3156
|
+
(digestReportId IS NULL AND digestedAt IS NULL)
|
|
3157
|
+
OR EXISTS (
|
|
3158
|
+
SELECT 1 FROM reports
|
|
3159
|
+
WHERE reports.id = held_notices.digestReportId
|
|
3160
|
+
AND reports.state IN ('failed', 'withdrawn')
|
|
3161
|
+
)
|
|
3162
|
+
)`,
|
|
3163
|
+
);
|
|
3164
|
+
const insertHandoffWithdrawal = db.query<
|
|
3165
|
+
unknown,
|
|
3166
|
+
[string, string, string, string, string, number, string, string]
|
|
3167
|
+
>(
|
|
3168
|
+
`INSERT INTO handoff_withdrawals
|
|
3169
|
+
(id, project, target, actor, reason, at, summary, detail)
|
|
3170
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
3171
|
+
);
|
|
3172
|
+
const selectHandoffWithdrawal = db.query<HandoffWithdrawalRow, [string]>(
|
|
3173
|
+
`SELECT * FROM handoff_withdrawals WHERE id = ?`,
|
|
3174
|
+
);
|
|
3175
|
+
const selectHandoffWithdrawals = db.query<HandoffWithdrawalRow, [string, number]>(
|
|
3176
|
+
`SELECT * FROM handoff_withdrawals
|
|
3177
|
+
WHERE project = ? ORDER BY at DESC, rowid DESC LIMIT ?`,
|
|
3178
|
+
);
|
|
3024
3179
|
const deletePendingMaterialReport = db.query<unknown, [string, string]>(
|
|
3025
3180
|
`DELETE FROM reports
|
|
3026
3181
|
WHERE id = ? AND project = ? AND kind = 'material' AND state = 'pending'`,
|
|
@@ -3051,7 +3206,7 @@ export function openStore(dbPath: string): Store {
|
|
|
3051
3206
|
THEN 'POSSIBLE REPEAT of catch-up ' || ? || ': ' || summary
|
|
3052
3207
|
ELSE summary
|
|
3053
3208
|
END
|
|
3054
|
-
WHERE project = ? AND digestReportId = ? AND EXISTS (
|
|
3209
|
+
WHERE project = ? AND digestReportId = ? AND withdrawnAt IS NULL AND EXISTS (
|
|
3055
3210
|
SELECT 1 FROM reports
|
|
3056
3211
|
WHERE id = ? AND project = ? AND kind = 'digest' AND state = 'pending'
|
|
3057
3212
|
AND dedupeKey LIKE 'availability/%'
|
|
@@ -3059,12 +3214,13 @@ export function openStore(dbPath: string): Store {
|
|
|
3059
3214
|
);
|
|
3060
3215
|
const selectAvailabilityReservationCandidate = db.query<{ id: string }, [string]>(
|
|
3061
3216
|
`SELECT held_notices.id FROM held_notices
|
|
3062
|
-
WHERE held_notices.project = ? AND held_notices.releaseOnAvailable = 1
|
|
3217
|
+
WHERE held_notices.project = ? AND held_notices.releaseOnAvailable = 1
|
|
3218
|
+
AND held_notices.withdrawnAt IS NULL AND (
|
|
3063
3219
|
(held_notices.digestReportId IS NULL AND held_notices.digestedAt IS NULL)
|
|
3064
3220
|
OR EXISTS (
|
|
3065
3221
|
SELECT 1 FROM reports
|
|
3066
3222
|
WHERE reports.id = held_notices.digestReportId AND (
|
|
3067
|
-
reports.state
|
|
3223
|
+
reports.state IN ('failed', 'withdrawn')
|
|
3068
3224
|
OR (
|
|
3069
3225
|
reports.state = 'pending' AND reports.kind = 'digest'
|
|
3070
3226
|
AND reports.dedupeKey LIKE 'availability/%'
|
|
@@ -3180,9 +3336,9 @@ export function openStore(dbPath: string): Store {
|
|
|
3180
3336
|
releaseOnAvailable: number;
|
|
3181
3337
|
urgent: number;
|
|
3182
3338
|
};
|
|
3183
|
-
const undigestedNoticeWhere = `project = ? AND (
|
|
3339
|
+
const undigestedNoticeWhere = `project = ? AND withdrawnAt IS NULL AND (
|
|
3184
3340
|
(digestReportId IS NULL AND digestedAt IS NULL)
|
|
3185
|
-
OR EXISTS (SELECT 1 FROM reports WHERE reports.id = held_notices.digestReportId AND reports.state
|
|
3341
|
+
OR EXISTS (SELECT 1 FROM reports WHERE reports.id = held_notices.digestReportId AND reports.state IN ('failed', 'withdrawn'))
|
|
3186
3342
|
)`;
|
|
3187
3343
|
const selectUndigestedNotices = db.query<HeldNoticeRow, [string, number]>(
|
|
3188
3344
|
`SELECT id, category, summary, detail, createdAt, releaseOnAvailable, urgent FROM held_notices
|
|
@@ -3212,16 +3368,16 @@ export function openStore(dbPath: string): Store {
|
|
|
3212
3368
|
MIN(CASE WHEN releaseOnAvailable = 0 THEN createdAt END) AS digestOldestAt,
|
|
3213
3369
|
MIN(CASE WHEN releaseOnAvailable = 1 THEN createdAt END) AS availabilityOldestAt
|
|
3214
3370
|
FROM held_notices
|
|
3215
|
-
WHERE project = ? AND (
|
|
3371
|
+
WHERE project = ? AND withdrawnAt IS NULL AND (
|
|
3216
3372
|
(digestReportId IS NULL AND digestedAt IS NULL)
|
|
3217
|
-
OR EXISTS (SELECT 1 FROM reports WHERE reports.id = held_notices.digestReportId AND reports.state
|
|
3373
|
+
OR EXISTS (SELECT 1 FROM reports WHERE reports.id = held_notices.digestReportId AND reports.state IN ('failed', 'withdrawn'))
|
|
3218
3374
|
)`,
|
|
3219
3375
|
);
|
|
3220
3376
|
const assignHeldNoticeToDigest = db.query<unknown, [number, string, string, string]>(
|
|
3221
3377
|
`UPDATE held_notices SET digestedAt = ?, digestReportId = ?
|
|
3222
|
-
WHERE project = ? AND id = ? AND (
|
|
3378
|
+
WHERE project = ? AND id = ? AND withdrawnAt IS NULL AND (
|
|
3223
3379
|
(digestReportId IS NULL AND digestedAt IS NULL)
|
|
3224
|
-
OR EXISTS (SELECT 1 FROM reports WHERE reports.id = held_notices.digestReportId AND reports.state
|
|
3380
|
+
OR EXISTS (SELECT 1 FROM reports WHERE reports.id = held_notices.digestReportId AND reports.state IN ('failed', 'withdrawn'))
|
|
3225
3381
|
)`,
|
|
3226
3382
|
);
|
|
3227
3383
|
const insertMaterialEvent = db.query<unknown, SqlValue[]>(
|
|
@@ -3236,7 +3392,7 @@ export function openStore(dbPath: string): Store {
|
|
|
3236
3392
|
`SELECT * FROM material_events
|
|
3237
3393
|
WHERE project = ? AND (
|
|
3238
3394
|
digestReportId IS NULL
|
|
3239
|
-
OR EXISTS (SELECT 1 FROM reports WHERE reports.id = material_events.digestReportId AND reports.state
|
|
3395
|
+
OR EXISTS (SELECT 1 FROM reports WHERE reports.id = material_events.digestReportId AND reports.state IN ('failed', 'withdrawn'))
|
|
3240
3396
|
)
|
|
3241
3397
|
ORDER BY occurredAt ASC, recordedAt ASC, rowid ASC
|
|
3242
3398
|
LIMIT ?`,
|
|
@@ -3245,14 +3401,14 @@ export function openStore(dbPath: string): Store {
|
|
|
3245
3401
|
`SELECT COUNT(*) AS count, MIN(occurredAt) AS oldestAt FROM material_events
|
|
3246
3402
|
WHERE project = ? AND (
|
|
3247
3403
|
digestReportId IS NULL
|
|
3248
|
-
OR EXISTS (SELECT 1 FROM reports WHERE reports.id = material_events.digestReportId AND reports.state
|
|
3404
|
+
OR EXISTS (SELECT 1 FROM reports WHERE reports.id = material_events.digestReportId AND reports.state IN ('failed', 'withdrawn'))
|
|
3249
3405
|
)`,
|
|
3250
3406
|
);
|
|
3251
3407
|
const assignMaterialEventToDigest = db.query<unknown, [string, string, string]>(
|
|
3252
3408
|
`UPDATE material_events SET digestReportId = ?
|
|
3253
3409
|
WHERE project = ? AND id = ? AND (
|
|
3254
3410
|
digestReportId IS NULL
|
|
3255
|
-
OR EXISTS (SELECT 1 FROM reports WHERE reports.id = material_events.digestReportId AND reports.state
|
|
3411
|
+
OR EXISTS (SELECT 1 FROM reports WHERE reports.id = material_events.digestReportId AND reports.state IN ('failed', 'withdrawn'))
|
|
3256
3412
|
)`,
|
|
3257
3413
|
);
|
|
3258
3414
|
const insertIntake = db.query<unknown, SqlValue[]>(
|
|
@@ -3278,7 +3434,7 @@ export function openStore(dbPath: string): Store {
|
|
|
3278
3434
|
// today's key under `digestDue` (#229).
|
|
3279
3435
|
const selectLastDigestKey = db.query<{ key: string | null }, [string]>(
|
|
3280
3436
|
`SELECT dedupeKey AS key FROM reports
|
|
3281
|
-
WHERE project = ? AND dedupeKey LIKE 'digest:%' AND state
|
|
3437
|
+
WHERE project = ? AND dedupeKey LIKE 'digest:%' AND state NOT IN ('failed', 'withdrawn')
|
|
3282
3438
|
ORDER BY createdAt DESC LIMIT 1`,
|
|
3283
3439
|
);
|
|
3284
3440
|
const claimReportRow = db.query<unknown, [string, number, string, number]>(
|
|
@@ -3404,6 +3560,109 @@ export function openStore(dbPath: string): Store {
|
|
|
3404
3560
|
}
|
|
3405
3561
|
return enqueued;
|
|
3406
3562
|
};
|
|
3563
|
+
|
|
3564
|
+
const withdrawHandoffTx = db.transaction(
|
|
3565
|
+
(
|
|
3566
|
+
id: string,
|
|
3567
|
+
project: string,
|
|
3568
|
+
actor: string,
|
|
3569
|
+
reason: string,
|
|
3570
|
+
at: number,
|
|
3571
|
+
): HandoffWithdrawalResult => {
|
|
3572
|
+
const report = selectReport.get(id);
|
|
3573
|
+
if (report !== null) {
|
|
3574
|
+
if (report.project !== project) return { kind: "not-found" };
|
|
3575
|
+
if (report.state !== "pending") {
|
|
3576
|
+
return { kind: "refused", target: "report", state: report.state };
|
|
3577
|
+
}
|
|
3578
|
+
const noticeIds = selectReportNoticeIds.all(id, project).map((row) => row.id);
|
|
3579
|
+
if (withdrawPendingReportRow.run(at, actor, reason, at, id, project).changes !== 1) {
|
|
3580
|
+
const current = selectReport.get(id);
|
|
3581
|
+
return {
|
|
3582
|
+
kind: "refused",
|
|
3583
|
+
target: "report",
|
|
3584
|
+
state: current?.state ?? "not-found",
|
|
3585
|
+
};
|
|
3586
|
+
}
|
|
3587
|
+
if (report.ambiguous !== 0) {
|
|
3588
|
+
markReportMaterialEventsPossibleRepeat.run(id, id, project);
|
|
3589
|
+
markReportHeldNoticesPossibleRepeat.run(id, id, project);
|
|
3590
|
+
}
|
|
3591
|
+
unassignReportMaterialEvents.run(id, project);
|
|
3592
|
+
unassignReportHeldNotices.run(id, project);
|
|
3593
|
+
for (const noticeId of noticeIds) {
|
|
3594
|
+
deleteAvailabilityReservationForNotice.run(project, noticeId);
|
|
3595
|
+
}
|
|
3596
|
+
const summary = report.body.split("\n", 1)[0]!.slice(0, 240);
|
|
3597
|
+
const withdrawal: HandoffWithdrawal = {
|
|
3598
|
+
id,
|
|
3599
|
+
project,
|
|
3600
|
+
target: "report",
|
|
3601
|
+
actor,
|
|
3602
|
+
reason,
|
|
3603
|
+
at,
|
|
3604
|
+
summary,
|
|
3605
|
+
detail: report.body,
|
|
3606
|
+
};
|
|
3607
|
+
insertHandoffWithdrawal.run(
|
|
3608
|
+
id,
|
|
3609
|
+
project,
|
|
3610
|
+
"report",
|
|
3611
|
+
actor,
|
|
3612
|
+
reason,
|
|
3613
|
+
at,
|
|
3614
|
+
summary,
|
|
3615
|
+
report.body,
|
|
3616
|
+
);
|
|
3617
|
+
return { kind: "withdrawn", withdrawal };
|
|
3618
|
+
}
|
|
3619
|
+
|
|
3620
|
+
const prior = selectHandoffWithdrawal.get(id);
|
|
3621
|
+
if (prior !== null) {
|
|
3622
|
+
return prior.project === project
|
|
3623
|
+
? { kind: "refused", target: prior.target as HandoffWithdrawal["target"], state: "withdrawn" }
|
|
3624
|
+
: { kind: "not-found" };
|
|
3625
|
+
}
|
|
3626
|
+
const notice = selectHeldNoticeLifecycle.get(id);
|
|
3627
|
+
if (notice === null || notice.project !== project) return { kind: "not-found" };
|
|
3628
|
+
if (notice.withdrawnAt !== null) {
|
|
3629
|
+
return { kind: "refused", target: "notice", state: "withdrawn" };
|
|
3630
|
+
}
|
|
3631
|
+
if (notice.digestReportId !== null) {
|
|
3632
|
+
const owner = selectReport.get(notice.digestReportId);
|
|
3633
|
+
if (owner === null || (owner.state !== "failed" && owner.state !== "withdrawn")) {
|
|
3634
|
+
return { kind: "refused", target: "notice", state: "assigned" };
|
|
3635
|
+
}
|
|
3636
|
+
} else if (notice.digestedAt !== null) {
|
|
3637
|
+
return { kind: "refused", target: "notice", state: "digested" };
|
|
3638
|
+
}
|
|
3639
|
+
if (withdrawHeldNoticeRow.run(at, actor, reason, id, project).changes !== 1) {
|
|
3640
|
+
return { kind: "refused", target: "notice", state: "changed" };
|
|
3641
|
+
}
|
|
3642
|
+
deleteAvailabilityReservationForNotice.run(project, id);
|
|
3643
|
+
const withdrawal: HandoffWithdrawal = {
|
|
3644
|
+
id,
|
|
3645
|
+
project,
|
|
3646
|
+
target: "notice",
|
|
3647
|
+
actor,
|
|
3648
|
+
reason,
|
|
3649
|
+
at,
|
|
3650
|
+
summary: notice.summary,
|
|
3651
|
+
detail: notice.detail,
|
|
3652
|
+
};
|
|
3653
|
+
insertHandoffWithdrawal.run(
|
|
3654
|
+
id,
|
|
3655
|
+
project,
|
|
3656
|
+
"notice",
|
|
3657
|
+
actor,
|
|
3658
|
+
reason,
|
|
3659
|
+
at,
|
|
3660
|
+
notice.summary,
|
|
3661
|
+
notice.detail,
|
|
3662
|
+
);
|
|
3663
|
+
return { kind: "withdrawn", withdrawal };
|
|
3664
|
+
},
|
|
3665
|
+
);
|
|
3407
3666
|
const enqueueDigestReportTx = db.transaction(enqueueDigestReportRecord);
|
|
3408
3667
|
const enqueueAvailabilityReportTx = db.transaction(
|
|
3409
3668
|
(draft: ReportDraft, heldNoticeIds: readonly string[]): ReportEnqueue | undefined => {
|
|
@@ -4870,6 +5129,21 @@ export function openStore(dbPath: string): Store {
|
|
|
4870
5129
|
return row === null ? undefined : toReport(row);
|
|
4871
5130
|
},
|
|
4872
5131
|
|
|
5132
|
+
withdrawHandoff(
|
|
5133
|
+
id: string,
|
|
5134
|
+
project: string,
|
|
5135
|
+
actor: string,
|
|
5136
|
+
reason: string,
|
|
5137
|
+
at: number,
|
|
5138
|
+
): HandoffWithdrawalResult {
|
|
5139
|
+
return withdrawHandoffTx.immediate(id, project, actor, reason, at);
|
|
5140
|
+
},
|
|
5141
|
+
|
|
5142
|
+
handoffWithdrawals(project: string, limit = 5): HandoffWithdrawal[] {
|
|
5143
|
+
return selectHandoffWithdrawals.all(project, limit).map(toHandoffWithdrawal);
|
|
5144
|
+
},
|
|
5145
|
+
|
|
5146
|
+
|
|
4873
5147
|
deferPendingReportToNotice(id: string, notice: HeldNoticeDraft): boolean {
|
|
4874
5148
|
return deferPendingReportToNoticeTx(id, notice);
|
|
4875
5149
|
},
|