omp-conductor 0.19.5 → 0.19.7
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/REFERENCE.md +17 -1
- 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 +146 -35
- package/src/doctor.ts +53 -15
- package/src/escalate.ts +39 -21
- package/src/fleet.ts +979 -143
- 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 +338 -22
- package/src/types.ts +51 -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 +146 -31
- package/systemd/omp-conductor-recover.sh +55 -7
- package/systemd/recover-unit-test.sh +99 -6
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;
|
|
@@ -1310,6 +1356,20 @@ CREATE TABLE IF NOT EXISTS orchestrator_incidents (
|
|
|
1310
1356
|
diverted INTEGER NOT NULL DEFAULT 0
|
|
1311
1357
|
);
|
|
1312
1358
|
|
|
1359
|
+
-- Conductor-owned Herdr worker workspaces (#1035 review). Herdr restores a
|
|
1360
|
+
-- session's workspaces and panes across a server restart but drops
|
|
1361
|
+
-- report-metadata tokens, so the live-only token cannot remain the only
|
|
1362
|
+
-- ownership authority: the restored surface would be invisible to discovery,
|
|
1363
|
+
-- a duplicate would be created beside it, and its stale panes could never be
|
|
1364
|
+
-- reconciled or removed. One row per workspace conductor created and still
|
|
1365
|
+
-- owns; forgotten when the workspace is removed or fails to create cleanly.
|
|
1366
|
+
CREATE TABLE IF NOT EXISTS herdr_worker_workspaces (
|
|
1367
|
+
project TEXT NOT NULL,
|
|
1368
|
+
workspace_id TEXT NOT NULL,
|
|
1369
|
+
created_at INTEGER NOT NULL,
|
|
1370
|
+
PRIMARY KEY (project, workspace_id)
|
|
1371
|
+
);
|
|
1372
|
+
|
|
1313
1373
|
-- Every stop/restart of the shared daemon, and who asked for it and why
|
|
1314
1374
|
-- (#378). Deliberately NOT partitioned by project: the daemon serves every
|
|
1315
1375
|
-- configured project, so a record written by one project's CLI must be
|
|
@@ -1562,9 +1622,25 @@ function toReport(row: ReportRow): ReportRecord {
|
|
|
1562
1622
|
if (row.sentPartsHash !== null) record.sentPartsHash = row.sentPartsHash;
|
|
1563
1623
|
if (row.deliveredAt !== null) record.deliveredAt = row.deliveredAt;
|
|
1564
1624
|
if (row.lastError !== null) record.lastError = row.lastError;
|
|
1625
|
+
if (row.withdrawnAt !== null) record.withdrawnAt = row.withdrawnAt;
|
|
1626
|
+
if (row.withdrawnBy !== null) record.withdrawnBy = row.withdrawnBy;
|
|
1627
|
+
if (row.withdrawReason !== null) record.withdrawReason = row.withdrawReason;
|
|
1565
1628
|
return record;
|
|
1566
1629
|
}
|
|
1567
1630
|
|
|
1631
|
+
function toHandoffWithdrawal(row: HandoffWithdrawalRow): HandoffWithdrawal {
|
|
1632
|
+
return {
|
|
1633
|
+
id: row.id,
|
|
1634
|
+
project: row.project,
|
|
1635
|
+
summary: row.summary,
|
|
1636
|
+
detail: row.detail,
|
|
1637
|
+
target: row.target as HandoffWithdrawal["target"],
|
|
1638
|
+
actor: row.actor,
|
|
1639
|
+
reason: row.reason,
|
|
1640
|
+
at: row.at,
|
|
1641
|
+
};
|
|
1642
|
+
}
|
|
1643
|
+
|
|
1568
1644
|
function toMaterialEvent(row: MaterialEventRow): MaterialEvent {
|
|
1569
1645
|
return {
|
|
1570
1646
|
id: row.id,
|
|
@@ -1905,20 +1981,20 @@ export function openStore(dbPath: string): Store {
|
|
|
1905
1981
|
db.exec("PRAGMA journal_mode = WAL;");
|
|
1906
1982
|
db.exec("PRAGMA foreign_keys = ON;");
|
|
1907
1983
|
db.exec(SCHEMA);
|
|
1908
|
-
//
|
|
1909
|
-
//
|
|
1910
|
-
// one-time schema so owed ledger rows can be handed off again.
|
|
1984
|
+
// Failed or explicitly withdrawn handoffs no longer own a digest dedupe key:
|
|
1985
|
+
// both leave their underlying ledger rows owed for a replacement report.
|
|
1911
1986
|
const reportDedupeIndex = db
|
|
1912
1987
|
.query<{ sql: string | null }, []>(
|
|
1913
1988
|
`SELECT sql FROM sqlite_master WHERE type = 'index' AND name = 'reports_dedupe'`,
|
|
1914
1989
|
)
|
|
1915
1990
|
.get();
|
|
1916
|
-
if (!(reportDedupeIndex?.sql ?? "").includes("state <> '
|
|
1991
|
+
if (!(reportDedupeIndex?.sql ?? "").includes("state <> 'withdrawn'")) {
|
|
1917
1992
|
db.exec(
|
|
1918
1993
|
`BEGIN IMMEDIATE;
|
|
1919
1994
|
DROP INDEX IF EXISTS reports_dedupe;
|
|
1920
1995
|
CREATE UNIQUE INDEX reports_dedupe
|
|
1921
|
-
ON reports (project, dedupeKey)
|
|
1996
|
+
ON reports (project, dedupeKey)
|
|
1997
|
+
WHERE dedupeKey IS NOT NULL AND state <> 'failed' AND state <> 'withdrawn';
|
|
1922
1998
|
COMMIT;`,
|
|
1923
1999
|
);
|
|
1924
2000
|
}
|
|
@@ -2112,6 +2188,15 @@ export function openStore(dbPath: string): Store {
|
|
|
2112
2188
|
}
|
|
2113
2189
|
// Urgent recovery notices bypass category batching only; they still wait for
|
|
2114
2190
|
// the configured availability window. Historical notices were not urgent.
|
|
2191
|
+
for (const [name, type] of [
|
|
2192
|
+
["withdrawnAt", "INTEGER"],
|
|
2193
|
+
["withdrawnBy", "TEXT"],
|
|
2194
|
+
["withdrawReason", "TEXT"],
|
|
2195
|
+
] as const) {
|
|
2196
|
+
if (!heldNoticeColumns.some((column) => column.name === name)) {
|
|
2197
|
+
db.exec(`ALTER TABLE held_notices ADD COLUMN ${name} ${type}`);
|
|
2198
|
+
}
|
|
2199
|
+
}
|
|
2115
2200
|
if (!heldNoticeColumns.some((column) => column.name === "urgent")) {
|
|
2116
2201
|
db.exec("ALTER TABLE held_notices ADD COLUMN urgent INTEGER NOT NULL DEFAULT 0");
|
|
2117
2202
|
}
|
|
@@ -2129,6 +2214,15 @@ export function openStore(dbPath: string): Store {
|
|
|
2129
2214
|
if (!reportColumns.some((column) => column.name === "sentPartsHash")) {
|
|
2130
2215
|
db.exec("ALTER TABLE reports ADD COLUMN sentPartsHash TEXT");
|
|
2131
2216
|
}
|
|
2217
|
+
for (const [name, type] of [
|
|
2218
|
+
["withdrawnAt", "INTEGER"],
|
|
2219
|
+
["withdrawnBy", "TEXT"],
|
|
2220
|
+
["withdrawReason", "TEXT"],
|
|
2221
|
+
] as const) {
|
|
2222
|
+
if (!reportColumns.some((column) => column.name === name)) {
|
|
2223
|
+
db.exec(`ALTER TABLE reports ADD COLUMN ${name} ${type}`);
|
|
2224
|
+
}
|
|
2225
|
+
}
|
|
2132
2226
|
// The kind split (#459): a row is either a question a human must answer or a
|
|
2133
2227
|
// watch the orchestrator set for itself. Databases written before the split
|
|
2134
2228
|
// never marked it, and every such row was a question — `watch` has no
|
|
@@ -2874,6 +2968,18 @@ export function openStore(dbPath: string): Store {
|
|
|
2874
2968
|
WHERE project = ?
|
|
2875
2969
|
RETURNING project, mode, cause, since, diverted`,
|
|
2876
2970
|
);
|
|
2971
|
+
// Worker-workspace ownership rows (#1035 review). See the table comment:
|
|
2972
|
+
// the durable half of discovery, so a Herdr restart that drops metadata
|
|
2973
|
+
// tokens cannot orphan the surface conductor created.
|
|
2974
|
+
const selectHerdrWorkerWorkspaces = db.query<{ workspace_id: string }, [string]>(
|
|
2975
|
+
`SELECT workspace_id FROM herdr_worker_workspaces WHERE project = ? ORDER BY created_at ASC, workspace_id ASC`,
|
|
2976
|
+
);
|
|
2977
|
+
const insertHerdrWorkerWorkspace = db.query<unknown, [string, string, number]>(
|
|
2978
|
+
`INSERT OR IGNORE INTO herdr_worker_workspaces (project, workspace_id, created_at) VALUES (?, ?, ?)`,
|
|
2979
|
+
);
|
|
2980
|
+
const deleteHerdrWorkerWorkspace = db.query<unknown, [string, string]>(
|
|
2981
|
+
`DELETE FROM herdr_worker_workspaces WHERE project = ? AND workspace_id = ?`,
|
|
2982
|
+
);
|
|
2877
2983
|
const upsertDispatch = db.query<unknown, [string, string]>(
|
|
2878
2984
|
`INSERT INTO dispatch_summaries (project, summary) VALUES (?, ?)
|
|
2879
2985
|
ON CONFLICT(project) DO UPDATE SET summary = excluded.summary`,
|
|
@@ -3012,7 +3118,7 @@ export function openStore(dbPath: string): Store {
|
|
|
3012
3118
|
const selectReport = db.query<ReportRow, [string]>(`SELECT * FROM reports WHERE id = ?`);
|
|
3013
3119
|
const selectReportByDedupe = db.query<ReportRow, [string, string]>(
|
|
3014
3120
|
`SELECT * FROM reports
|
|
3015
|
-
WHERE project = ? AND dedupeKey = ? AND state
|
|
3121
|
+
WHERE project = ? AND dedupeKey = ? AND state NOT IN ('failed', 'withdrawn')
|
|
3016
3122
|
ORDER BY createdAt DESC, rowid DESC LIMIT 1`,
|
|
3017
3123
|
);
|
|
3018
3124
|
const selectDueReports = db.query<ReportRow, [string, number, number]>(
|
|
@@ -3021,6 +3127,81 @@ export function openStore(dbPath: string): Store {
|
|
|
3021
3127
|
ORDER BY createdAt ASC, rowid ASC
|
|
3022
3128
|
LIMIT ?`,
|
|
3023
3129
|
);
|
|
3130
|
+
const withdrawPendingReportRow = db.query<
|
|
3131
|
+
unknown,
|
|
3132
|
+
[number, string, string, number, string, string]
|
|
3133
|
+
>(
|
|
3134
|
+
`UPDATE reports
|
|
3135
|
+
SET state = 'withdrawn', withdrawnAt = ?, withdrawnBy = ?,
|
|
3136
|
+
withdrawReason = ?, updatedAt = ?, attemptId = NULL
|
|
3137
|
+
WHERE id = ? AND project = ? AND state = 'pending'`,
|
|
3138
|
+
);
|
|
3139
|
+
const selectReportNoticeIds = db.query<{ id: string }, [string, string]>(
|
|
3140
|
+
`SELECT id FROM held_notices WHERE digestReportId = ? AND project = ?`,
|
|
3141
|
+
);
|
|
3142
|
+
const unassignReportMaterialEvents = db.query<unknown, [string, string]>(
|
|
3143
|
+
`UPDATE material_events SET digestReportId = NULL
|
|
3144
|
+
WHERE digestReportId = ? AND project = ?`,
|
|
3145
|
+
);
|
|
3146
|
+
const markReportMaterialEventsPossibleRepeat = db.query<unknown, [string, string, string]>(
|
|
3147
|
+
`UPDATE material_events
|
|
3148
|
+
SET summary = CASE
|
|
3149
|
+
WHEN summary NOT LIKE 'POSSIBLE REPEAT%' THEN 'POSSIBLE REPEAT of report ' || ? || ': ' || summary
|
|
3150
|
+
ELSE summary
|
|
3151
|
+
END
|
|
3152
|
+
WHERE digestReportId = ? AND project = ?`,
|
|
3153
|
+
);
|
|
3154
|
+
const markReportHeldNoticesPossibleRepeat = db.query<unknown, [string, string, string]>(
|
|
3155
|
+
`UPDATE held_notices
|
|
3156
|
+
SET summary = CASE
|
|
3157
|
+
WHEN summary NOT LIKE 'POSSIBLE REPEAT%' THEN 'POSSIBLE REPEAT of report ' || ? || ': ' || summary
|
|
3158
|
+
ELSE summary
|
|
3159
|
+
END,
|
|
3160
|
+
detail = CASE
|
|
3161
|
+
WHEN detail NOT LIKE 'This handoff may already%' THEN
|
|
3162
|
+
'This handoff may already have reached Telegram before its outcome was lost.\n\n' || detail
|
|
3163
|
+
ELSE detail
|
|
3164
|
+
END
|
|
3165
|
+
WHERE digestReportId = ? AND project = ? AND withdrawnAt IS NULL`,
|
|
3166
|
+
);
|
|
3167
|
+
const unassignReportHeldNotices = db.query<unknown, [string, string]>(
|
|
3168
|
+
`UPDATE held_notices SET digestReportId = NULL, digestedAt = NULL
|
|
3169
|
+
WHERE digestReportId = ? AND project = ? AND withdrawnAt IS NULL`,
|
|
3170
|
+
);
|
|
3171
|
+
const selectHeldNoticeLifecycle = db.query<HeldNoticeLifecycleRow, [string]>(
|
|
3172
|
+
`SELECT id, project, summary, detail, digestedAt, digestReportId, withdrawnAt
|
|
3173
|
+
FROM held_notices WHERE id = ?`,
|
|
3174
|
+
);
|
|
3175
|
+
const withdrawHeldNoticeRow = db.query<
|
|
3176
|
+
unknown,
|
|
3177
|
+
[number, string, string, string, string]
|
|
3178
|
+
>(
|
|
3179
|
+
`UPDATE held_notices
|
|
3180
|
+
SET withdrawnAt = ?, withdrawnBy = ?, withdrawReason = ?
|
|
3181
|
+
WHERE id = ? AND project = ? AND withdrawnAt IS NULL AND (
|
|
3182
|
+
(digestReportId IS NULL AND digestedAt IS NULL)
|
|
3183
|
+
OR EXISTS (
|
|
3184
|
+
SELECT 1 FROM reports
|
|
3185
|
+
WHERE reports.id = held_notices.digestReportId
|
|
3186
|
+
AND reports.state IN ('failed', 'withdrawn')
|
|
3187
|
+
)
|
|
3188
|
+
)`,
|
|
3189
|
+
);
|
|
3190
|
+
const insertHandoffWithdrawal = db.query<
|
|
3191
|
+
unknown,
|
|
3192
|
+
[string, string, string, string, string, number, string, string]
|
|
3193
|
+
>(
|
|
3194
|
+
`INSERT INTO handoff_withdrawals
|
|
3195
|
+
(id, project, target, actor, reason, at, summary, detail)
|
|
3196
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
3197
|
+
);
|
|
3198
|
+
const selectHandoffWithdrawal = db.query<HandoffWithdrawalRow, [string]>(
|
|
3199
|
+
`SELECT * FROM handoff_withdrawals WHERE id = ?`,
|
|
3200
|
+
);
|
|
3201
|
+
const selectHandoffWithdrawals = db.query<HandoffWithdrawalRow, [string, number]>(
|
|
3202
|
+
`SELECT * FROM handoff_withdrawals
|
|
3203
|
+
WHERE project = ? ORDER BY at DESC, rowid DESC LIMIT ?`,
|
|
3204
|
+
);
|
|
3024
3205
|
const deletePendingMaterialReport = db.query<unknown, [string, string]>(
|
|
3025
3206
|
`DELETE FROM reports
|
|
3026
3207
|
WHERE id = ? AND project = ? AND kind = 'material' AND state = 'pending'`,
|
|
@@ -3051,7 +3232,7 @@ export function openStore(dbPath: string): Store {
|
|
|
3051
3232
|
THEN 'POSSIBLE REPEAT of catch-up ' || ? || ': ' || summary
|
|
3052
3233
|
ELSE summary
|
|
3053
3234
|
END
|
|
3054
|
-
WHERE project = ? AND digestReportId = ? AND EXISTS (
|
|
3235
|
+
WHERE project = ? AND digestReportId = ? AND withdrawnAt IS NULL AND EXISTS (
|
|
3055
3236
|
SELECT 1 FROM reports
|
|
3056
3237
|
WHERE id = ? AND project = ? AND kind = 'digest' AND state = 'pending'
|
|
3057
3238
|
AND dedupeKey LIKE 'availability/%'
|
|
@@ -3059,12 +3240,13 @@ export function openStore(dbPath: string): Store {
|
|
|
3059
3240
|
);
|
|
3060
3241
|
const selectAvailabilityReservationCandidate = db.query<{ id: string }, [string]>(
|
|
3061
3242
|
`SELECT held_notices.id FROM held_notices
|
|
3062
|
-
WHERE held_notices.project = ? AND held_notices.releaseOnAvailable = 1
|
|
3243
|
+
WHERE held_notices.project = ? AND held_notices.releaseOnAvailable = 1
|
|
3244
|
+
AND held_notices.withdrawnAt IS NULL AND (
|
|
3063
3245
|
(held_notices.digestReportId IS NULL AND held_notices.digestedAt IS NULL)
|
|
3064
3246
|
OR EXISTS (
|
|
3065
3247
|
SELECT 1 FROM reports
|
|
3066
3248
|
WHERE reports.id = held_notices.digestReportId AND (
|
|
3067
|
-
reports.state
|
|
3249
|
+
reports.state IN ('failed', 'withdrawn')
|
|
3068
3250
|
OR (
|
|
3069
3251
|
reports.state = 'pending' AND reports.kind = 'digest'
|
|
3070
3252
|
AND reports.dedupeKey LIKE 'availability/%'
|
|
@@ -3180,9 +3362,9 @@ export function openStore(dbPath: string): Store {
|
|
|
3180
3362
|
releaseOnAvailable: number;
|
|
3181
3363
|
urgent: number;
|
|
3182
3364
|
};
|
|
3183
|
-
const undigestedNoticeWhere = `project = ? AND (
|
|
3365
|
+
const undigestedNoticeWhere = `project = ? AND withdrawnAt IS NULL AND (
|
|
3184
3366
|
(digestReportId IS NULL AND digestedAt IS NULL)
|
|
3185
|
-
OR EXISTS (SELECT 1 FROM reports WHERE reports.id = held_notices.digestReportId AND reports.state
|
|
3367
|
+
OR EXISTS (SELECT 1 FROM reports WHERE reports.id = held_notices.digestReportId AND reports.state IN ('failed', 'withdrawn'))
|
|
3186
3368
|
)`;
|
|
3187
3369
|
const selectUndigestedNotices = db.query<HeldNoticeRow, [string, number]>(
|
|
3188
3370
|
`SELECT id, category, summary, detail, createdAt, releaseOnAvailable, urgent FROM held_notices
|
|
@@ -3212,16 +3394,16 @@ export function openStore(dbPath: string): Store {
|
|
|
3212
3394
|
MIN(CASE WHEN releaseOnAvailable = 0 THEN createdAt END) AS digestOldestAt,
|
|
3213
3395
|
MIN(CASE WHEN releaseOnAvailable = 1 THEN createdAt END) AS availabilityOldestAt
|
|
3214
3396
|
FROM held_notices
|
|
3215
|
-
WHERE project = ? AND (
|
|
3397
|
+
WHERE project = ? AND withdrawnAt IS NULL AND (
|
|
3216
3398
|
(digestReportId IS NULL AND digestedAt IS NULL)
|
|
3217
|
-
OR EXISTS (SELECT 1 FROM reports WHERE reports.id = held_notices.digestReportId AND reports.state
|
|
3399
|
+
OR EXISTS (SELECT 1 FROM reports WHERE reports.id = held_notices.digestReportId AND reports.state IN ('failed', 'withdrawn'))
|
|
3218
3400
|
)`,
|
|
3219
3401
|
);
|
|
3220
3402
|
const assignHeldNoticeToDigest = db.query<unknown, [number, string, string, string]>(
|
|
3221
3403
|
`UPDATE held_notices SET digestedAt = ?, digestReportId = ?
|
|
3222
|
-
WHERE project = ? AND id = ? AND (
|
|
3404
|
+
WHERE project = ? AND id = ? AND withdrawnAt IS NULL AND (
|
|
3223
3405
|
(digestReportId IS NULL AND digestedAt IS NULL)
|
|
3224
|
-
OR EXISTS (SELECT 1 FROM reports WHERE reports.id = held_notices.digestReportId AND reports.state
|
|
3406
|
+
OR EXISTS (SELECT 1 FROM reports WHERE reports.id = held_notices.digestReportId AND reports.state IN ('failed', 'withdrawn'))
|
|
3225
3407
|
)`,
|
|
3226
3408
|
);
|
|
3227
3409
|
const insertMaterialEvent = db.query<unknown, SqlValue[]>(
|
|
@@ -3236,7 +3418,7 @@ export function openStore(dbPath: string): Store {
|
|
|
3236
3418
|
`SELECT * FROM material_events
|
|
3237
3419
|
WHERE project = ? AND (
|
|
3238
3420
|
digestReportId IS NULL
|
|
3239
|
-
OR EXISTS (SELECT 1 FROM reports WHERE reports.id = material_events.digestReportId AND reports.state
|
|
3421
|
+
OR EXISTS (SELECT 1 FROM reports WHERE reports.id = material_events.digestReportId AND reports.state IN ('failed', 'withdrawn'))
|
|
3240
3422
|
)
|
|
3241
3423
|
ORDER BY occurredAt ASC, recordedAt ASC, rowid ASC
|
|
3242
3424
|
LIMIT ?`,
|
|
@@ -3245,14 +3427,14 @@ export function openStore(dbPath: string): Store {
|
|
|
3245
3427
|
`SELECT COUNT(*) AS count, MIN(occurredAt) AS oldestAt FROM material_events
|
|
3246
3428
|
WHERE project = ? AND (
|
|
3247
3429
|
digestReportId IS NULL
|
|
3248
|
-
OR EXISTS (SELECT 1 FROM reports WHERE reports.id = material_events.digestReportId AND reports.state
|
|
3430
|
+
OR EXISTS (SELECT 1 FROM reports WHERE reports.id = material_events.digestReportId AND reports.state IN ('failed', 'withdrawn'))
|
|
3249
3431
|
)`,
|
|
3250
3432
|
);
|
|
3251
3433
|
const assignMaterialEventToDigest = db.query<unknown, [string, string, string]>(
|
|
3252
3434
|
`UPDATE material_events SET digestReportId = ?
|
|
3253
3435
|
WHERE project = ? AND id = ? AND (
|
|
3254
3436
|
digestReportId IS NULL
|
|
3255
|
-
OR EXISTS (SELECT 1 FROM reports WHERE reports.id = material_events.digestReportId AND reports.state
|
|
3437
|
+
OR EXISTS (SELECT 1 FROM reports WHERE reports.id = material_events.digestReportId AND reports.state IN ('failed', 'withdrawn'))
|
|
3256
3438
|
)`,
|
|
3257
3439
|
);
|
|
3258
3440
|
const insertIntake = db.query<unknown, SqlValue[]>(
|
|
@@ -3278,7 +3460,7 @@ export function openStore(dbPath: string): Store {
|
|
|
3278
3460
|
// today's key under `digestDue` (#229).
|
|
3279
3461
|
const selectLastDigestKey = db.query<{ key: string | null }, [string]>(
|
|
3280
3462
|
`SELECT dedupeKey AS key FROM reports
|
|
3281
|
-
WHERE project = ? AND dedupeKey LIKE 'digest:%' AND state
|
|
3463
|
+
WHERE project = ? AND dedupeKey LIKE 'digest:%' AND state NOT IN ('failed', 'withdrawn')
|
|
3282
3464
|
ORDER BY createdAt DESC LIMIT 1`,
|
|
3283
3465
|
);
|
|
3284
3466
|
const claimReportRow = db.query<unknown, [string, number, string, number]>(
|
|
@@ -3404,6 +3586,109 @@ export function openStore(dbPath: string): Store {
|
|
|
3404
3586
|
}
|
|
3405
3587
|
return enqueued;
|
|
3406
3588
|
};
|
|
3589
|
+
|
|
3590
|
+
const withdrawHandoffTx = db.transaction(
|
|
3591
|
+
(
|
|
3592
|
+
id: string,
|
|
3593
|
+
project: string,
|
|
3594
|
+
actor: string,
|
|
3595
|
+
reason: string,
|
|
3596
|
+
at: number,
|
|
3597
|
+
): HandoffWithdrawalResult => {
|
|
3598
|
+
const report = selectReport.get(id);
|
|
3599
|
+
if (report !== null) {
|
|
3600
|
+
if (report.project !== project) return { kind: "not-found" };
|
|
3601
|
+
if (report.state !== "pending") {
|
|
3602
|
+
return { kind: "refused", target: "report", state: report.state };
|
|
3603
|
+
}
|
|
3604
|
+
const noticeIds = selectReportNoticeIds.all(id, project).map((row) => row.id);
|
|
3605
|
+
if (withdrawPendingReportRow.run(at, actor, reason, at, id, project).changes !== 1) {
|
|
3606
|
+
const current = selectReport.get(id);
|
|
3607
|
+
return {
|
|
3608
|
+
kind: "refused",
|
|
3609
|
+
target: "report",
|
|
3610
|
+
state: current?.state ?? "not-found",
|
|
3611
|
+
};
|
|
3612
|
+
}
|
|
3613
|
+
if (report.ambiguous !== 0) {
|
|
3614
|
+
markReportMaterialEventsPossibleRepeat.run(id, id, project);
|
|
3615
|
+
markReportHeldNoticesPossibleRepeat.run(id, id, project);
|
|
3616
|
+
}
|
|
3617
|
+
unassignReportMaterialEvents.run(id, project);
|
|
3618
|
+
unassignReportHeldNotices.run(id, project);
|
|
3619
|
+
for (const noticeId of noticeIds) {
|
|
3620
|
+
deleteAvailabilityReservationForNotice.run(project, noticeId);
|
|
3621
|
+
}
|
|
3622
|
+
const summary = report.body.split("\n", 1)[0]!.slice(0, 240);
|
|
3623
|
+
const withdrawal: HandoffWithdrawal = {
|
|
3624
|
+
id,
|
|
3625
|
+
project,
|
|
3626
|
+
target: "report",
|
|
3627
|
+
actor,
|
|
3628
|
+
reason,
|
|
3629
|
+
at,
|
|
3630
|
+
summary,
|
|
3631
|
+
detail: report.body,
|
|
3632
|
+
};
|
|
3633
|
+
insertHandoffWithdrawal.run(
|
|
3634
|
+
id,
|
|
3635
|
+
project,
|
|
3636
|
+
"report",
|
|
3637
|
+
actor,
|
|
3638
|
+
reason,
|
|
3639
|
+
at,
|
|
3640
|
+
summary,
|
|
3641
|
+
report.body,
|
|
3642
|
+
);
|
|
3643
|
+
return { kind: "withdrawn", withdrawal };
|
|
3644
|
+
}
|
|
3645
|
+
|
|
3646
|
+
const prior = selectHandoffWithdrawal.get(id);
|
|
3647
|
+
if (prior !== null) {
|
|
3648
|
+
return prior.project === project
|
|
3649
|
+
? { kind: "refused", target: prior.target as HandoffWithdrawal["target"], state: "withdrawn" }
|
|
3650
|
+
: { kind: "not-found" };
|
|
3651
|
+
}
|
|
3652
|
+
const notice = selectHeldNoticeLifecycle.get(id);
|
|
3653
|
+
if (notice === null || notice.project !== project) return { kind: "not-found" };
|
|
3654
|
+
if (notice.withdrawnAt !== null) {
|
|
3655
|
+
return { kind: "refused", target: "notice", state: "withdrawn" };
|
|
3656
|
+
}
|
|
3657
|
+
if (notice.digestReportId !== null) {
|
|
3658
|
+
const owner = selectReport.get(notice.digestReportId);
|
|
3659
|
+
if (owner === null || (owner.state !== "failed" && owner.state !== "withdrawn")) {
|
|
3660
|
+
return { kind: "refused", target: "notice", state: "assigned" };
|
|
3661
|
+
}
|
|
3662
|
+
} else if (notice.digestedAt !== null) {
|
|
3663
|
+
return { kind: "refused", target: "notice", state: "digested" };
|
|
3664
|
+
}
|
|
3665
|
+
if (withdrawHeldNoticeRow.run(at, actor, reason, id, project).changes !== 1) {
|
|
3666
|
+
return { kind: "refused", target: "notice", state: "changed" };
|
|
3667
|
+
}
|
|
3668
|
+
deleteAvailabilityReservationForNotice.run(project, id);
|
|
3669
|
+
const withdrawal: HandoffWithdrawal = {
|
|
3670
|
+
id,
|
|
3671
|
+
project,
|
|
3672
|
+
target: "notice",
|
|
3673
|
+
actor,
|
|
3674
|
+
reason,
|
|
3675
|
+
at,
|
|
3676
|
+
summary: notice.summary,
|
|
3677
|
+
detail: notice.detail,
|
|
3678
|
+
};
|
|
3679
|
+
insertHandoffWithdrawal.run(
|
|
3680
|
+
id,
|
|
3681
|
+
project,
|
|
3682
|
+
"notice",
|
|
3683
|
+
actor,
|
|
3684
|
+
reason,
|
|
3685
|
+
at,
|
|
3686
|
+
notice.summary,
|
|
3687
|
+
notice.detail,
|
|
3688
|
+
);
|
|
3689
|
+
return { kind: "withdrawn", withdrawal };
|
|
3690
|
+
},
|
|
3691
|
+
);
|
|
3407
3692
|
const enqueueDigestReportTx = db.transaction(enqueueDigestReportRecord);
|
|
3408
3693
|
const enqueueAvailabilityReportTx = db.transaction(
|
|
3409
3694
|
(draft: ReportDraft, heldNoticeIds: readonly string[]): ReportEnqueue | undefined => {
|
|
@@ -4590,6 +4875,22 @@ export function openStore(dbPath: string): Store {
|
|
|
4590
4875
|
bumpOrchestratorIncident.run(by, project);
|
|
4591
4876
|
},
|
|
4592
4877
|
|
|
4878
|
+
/** The workspace ids conductor recorded as its own worker surface for
|
|
4879
|
+
* this project, oldest first (#1035 review). Durable across daemon AND
|
|
4880
|
+
* Herdr restarts, which is the point: Herdr restores workspaces without
|
|
4881
|
+
* their metadata tokens, so discovery needs a second authority. */
|
|
4882
|
+
workerWorkspaceIds(project: string): string[] {
|
|
4883
|
+
return selectHerdrWorkerWorkspaces.all(project).map((row) => row.workspace_id);
|
|
4884
|
+
},
|
|
4885
|
+
|
|
4886
|
+
rememberWorkerWorkspace(project: string, workspaceId: string): void {
|
|
4887
|
+
insertHerdrWorkerWorkspace.run(project, workspaceId, Date.now());
|
|
4888
|
+
},
|
|
4889
|
+
|
|
4890
|
+
forgetWorkerWorkspace(project: string, workspaceId: string): void {
|
|
4891
|
+
deleteHerdrWorkerWorkspace.run(project, workspaceId);
|
|
4892
|
+
},
|
|
4893
|
+
|
|
4593
4894
|
closeOrchestratorIncident(project: string, _at: number): OrchestratorIncident | undefined {
|
|
4594
4895
|
const row = deleteOrchestratorIncident.get(project);
|
|
4595
4896
|
return row === null ? undefined : toOrchestratorIncident(row);
|
|
@@ -4870,6 +5171,21 @@ export function openStore(dbPath: string): Store {
|
|
|
4870
5171
|
return row === null ? undefined : toReport(row);
|
|
4871
5172
|
},
|
|
4872
5173
|
|
|
5174
|
+
withdrawHandoff(
|
|
5175
|
+
id: string,
|
|
5176
|
+
project: string,
|
|
5177
|
+
actor: string,
|
|
5178
|
+
reason: string,
|
|
5179
|
+
at: number,
|
|
5180
|
+
): HandoffWithdrawalResult {
|
|
5181
|
+
return withdrawHandoffTx.immediate(id, project, actor, reason, at);
|
|
5182
|
+
},
|
|
5183
|
+
|
|
5184
|
+
handoffWithdrawals(project: string, limit = 5): HandoffWithdrawal[] {
|
|
5185
|
+
return selectHandoffWithdrawals.all(project, limit).map(toHandoffWithdrawal);
|
|
5186
|
+
},
|
|
5187
|
+
|
|
5188
|
+
|
|
4873
5189
|
deferPendingReportToNotice(id: string, notice: HeldNoticeDraft): boolean {
|
|
4874
5190
|
return deferPendingReportToNoticeTx(id, notice);
|
|
4875
5191
|
},
|