zigmini 1.0.0-beta.2 → 1.1.0-beta.1
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/README.ko.md +36 -3
- package/README.md +36 -3
- package/assets/agent/gateway-request-rules.md +1 -1
- package/assets/agent/gateway-rules.md +6 -1
- package/assets/android/zigmini.apk +0 -0
- package/assets/android/zigmini.apk.json +3 -3
- package/assets/build.json +1 -1
- package/assets/document-reader/document-reader +0 -0
- package/assets/document-reader/host-selector +0 -0
- package/assets/document-reader/manifest.json +27 -0
- package/docs/manual.ko.md +120 -11
- package/docs/manual.md +123 -12
- package/host/document-reader/DocumentRead.swift +370 -0
- package/host/document-reader/HostSelector.swift +192 -0
- package/host/document-reader/Protocol.swift +647 -0
- package/host/document-reader/main.swift +22 -0
- package/host/omo-bridge/agent-aside-tool.ts +119 -0
- package/host/omo-bridge/agent-aside.ts +522 -0
- package/host/omo-bridge/agent-collaboration-files.ts +282 -0
- package/host/omo-bridge/agent-collaboration.ts +385 -0
- package/host/omo-bridge/agent-control.ts +400 -28
- package/host/omo-bridge/agent-execution.sb +42 -0
- package/host/omo-bridge/agent-execution.ts +158 -0
- package/host/omo-bridge/agent-tool-adapters.ts +388 -0
- package/host/omo-bridge/agent-tool-permissions.ts +96 -0
- package/host/omo-bridge/aside-tabs.ts +41 -0
- package/host/omo-bridge/attachment-read.ts +100 -0
- package/host/omo-bridge/bridge.ts +10 -0
- package/host/omo-bridge/managed-call.ts +46 -0
- package/host/omo-bridge/task-control.ts +39 -5
- package/host/omo-check/patches/attachments-293/README.md +138 -0
- package/host/omo-check/patches/attachments-293/document-content.js +234 -0
- package/host/omo-check/patches/attachments-293/document-content.types.txt +91 -0
- package/host/omo-check/patches/attachments-293/omo-5.1.51-documents.patch +831 -0
- package/host/omo-check/patches/attachments-293/omo-5.1.51-gateway-port.manifest.json +471 -0
- package/host/omo-check/patches/omo-5.1.51-agent-tool-authority.patch +471 -0
- package/host/omo-check/patches/omo-5.1.51-chatgpt-terra.patch +21 -0
- package/host/omo-check/patches/omo-5.1.51-gateway-port.manifest.json +44 -6
- package/host/omo-check/patches/omo-5.1.51-memory-receipts.patch +5 -0
- package/package.json +5 -3
- package/src/agent/api.ts +345 -20
- package/src/agent/artifacts.ts +196 -0
- package/src/agent/aside-target-routes.ts +369 -0
- package/src/agent/aside-targets.ts +215 -0
- package/src/agent/collaboration-admission.ts +47 -0
- package/src/agent/collaboration-read.ts +117 -0
- package/src/agent/collaboration-session.ts +196 -0
- package/src/agent/collaboration-types.ts +258 -0
- package/src/agent/collaboration.ts +399 -0
- package/src/agent/definition.ts +65 -3
- package/src/agent/fields.ts +9 -3
- package/src/agent/file-core.ts +325 -0
- package/src/agent/files.ts +87 -8
- package/src/agent/list.ts +3 -3
- package/src/agent/managed-call.ts +144 -0
- package/src/agent/tool-catalog.ts +184 -0
- package/src/agent/tool-delivery.ts +359 -0
- package/src/agent/tool-permission-types.ts +81 -0
- package/src/agent/tool-permissions.ts +1366 -0
- package/src/attachments/api.ts +161 -0
- package/src/attachments/document-reader.ts +325 -0
- package/src/attachments/host-selector.ts +410 -0
- package/src/attachments/managed-binding.ts +83 -0
- package/src/attachments/prepare.ts +214 -0
- package/src/attachments/selector-port.ts +28 -0
- package/src/attachments/service.ts +729 -0
- package/src/attachments/store.ts +698 -0
- package/src/attachments/types.ts +123 -0
- package/src/cli/doctor.ts +30 -10
- package/src/decisions.ts +109 -10
- package/src/engine/apply-patch.ts +21 -0
- package/src/engine/compat.json +2 -1
- package/src/engine/install.ts +45 -4
- package/src/engine/sandbox-cell.ts +2 -0
- package/src/gc.ts +33 -225
- package/src/housekeeping/api.ts +163 -0
- package/src/housekeeping/audit.ts +61 -0
- package/src/housekeeping/collectors/agent-collaboration.ts +33 -0
- package/src/housekeeping/collectors/agent-drafts.ts +43 -0
- package/src/housekeeping/collectors/agent-file-trash.ts +36 -0
- package/src/housekeeping/collectors/agent-trash.ts +28 -0
- package/src/housekeeping/collectors/attachment-cleanup.ts +35 -0
- package/src/housekeeping/collectors/db-backup.ts +25 -0
- package/src/housekeeping/collectors/docker-build-cache.ts +29 -0
- package/src/housekeeping/collectors/docker-image.ts +135 -0
- package/src/housekeeping/collectors/omo-version.ts +32 -0
- package/src/housekeeping/collectors/orphan-test-process.ts +205 -0
- package/src/housekeeping/collectors/reconcile-work.ts +55 -0
- package/src/housekeeping/collectors/rpc-daemon.ts +39 -0
- package/src/housekeeping/collectors/shared.ts +149 -0
- package/src/housekeeping/collectors/temp-dir.ts +28 -0
- package/src/housekeeping/collectors/update-file.ts +25 -0
- package/src/housekeeping/collectors/worktree.ts +193 -0
- package/src/housekeeping/engine.ts +125 -0
- package/src/housekeeping/reconcile/work.ts +130 -0
- package/src/housekeeping/registry.ts +63 -0
- package/src/housekeeping/trash.ts +59 -0
- package/src/housekeeping/types.ts +31 -0
- package/src/hub.ts +1095 -41
- package/src/main.ts +230 -31
- package/src/message-references.ts +14 -5
- package/src/profile-prompt.ts +13 -1
- package/src/profile.ts +13 -5
- package/src/protocol.ts +41 -6
- package/src/security-allow.ts +79 -2
- package/src/security-check.ts +1 -1
- package/src/store.ts +1946 -23
- package/src/test-crash.ts +20 -0
- package/src/usage.ts +18 -7
- package/src/work-watch.ts +55 -7
- package/src/work.ts +192 -3
- package/web/DESIGN.md +35 -1
- package/web/agents.d.ts +189 -12
- package/web/agents.js +898 -39
- package/web/app.js +2634 -210
- package/web/attachments.d.ts +91 -0
- package/web/attachments.js +422 -0
- package/web/collaboration.d.ts +87 -0
- package/web/collaboration.js +290 -0
- package/web/decisions.d.ts +39 -2
- package/web/decisions.js +354 -2
- package/web/gc.d.ts +26 -5
- package/web/gc.js +113 -13
- package/web/i18n/agents.d.ts +4 -0
- package/web/i18n/agents.js +200 -8
- package/web/i18n/attachments.js +78 -0
- package/web/i18n/decisions.d.ts +4 -0
- package/web/i18n/decisions.js +68 -0
- package/web/i18n/gc.d.ts +4 -0
- package/web/i18n/gc.js +184 -2
- package/web/i18n/main.js +80 -8
- package/web/i18n/tour.d.ts +4 -0
- package/web/i18n/tour.js +37 -0
- package/web/index.html +20 -3
- package/web/profile.d.ts +2 -2
- package/web/profile.js +16 -10
- package/web/style.css +342 -21
- package/web/tooltip.d.ts +2 -0
- package/web/tooltip.js +12 -0
- package/web/tour.d.ts +57 -0
- package/web/tour.js +450 -0
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { isAbsolute, relative, resolve, sep } from "node:path";
|
|
2
|
+
import type { InstallKind } from "../../runtime/install-kind.ts";
|
|
3
|
+
import type { TrashResult } from "../trash.ts";
|
|
4
|
+
import type { ActResult, Candidate, Collector, Trigger, Verdict } from "../types.ts";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Shared types, retention values and root checks of the six resource kinds of improvement 66 (src/gc.ts re-exports
|
|
8
|
+
* them). `gcCollector` turns one kind's rule into a housekeeping collector: `find` lists what the rule finds now, kept
|
|
9
|
+
* inside the kind's root and off protected folders; `judge` runs the rule again and acts only on a candidate it still
|
|
10
|
+
* finds; `act` moves a path to the Trash through `deps.remove` or sends SIGTERM to a daemon's pids.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export const GC_KINDS = ["db-backup", "omo-version", "update-file", "temp-dir", "rpc-daemon", "agent-trash", "agent-file-trash", "agent-drafts"] as const;
|
|
14
|
+
export type GcKind = (typeof GC_KINDS)[number];
|
|
15
|
+
export const DAY_MS = 24 * 60 * 60 * 1000;
|
|
16
|
+
export const KEEP_BACKUPS = 10;
|
|
17
|
+
export const BACKUP_KEEP_MS = 30 * DAY_MS;
|
|
18
|
+
export const UPDATE_MIN_AGE_MS = 7 * DAY_MS;
|
|
19
|
+
export const TEMP_MIN_AGE_MS = 7 * DAY_MS;
|
|
20
|
+
export const AGENT_TRASH_MIN_AGE_MS = 30 * DAY_MS;
|
|
21
|
+
|
|
22
|
+
/** Never a candidate, whatever folder it is found in. */
|
|
23
|
+
const PROTECTED_SEGMENTS = [`${sep}.omo${sep}evidence`, `${sep}secrets`];
|
|
24
|
+
|
|
25
|
+
export interface GcCandidate {
|
|
26
|
+
readonly id: string;
|
|
27
|
+
readonly kind: GcKind;
|
|
28
|
+
readonly target: string;
|
|
29
|
+
readonly reason: string;
|
|
30
|
+
readonly ageMs: number;
|
|
31
|
+
readonly bytes: number | null;
|
|
32
|
+
readonly pids?: readonly number[];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface GcEntry { readonly name: string; readonly type: "file" | "dir" | "link" | "other"; readonly mtimeMs: number }
|
|
36
|
+
export interface RpcUnit { readonly version: string; readonly verdict: string; readonly pids: readonly number[]; readonly ageMs: number; readonly rssKb: number }
|
|
37
|
+
|
|
38
|
+
export interface GcRoots {
|
|
39
|
+
readonly kind: InstallKind;
|
|
40
|
+
readonly home: string;
|
|
41
|
+
readonly backups: string;
|
|
42
|
+
readonly omo: string;
|
|
43
|
+
readonly update: string;
|
|
44
|
+
readonly tmp: string;
|
|
45
|
+
readonly agents: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export type MaybeAsync<T> = T | Promise<T>;
|
|
49
|
+
export interface GcDeps {
|
|
50
|
+
readonly now: () => number;
|
|
51
|
+
/** Entries of a folder, or null when it cannot be read. */
|
|
52
|
+
readonly list: (dir: string) => GcEntry[] | null;
|
|
53
|
+
readonly size: (path: string) => MaybeAsync<number | null>;
|
|
54
|
+
readonly readlink: (path: string) => string | null;
|
|
55
|
+
readonly readText: (path: string) => string | null;
|
|
56
|
+
/** Command lines of every process, or null when ps failed. */
|
|
57
|
+
readonly commands: () => MaybeAsync<readonly string[] | null>;
|
|
58
|
+
/** Whether a process holds a file under `path`; null when lsof failed. */
|
|
59
|
+
readonly openBy: (path: string) => MaybeAsync<boolean | null>;
|
|
60
|
+
readonly rpcUnits: () => MaybeAsync<readonly RpcUnit[] | null>;
|
|
61
|
+
/** Moves `path` away (throws on failure); the trash result, when returned, names the method used. */
|
|
62
|
+
readonly remove: (path: string, kind: GcKind) => MaybeAsync<TrashResult | void>;
|
|
63
|
+
readonly terminate: (pid: number) => void;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface GcContext { readonly roots: GcRoots; readonly deps: GcDeps }
|
|
67
|
+
|
|
68
|
+
export function inside(root: string, path: string): boolean {
|
|
69
|
+
const rel = relative(resolve(root), resolve(path));
|
|
70
|
+
return rel !== "" && !rel.startsWith("..") && !isAbsolute(rel);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function guarded(root: string, path: string): boolean {
|
|
74
|
+
const full = resolve(path);
|
|
75
|
+
return inside(root, full) && !PROTECTED_SEGMENTS.some((segment) => full.includes(segment));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function linkTarget(root: string, link: string, deps: Pick<GcDeps, "readlink">): string | null {
|
|
79
|
+
const target = deps.readlink(link);
|
|
80
|
+
if (target === null) return null;
|
|
81
|
+
const name = relative(root, resolve(root, target));
|
|
82
|
+
if (name === "" || name.startsWith("..") || name.includes(sep)) return null;
|
|
83
|
+
return name;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export async function sized(found: readonly GcCandidate[], deps: GcDeps): Promise<GcCandidate[]> {
|
|
87
|
+
const out: GcCandidate[] = [];
|
|
88
|
+
for (const candidate of found) out.push(candidate.bytes === null ? { ...candidate, bytes: await deps.size(candidate.target) } : candidate);
|
|
89
|
+
return out;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function toCandidate(c: GcCandidate): Candidate {
|
|
93
|
+
const out: Candidate = { id: c.id, collector: c.kind, target: c.target, reason: c.reason, ageMs: c.ageMs, bytes: c.bytes };
|
|
94
|
+
if (c.pids !== undefined) out.details = { pids: [...c.pids] };
|
|
95
|
+
return out;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export interface GcRule {
|
|
99
|
+
readonly kind: GcKind;
|
|
100
|
+
/** A checkout (this Mac's Docker gateway) cannot see host processes and folders, so only db-backup runs there. */
|
|
101
|
+
readonly npmOnly: boolean;
|
|
102
|
+
/** The folder every candidate path must stay inside; null for daemons, which are processes, not paths. */
|
|
103
|
+
readonly root: ((roots: GcRoots) => string) | null;
|
|
104
|
+
readonly candidates: (roots: GcRoots, deps: GcDeps) => MaybeAsync<GcCandidate[]>;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export interface GcCollector extends Collector<GcContext> {
|
|
108
|
+
/** Read only: the rule's candidates now, unsized, each path kept inside its root. */
|
|
109
|
+
listed(ctx: GcContext): Promise<GcCandidate[]>;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const TRIGGERS: readonly Trigger[] = ["start", "update_end", "web_open", "doctor"];
|
|
113
|
+
|
|
114
|
+
export function gcCollector(rule: GcRule): GcCollector {
|
|
115
|
+
const daemon = rule.root === null;
|
|
116
|
+
const listed = async ({ roots, deps }: GcContext): Promise<GcCandidate[]> => {
|
|
117
|
+
if (rule.npmOnly && roots.kind !== "npm") return [];
|
|
118
|
+
const found = await rule.candidates(roots, deps);
|
|
119
|
+
const root = rule.root;
|
|
120
|
+
return root === null ? found : found.filter((c) => guarded(root(roots), c.target));
|
|
121
|
+
};
|
|
122
|
+
return {
|
|
123
|
+
name: rule.kind,
|
|
124
|
+
role: "gc",
|
|
125
|
+
triggers: TRIGGERS,
|
|
126
|
+
confirmation: daemon ? "auto" : "operator",
|
|
127
|
+
reversibility: daemon ? "process_none" : "trash",
|
|
128
|
+
listed,
|
|
129
|
+
async find(ctx) {
|
|
130
|
+
return (await sized(await listed(ctx), ctx.deps)).map(toCandidate);
|
|
131
|
+
},
|
|
132
|
+
async judge(c, ctx): Promise<Verdict> {
|
|
133
|
+
const now = (await listed(ctx)).find((found) => found.id === c.id);
|
|
134
|
+
if (now === undefined) return { kind: "keep", why: "no_longer_a_candidate" };
|
|
135
|
+
const evidence: Record<string, unknown> = { rule: rule.kind, reason: now.reason, ageMs: now.ageMs };
|
|
136
|
+
if (now.pids !== undefined) evidence.pids = [...now.pids];
|
|
137
|
+
return { kind: "act", why: "rule_holds", evidence };
|
|
138
|
+
},
|
|
139
|
+
async act(c, { deps }): Promise<ActResult> {
|
|
140
|
+
if (daemon) {
|
|
141
|
+
const pids = c.details?.pids;
|
|
142
|
+
if (!Array.isArray(pids) || pids.length === 0) return { ok: false, method: "sigterm", error: "no_pids" };
|
|
143
|
+
for (const pid of pids as number[]) deps.terminate(pid);
|
|
144
|
+
return { ok: true, method: "sigterm" };
|
|
145
|
+
}
|
|
146
|
+
return (await deps.remove(c.target, rule.kind)) ?? { ok: true, method: "trash" };
|
|
147
|
+
},
|
|
148
|
+
};
|
|
149
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { say, type Text } from "../../server-text.ts";
|
|
3
|
+
import { DAY_MS, type GcCandidate, gcCollector, type GcDeps, type GcRoots, TEMP_MIN_AGE_MS } from "./shared.ts";
|
|
4
|
+
|
|
5
|
+
/** `zgw-*` or `zigmini-*` folder in $TMPDIR, older than TEMP_MIN_AGE_MS, named by no process, opened by none. */
|
|
6
|
+
|
|
7
|
+
const REASON_TEMP: Text = { en: "Older than {days} days and no process uses it", ko: "{days}일 지났고 쓰는 프로세스 없음" };
|
|
8
|
+
const TEMP_NAME = /^(?:zgw-|zigmini-)/;
|
|
9
|
+
|
|
10
|
+
export async function tempCandidates(roots: GcRoots, deps: GcDeps): Promise<GcCandidate[]> {
|
|
11
|
+
const entries = deps.list(roots.tmp);
|
|
12
|
+
if (entries === null) return [];
|
|
13
|
+
const now = deps.now();
|
|
14
|
+
const old = entries.filter((entry) => entry.type === "dir" && TEMP_NAME.test(entry.name) && now - entry.mtimeMs > TEMP_MIN_AGE_MS);
|
|
15
|
+
if (old.length === 0) return [];
|
|
16
|
+
const commands = await deps.commands();
|
|
17
|
+
if (commands === null) return [];
|
|
18
|
+
const out: GcCandidate[] = [];
|
|
19
|
+
for (const entry of old) {
|
|
20
|
+
const target = join(roots.tmp, entry.name);
|
|
21
|
+
if (commands.some((command) => command.includes(target))) continue;
|
|
22
|
+
if (await deps.openBy(target) !== false) continue;
|
|
23
|
+
out.push({ id: `temp-dir:${target}`, kind: "temp-dir", target, reason: say(REASON_TEMP, { days: TEMP_MIN_AGE_MS / DAY_MS }), ageMs: now - entry.mtimeMs, bytes: null });
|
|
24
|
+
}
|
|
25
|
+
return out;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export const tempDir = gcCollector({ kind: "temp-dir", npmOnly: true, root: (roots) => roots.tmp, candidates: tempCandidates });
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { say, type Text } from "../../server-text.ts";
|
|
3
|
+
import { DAY_MS, type GcCandidate, gcCollector, type GcDeps, type GcRoots, UPDATE_MIN_AGE_MS } from "./shared.ts";
|
|
4
|
+
|
|
5
|
+
/** `*.log` or `request*.json` file in <home>/update/, older than UPDATE_MIN_AGE_MS. */
|
|
6
|
+
|
|
7
|
+
const REASON_UPDATE: Text = { en: "Update log or request file older than {days} days", ko: "{days}일 지난 업데이트 로그·요청 파일" };
|
|
8
|
+
const UPDATE_NAME = /^(?:.+\.log|request.*\.json)$/;
|
|
9
|
+
|
|
10
|
+
export function updateCandidates(roots: GcRoots, deps: GcDeps): GcCandidate[] {
|
|
11
|
+
const entries = deps.list(roots.update);
|
|
12
|
+
if (entries === null) return [];
|
|
13
|
+
const now = deps.now();
|
|
14
|
+
const out: GcCandidate[] = [];
|
|
15
|
+
for (const entry of entries) {
|
|
16
|
+
if (entry.type !== "file" || !UPDATE_NAME.test(entry.name)) continue;
|
|
17
|
+
const ageMs = now - entry.mtimeMs;
|
|
18
|
+
if (ageMs <= UPDATE_MIN_AGE_MS) continue;
|
|
19
|
+
const target = join(roots.update, entry.name);
|
|
20
|
+
out.push({ id: `update-file:${target}`, kind: "update-file", target, reason: say(REASON_UPDATE, { days: UPDATE_MIN_AGE_MS / DAY_MS }), ageMs, bytes: null });
|
|
21
|
+
}
|
|
22
|
+
return out;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export const updateFile = gcCollector({ kind: "update-file", npmOnly: true, root: (roots) => roots.update, candidates: updateCandidates });
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { realpathSync, statSync } from "node:fs";
|
|
2
|
+
import { isAbsolute, relative, resolve, sep } from "node:path";
|
|
3
|
+
import { say, type Text } from "../../server-text.ts";
|
|
4
|
+
import { moveToTrash, type Trash } from "../trash.ts";
|
|
5
|
+
import type { ActResult, Candidate, Collector, Verdict } from "../types.ts";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Worktree collector (plan housekeeping todo 6, improvement 66 row "worktree"). Repos are the distinct cwd values of works
|
|
9
|
+
* in the store. A linked worktree is offered when its branch (or detached commit) is merged into `dev`, it has no
|
|
10
|
+
* uncommitted or untracked change, no ignored file under `.omo/evidence` or `secrets`, no process uses it, no unfinished
|
|
11
|
+
* work runs inside it, and its folder is older than 7 days. Act moves the folder to the Trash and runs
|
|
12
|
+
* `git worktree prune`; it never runs `git worktree remove` and never deletes a branch. Any git failure keeps.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export const WORKTREE_MIN_AGE_MS = 7 * 24 * 60 * 60 * 1000;
|
|
16
|
+
const MERGE_TARGET = "dev";
|
|
17
|
+
const REASON: Text = { en: "Merged into dev, no changes, unused, older than {days} days", ko: "dev에 병합됨, 변경 없음, 쓰는 곳 없음, {days}일 지남" };
|
|
18
|
+
|
|
19
|
+
export interface GitRun { readonly code: number; readonly stdout: string }
|
|
20
|
+
export interface WorktreeDeps {
|
|
21
|
+
readonly now: () => number;
|
|
22
|
+
/** Distinct cwd values of works in the store, read in-process. */
|
|
23
|
+
readonly workRepos: () => readonly string[];
|
|
24
|
+
readonly unfinishedWorkCwds: () => readonly string[];
|
|
25
|
+
readonly git: (args: readonly string[]) => Promise<GitRun>;
|
|
26
|
+
readonly mtimeMs: (path: string) => number | null;
|
|
27
|
+
/** Whether a process has its cwd, an open file or an argument inside `path`; null when lsof or ps failed. */
|
|
28
|
+
readonly usedByProcess: (path: string) => Promise<boolean | null>;
|
|
29
|
+
readonly trash: Trash;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
interface Entry { readonly path: string; readonly head: string | null; readonly branch: string | null; readonly bare: boolean; readonly locked: boolean; readonly prunable: boolean }
|
|
33
|
+
|
|
34
|
+
function real(path: string): string {
|
|
35
|
+
try {
|
|
36
|
+
return realpathSync(path);
|
|
37
|
+
} catch {
|
|
38
|
+
return resolve(path);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function within(root: string, path: string): boolean {
|
|
43
|
+
const rel = relative(root, path);
|
|
44
|
+
return rel === "" || (rel.split(sep)[0] !== ".." && !isAbsolute(rel));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function parse(porcelain: string): Entry[] {
|
|
48
|
+
return porcelain
|
|
49
|
+
.split("\n\n")
|
|
50
|
+
.map((block) => block.split("\n").filter((line) => line !== ""))
|
|
51
|
+
.filter((lines) => lines[0]?.startsWith("worktree ") === true)
|
|
52
|
+
.map((lines) => {
|
|
53
|
+
const value = (key: string) => lines.find((line) => line === key || line.startsWith(`${key} `))?.slice(key.length + 1) ?? null;
|
|
54
|
+
const has = (key: string) => lines.some((line) => line === key || line.startsWith(`${key} `));
|
|
55
|
+
return { path: lines[0]!.slice("worktree ".length), head: value("HEAD"), branch: value("branch"), bare: has("bare"), locked: has("locked"), prunable: has("prunable") };
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** The main worktree path and the linked worktrees of the repo that contains `cwd`, or null when git fails. */
|
|
60
|
+
async function worktrees(cwd: string, deps: WorktreeDeps): Promise<{ main: string; linked: Entry[] } | null> {
|
|
61
|
+
const run = await deps.git(["-C", cwd, "worktree", "list", "--porcelain"]);
|
|
62
|
+
if (run.code !== 0) return null;
|
|
63
|
+
const [main, ...linked] = parse(run.stdout);
|
|
64
|
+
if (main === undefined) return null;
|
|
65
|
+
return { main: main.path, linked: linked.filter((entry) => !entry.bare && !entry.prunable) };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function keep(why: string): Verdict {
|
|
69
|
+
return { kind: "keep", why };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async function judge(c: Candidate, deps: WorktreeDeps): Promise<Verdict> {
|
|
73
|
+
const repo = c.details?.repo;
|
|
74
|
+
if (typeof repo !== "string") return keep("git_failed");
|
|
75
|
+
const list = await worktrees(repo, deps);
|
|
76
|
+
if (list === null) return keep("git_failed");
|
|
77
|
+
const entry = list.linked.find((e) => e.path === c.target);
|
|
78
|
+
if (entry === undefined) return keep("not_a_worktree");
|
|
79
|
+
if (entry.locked) return keep("locked");
|
|
80
|
+
const mtime = deps.mtimeMs(entry.path);
|
|
81
|
+
if (mtime === null) return keep("stat_failed");
|
|
82
|
+
const ageMs = deps.now() - mtime;
|
|
83
|
+
if (ageMs <= WORKTREE_MIN_AGE_MS) return keep("younger_than_7_days");
|
|
84
|
+
const wt = real(entry.path);
|
|
85
|
+
if (deps.unfinishedWorkCwds().some((cwd) => within(wt, real(cwd)))) return keep("unfinished_work_inside");
|
|
86
|
+
const rev = entry.branch ?? entry.head;
|
|
87
|
+
if (rev === null) return keep("git_failed");
|
|
88
|
+
const merged = await deps.git(["-C", repo, "merge-base", "--is-ancestor", rev, MERGE_TARGET]);
|
|
89
|
+
if (merged.code === 1) return keep("not_merged_into_dev");
|
|
90
|
+
if (merged.code !== 0) return keep("git_failed");
|
|
91
|
+
const status = await deps.git(["-C", entry.path, "status", "--porcelain", "--untracked-files=all"]);
|
|
92
|
+
if (status.code !== 0) return keep("git_failed");
|
|
93
|
+
if (status.stdout.trim() !== "") return keep("uncommitted_changes");
|
|
94
|
+
const kept = await deps.git(["-C", entry.path, "status", "--porcelain", "--ignored", "--untracked-files=all", "--", ".omo/evidence", "secrets"]);
|
|
95
|
+
if (kept.code !== 0) return keep("git_failed");
|
|
96
|
+
if (kept.stdout.trim() !== "") return keep("evidence_or_secrets_present");
|
|
97
|
+
const used = await deps.usedByProcess(entry.path);
|
|
98
|
+
if (used === null) return keep("process_check_failed");
|
|
99
|
+
if (used) return keep("in_use_by_process");
|
|
100
|
+
return { kind: "act", why: "merged_clean_unused_old", evidence: { repo, branch: entry.branch, head: entry.head, mergedInto: MERGE_TARGET, ageMs } };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export const worktree: Collector<WorktreeDeps> = {
|
|
104
|
+
name: "worktree",
|
|
105
|
+
role: "gc",
|
|
106
|
+
triggers: ["web_open", "doctor"],
|
|
107
|
+
confirmation: "operator",
|
|
108
|
+
reversibility: "trash",
|
|
109
|
+
|
|
110
|
+
async find(deps) {
|
|
111
|
+
const seen = new Set<string>();
|
|
112
|
+
const out: Candidate[] = [];
|
|
113
|
+
for (const cwd of deps.workRepos()) {
|
|
114
|
+
const list = await worktrees(cwd, deps);
|
|
115
|
+
if (list === null || seen.has(list.main)) continue;
|
|
116
|
+
seen.add(list.main);
|
|
117
|
+
for (const entry of list.linked) {
|
|
118
|
+
const mtime = deps.mtimeMs(entry.path);
|
|
119
|
+
out.push({
|
|
120
|
+
id: `worktree:${entry.path}`,
|
|
121
|
+
collector: "worktree",
|
|
122
|
+
target: entry.path,
|
|
123
|
+
reason: say(REASON, { days: WORKTREE_MIN_AGE_MS / (24 * 60 * 60 * 1000) }),
|
|
124
|
+
ageMs: mtime === null ? null : deps.now() - mtime,
|
|
125
|
+
bytes: null,
|
|
126
|
+
details: { repo: list.main, branch: entry.branch, head: entry.head },
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return out;
|
|
131
|
+
},
|
|
132
|
+
|
|
133
|
+
judge,
|
|
134
|
+
|
|
135
|
+
async act(c, deps): Promise<ActResult> {
|
|
136
|
+
const verdict = await judge(c, deps);
|
|
137
|
+
if (verdict.kind !== "act") return { ok: false, method: "trash", error: `kept:${verdict.why}` };
|
|
138
|
+
const moved = await deps.trash(c.target, "worktree");
|
|
139
|
+
if (!moved.ok) return { ok: false, method: "trash", error: moved.error ?? "trash_failed" };
|
|
140
|
+
const prune = await deps.git(["-C", String(c.details?.repo), "worktree", "prune"]);
|
|
141
|
+
if (prune.code !== 0) return { ok: false, method: "git_prune", error: "git_prune_failed" };
|
|
142
|
+
return { ok: true, method: "trash" };
|
|
143
|
+
},
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
async function output(argv: readonly string[], env?: Record<string, string | undefined>): Promise<{ code: number; out: string }> {
|
|
147
|
+
try {
|
|
148
|
+
const child = Bun.spawn([...argv], { env, stdin: "ignore", stdout: "pipe", stderr: "ignore" });
|
|
149
|
+
const [out, code] = await Promise.all([new Response(child.stdout).text(), child.exited]);
|
|
150
|
+
return { code, out };
|
|
151
|
+
} catch {
|
|
152
|
+
return { code: -1, out: "" };
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function namedIn(line: string, path: string): boolean {
|
|
157
|
+
for (let at = line.indexOf(path); at !== -1; at = line.indexOf(path, at + 1)) {
|
|
158
|
+
const next = line[at + path.length];
|
|
159
|
+
if (next === undefined || next === sep || /\s/.test(next)) return true;
|
|
160
|
+
}
|
|
161
|
+
return false;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export function systemWorktreeDeps(options: { workRepos: () => readonly string[]; unfinishedWorkCwds: () => readonly string[]; trash?: Trash }): WorktreeDeps {
|
|
165
|
+
// GIT_OPTIONAL_LOCKS=0 keeps `git status` from refreshing the index, so find and judge stay read only.
|
|
166
|
+
const gitEnv = { ...process.env, GIT_OPTIONAL_LOCKS: "0" };
|
|
167
|
+
return {
|
|
168
|
+
now: Date.now,
|
|
169
|
+
workRepos: options.workRepos,
|
|
170
|
+
unfinishedWorkCwds: options.unfinishedWorkCwds,
|
|
171
|
+
git: async (args) => {
|
|
172
|
+
const run = await output(["git", ...args], gitEnv);
|
|
173
|
+
return { code: run.code, stdout: run.out };
|
|
174
|
+
},
|
|
175
|
+
mtimeMs: (path) => {
|
|
176
|
+
try {
|
|
177
|
+
return statSync(path).mtimeMs;
|
|
178
|
+
} catch {
|
|
179
|
+
return null;
|
|
180
|
+
}
|
|
181
|
+
},
|
|
182
|
+
usedByProcess: async (path) => {
|
|
183
|
+
// lsof exits 1 with no output when nothing (cwd or open file) is under the folder.
|
|
184
|
+
const lsof = await output(["lsof", "-t", "+D", path]);
|
|
185
|
+
if (lsof.out.trim() !== "") return true;
|
|
186
|
+
if (lsof.code !== 1) return null;
|
|
187
|
+
const ps = await output(["ps", "-axww", "-o", "command="]);
|
|
188
|
+
if (ps.code !== 0) return null;
|
|
189
|
+
return ps.out.split("\n").some((line) => namedIn(line, path));
|
|
190
|
+
},
|
|
191
|
+
trash: options.trash ?? moveToTrash,
|
|
192
|
+
};
|
|
193
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import type { AuditActor, AuditLine, AuditSink } from "./audit.ts";
|
|
2
|
+
import type { ActResult, BoundCollector, Candidate, Confirmation, Reversibility, Role, Trigger, Verdict } from "./types.ts";
|
|
3
|
+
|
|
4
|
+
/** An act that threw has no method. */
|
|
5
|
+
export type ActOutcome = ActResult | { ok: false; method: null; error: string };
|
|
6
|
+
export interface ScanItem { candidate: Candidate; verdict: Verdict; result?: ActOutcome }
|
|
7
|
+
export interface CollectorReport {
|
|
8
|
+
name: string;
|
|
9
|
+
role: Role;
|
|
10
|
+
confirmation: Confirmation;
|
|
11
|
+
reversibility: Reversibility;
|
|
12
|
+
scannedAt: string | null;
|
|
13
|
+
trigger: Trigger | null;
|
|
14
|
+
items: ScanItem[];
|
|
15
|
+
error: string | null;
|
|
16
|
+
}
|
|
17
|
+
export interface Selection { ids?: readonly string[]; collectors?: readonly string[] }
|
|
18
|
+
export interface CleanResult {
|
|
19
|
+
cleaned: Candidate[];
|
|
20
|
+
failed: { id: string; error: string }[];
|
|
21
|
+
kept: { id: string; why: string }[];
|
|
22
|
+
missing: string[];
|
|
23
|
+
}
|
|
24
|
+
export interface Housekeeping {
|
|
25
|
+
scan(trigger: Trigger): Promise<CollectorReport[]>;
|
|
26
|
+
clean(selection: Selection, actor: Exclude<AuditActor, "auto">): Promise<CleanResult>;
|
|
27
|
+
report(): CollectorReport[];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function message(error: unknown): string {
|
|
31
|
+
return error instanceof Error ? error.message : String(error);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function createHousekeeping(options: { collectors: readonly BoundCollector[]; audit: AuditSink; now: () => number }): Housekeeping {
|
|
35
|
+
const { collectors, audit, now } = options;
|
|
36
|
+
const seen = new Set<string>();
|
|
37
|
+
for (const c of collectors) {
|
|
38
|
+
if (seen.has(c.name)) throw new Error(`duplicate_collector:${c.name}`);
|
|
39
|
+
seen.add(c.name);
|
|
40
|
+
}
|
|
41
|
+
const latest = new Map<string, CollectorReport>();
|
|
42
|
+
const at = () => new Date(now()).toISOString();
|
|
43
|
+
|
|
44
|
+
const empty = (c: BoundCollector): CollectorReport => ({ name: c.name, role: c.role, confirmation: c.confirmation, reversibility: c.reversibility, scannedAt: null, trigger: null, items: [], error: null });
|
|
45
|
+
|
|
46
|
+
/** find + judge; a throw yields no items and one audit error line. */
|
|
47
|
+
async function survey(c: BoundCollector, trigger: Trigger | null, actor: AuditActor): Promise<{ items: ScanItem[]; error: string | null }> {
|
|
48
|
+
try {
|
|
49
|
+
const items: ScanItem[] = [];
|
|
50
|
+
for (const candidate of await c.find()) items.push({ candidate, verdict: await c.judge(candidate) });
|
|
51
|
+
return { items, error: null };
|
|
52
|
+
} catch (error) {
|
|
53
|
+
const text = message(error);
|
|
54
|
+
audit.append({ at: at(), trigger, actor, collector: c.name, id: null, target: null, verdict: "error", evidence: null, method: null, ok: false, error: text });
|
|
55
|
+
return { items: [], error: text };
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Acts on an `act` verdict and writes exactly one audit line, whatever the outcome. */
|
|
60
|
+
async function perform(c: BoundCollector, item: ScanItem & { verdict: { kind: "act" } }, trigger: Trigger | null, actor: AuditActor): Promise<ActOutcome> {
|
|
61
|
+
let result: ActOutcome;
|
|
62
|
+
try {
|
|
63
|
+
result = await c.act(item.candidate);
|
|
64
|
+
} catch (error) {
|
|
65
|
+
result = { ok: false, method: null, error: message(error) };
|
|
66
|
+
}
|
|
67
|
+
const line: AuditLine = { at: at(), trigger, actor, collector: c.name, id: item.candidate.id, target: item.candidate.target, verdict: "act", evidence: item.verdict.evidence, method: result.method, ok: result.ok };
|
|
68
|
+
if (result.error !== undefined) line.error = result.error;
|
|
69
|
+
audit.append(line);
|
|
70
|
+
return result;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return {
|
|
74
|
+
async scan(trigger) {
|
|
75
|
+
const out: CollectorReport[] = [];
|
|
76
|
+
for (const c of collectors) {
|
|
77
|
+
if (!c.triggers.includes(trigger)) continue;
|
|
78
|
+
const { items, error } = await survey(c, trigger, "auto");
|
|
79
|
+
if (c.confirmation === "auto") {
|
|
80
|
+
for (const item of items) {
|
|
81
|
+
if (item.verdict.kind !== "act") continue;
|
|
82
|
+
item.result = await perform(c, item as ScanItem & { verdict: { kind: "act" } }, trigger, "auto");
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
const entry: CollectorReport = { ...empty(c), scannedAt: at(), trigger, items, error };
|
|
86
|
+
latest.set(c.name, entry);
|
|
87
|
+
out.push(entry);
|
|
88
|
+
}
|
|
89
|
+
return out;
|
|
90
|
+
},
|
|
91
|
+
|
|
92
|
+
async clean(selection, actor) {
|
|
93
|
+
const ids = selection.ids ?? [];
|
|
94
|
+
const names = selection.collectors ?? [];
|
|
95
|
+
const result: CleanResult = { cleaned: [], failed: [], kept: [], missing: [] };
|
|
96
|
+
const found = new Set<string>();
|
|
97
|
+
for (const c of collectors) {
|
|
98
|
+
const { items } = await survey(c, null, actor);
|
|
99
|
+
for (const item of items) {
|
|
100
|
+
if (!ids.includes(item.candidate.id) && !names.includes(c.name)) continue;
|
|
101
|
+
found.add(item.candidate.id);
|
|
102
|
+
if (item.verdict.kind !== "act") {
|
|
103
|
+
result.kept.push({ id: item.candidate.id, why: item.verdict.why });
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
if (c.confirmation === "operator_via_main" && actor === "operator") {
|
|
107
|
+
const error = "confirm_through_main";
|
|
108
|
+
audit.append({ at: at(), trigger: null, actor, collector: c.name, id: item.candidate.id, target: item.candidate.target, verdict: "act", evidence: item.verdict.evidence, method: null, ok: false, error });
|
|
109
|
+
result.failed.push({ id: item.candidate.id, error });
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
const done = await perform(c, item as ScanItem & { verdict: { kind: "act" } }, null, actor);
|
|
113
|
+
if (done.ok) result.cleaned.push(item.candidate);
|
|
114
|
+
else result.failed.push({ id: item.candidate.id, error: done.error ?? "act_failed" });
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
result.missing = ids.filter((id) => !found.has(id));
|
|
118
|
+
return result;
|
|
119
|
+
},
|
|
120
|
+
|
|
121
|
+
report() {
|
|
122
|
+
return collectors.map((c) => latest.get(c.name) ?? empty(c));
|
|
123
|
+
},
|
|
124
|
+
};
|
|
125
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import type { WorkCommand, WorkStatus } from "../../store.ts";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Evidence-only reconcile classifier for unfinished works (bug 282, .omo/plans/submitted-work-gc.md "State transition
|
|
5
|
+
* table", renamed by .omo/plans/housekeeping.md "Names"). Pure: no clock, no store, no OMO client. Timestamps are
|
|
6
|
+
* compared only with each other, never with the current time, so no age can ever decide a closure.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export type ReconcileReason = "reconcile_cancel_unreachable" | "reconcile_session_closed";
|
|
10
|
+
export type ReconcileConfirmReason = "reconcile_confirm_undelivered" | "reconcile_confirm_no_report" | "reconcile_confirm_unreachable";
|
|
11
|
+
|
|
12
|
+
export type ReconcileSession =
|
|
13
|
+
| { readonly kind: "readable"; readonly isStreaming: boolean; readonly stateReadAt: string }
|
|
14
|
+
| { readonly kind: "refused"; readonly retryable: boolean; readonly error: string }
|
|
15
|
+
| { readonly kind: "unread" };
|
|
16
|
+
|
|
17
|
+
export interface ReconcileCommand extends Pick<WorkCommand, "id" | "seq" | "kind" | "delivery" | "error" | "createdAt" | "acceptedAt"> {
|
|
18
|
+
/** The admission of this command failed in the current OMO generation. */
|
|
19
|
+
readonly admissionFailed: boolean;
|
|
20
|
+
/** Found in the session queue or user entries; null when the session was not read. */
|
|
21
|
+
readonly inSession: boolean | null;
|
|
22
|
+
/** The work reported on this command's seq. */
|
|
23
|
+
readonly reported: boolean;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface ReconcileEvidence {
|
|
27
|
+
readonly status: WorkStatus;
|
|
28
|
+
readonly latest: ReconcileCommand | null;
|
|
29
|
+
/** The earlier `unknown` command a pending latest command waits behind (src/work.ts transmit), if any. */
|
|
30
|
+
readonly blockedBy: (Pick<WorkCommand, "id" | "seq" | "delivery"> & { readonly inSession: boolean | null }) | null;
|
|
31
|
+
readonly omoReady: boolean;
|
|
32
|
+
/** True only when this generation's dispatch for the work has settled. */
|
|
33
|
+
readonly evaluatedAfterDispatch: boolean;
|
|
34
|
+
readonly session: ReconcileSession;
|
|
35
|
+
/** A session_closed binding event of this generation (synthetic generation closes are never passed in). */
|
|
36
|
+
readonly lifecycle: { readonly kind: "closed"; readonly reason: string; readonly at: string } | null;
|
|
37
|
+
/** Active child tasks; null when unknown (e.g. right after a restart). */
|
|
38
|
+
readonly children: number | null;
|
|
39
|
+
readonly openDecisions: number;
|
|
40
|
+
readonly inFlight: boolean;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export type ReconcileVerdict =
|
|
44
|
+
| { readonly kind: "keep"; readonly why: string }
|
|
45
|
+
| { readonly kind: "close"; readonly status: "failed" | "cancelled"; readonly reason: ReconcileReason; readonly evidence: Record<string, unknown> }
|
|
46
|
+
| { readonly kind: "confirm"; readonly reason: ReconcileConfirmReason; readonly evidence: Record<string, unknown> };
|
|
47
|
+
|
|
48
|
+
const RETRYABLE_CODES: ReadonlySet<string> = new Set(["host_memory_pressure", "session_closing"]);
|
|
49
|
+
const NOT_LOSS: ReadonlySet<string> = new Set(["handoff_parked", "generation_ended"]);
|
|
50
|
+
const SETTLED: ReadonlySet<WorkStatus> = new Set(["completed", "failed", "cancelled", "waiting_input"]);
|
|
51
|
+
|
|
52
|
+
/** A refusal the existing retry paths own: it carries retry_after_ms or a known transient code. */
|
|
53
|
+
export function retryableRefusal(code: string | null, retryAfterMs: number | null): boolean {
|
|
54
|
+
return retryAfterMs !== null || (code !== null && RETRYABLE_CODES.has(code));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const at = (iso: string) => Date.parse(iso);
|
|
58
|
+
|
|
59
|
+
export function classify(e: ReconcileEvidence): ReconcileVerdict {
|
|
60
|
+
const latest = e.latest;
|
|
61
|
+
const closedAt = closeEvent(e, latest);
|
|
62
|
+
const state = e.session.kind === "readable" && (closedAt === null || at(e.session.stateReadAt) >= closedAt) ? e.session : null;
|
|
63
|
+
|
|
64
|
+
if (!e.omoReady) return keep("omo_not_ready");
|
|
65
|
+
if (!e.evaluatedAfterDispatch) return keep("dispatch_not_settled");
|
|
66
|
+
if (state?.isStreaming === true) return keep("streaming");
|
|
67
|
+
if (e.children !== null && e.children > 0) return keep("children_active");
|
|
68
|
+
if (e.openDecisions > 0) return keep("open_decisions");
|
|
69
|
+
if (e.inFlight) return keep("dispatch_in_flight");
|
|
70
|
+
if (SETTLED.has(e.status)) return keep("status_settled");
|
|
71
|
+
if (e.session.kind === "refused" && e.session.retryable) return keep("retryable_refusal");
|
|
72
|
+
if (latest === null) return keep("no_command");
|
|
73
|
+
|
|
74
|
+
const row = pick(e, latest, closedAt, state);
|
|
75
|
+
if (row === null) return keep("no_row");
|
|
76
|
+
if (e.children === null && row.name !== "A1" && row.name !== "A4") return keep("children_unknown");
|
|
77
|
+
|
|
78
|
+
const evidence: Record<string, unknown> = {
|
|
79
|
+
row: row.name,
|
|
80
|
+
commandId: latest.id,
|
|
81
|
+
commandSeq: latest.seq,
|
|
82
|
+
kind: latest.kind,
|
|
83
|
+
delivery: latest.delivery,
|
|
84
|
+
error: latest.error,
|
|
85
|
+
session: e.session,
|
|
86
|
+
lifecycle: e.lifecycle,
|
|
87
|
+
...(row.name === "A4" ? { blockedBy: e.blockedBy } : {}),
|
|
88
|
+
};
|
|
89
|
+
return row.verdict === "close"
|
|
90
|
+
? { kind: "close", status: row.status, reason: row.reason, evidence }
|
|
91
|
+
: { kind: "confirm", reason: row.reason, evidence };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
type Row =
|
|
95
|
+
| { readonly name: "D1" | "D2"; readonly verdict: "close"; readonly status: "failed" | "cancelled"; readonly reason: ReconcileReason }
|
|
96
|
+
| { readonly name: "A1" | "A2" | "A3" | "A4"; readonly verdict: "confirm"; readonly reason: ReconcileConfirmReason };
|
|
97
|
+
|
|
98
|
+
function pick(e: ReconcileEvidence, latest: ReconcileCommand, closedAt: number | null, state: Extract<ReconcileSession, { kind: "readable" }> | null): Row | null {
|
|
99
|
+
const closed = closedAt !== null;
|
|
100
|
+
const refusedHard = e.session.kind === "refused" && !e.session.retryable;
|
|
101
|
+
const idle = state !== null && !state.isStreaming && at(state.stateReadAt) > at(latest.acceptedAt ?? latest.createdAt);
|
|
102
|
+
const readAfterCreated = state !== null && at(state.stateReadAt) >= at(latest.createdAt);
|
|
103
|
+
const absent = (inSession: boolean | null) => inSession === false && readAfterCreated;
|
|
104
|
+
|
|
105
|
+
if (latest.kind === "cancel" && latest.delivery !== "accepted" && (idle || refusedHard || closed))
|
|
106
|
+
return { name: "D1", verdict: "close", status: "cancelled", reason: "reconcile_cancel_unreachable" };
|
|
107
|
+
if (closed && !latest.reported)
|
|
108
|
+
return latest.kind === "cancel"
|
|
109
|
+
? { name: "D2", verdict: "close", status: "cancelled", reason: "reconcile_cancel_unreachable" }
|
|
110
|
+
: { name: "D2", verdict: "close", status: "failed", reason: "reconcile_session_closed" };
|
|
111
|
+
if (refusedHard || latest.delivery === "rejected") return { name: "A3", verdict: "confirm", reason: "reconcile_confirm_unreachable" };
|
|
112
|
+
if (((latest.delivery === "pending" && latest.admissionFailed) || latest.delivery === "unknown") && absent(latest.inSession))
|
|
113
|
+
return { name: "A1", verdict: "confirm", reason: "reconcile_confirm_undelivered" };
|
|
114
|
+
if (latest.delivery === "pending" && latest.error === null && e.blockedBy?.delivery === "unknown" && absent(e.blockedBy.inSession))
|
|
115
|
+
return { name: "A4", verdict: "confirm", reason: "reconcile_confirm_undelivered" };
|
|
116
|
+
if (latest.delivery === "accepted" && latest.kind !== "cancel" && idle && !latest.reported)
|
|
117
|
+
return { name: "A2", verdict: "confirm", reason: "reconcile_confirm_no_report" };
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** The close event counts only when it is real loss and was observed after the latest command was created. */
|
|
122
|
+
function closeEvent(e: ReconcileEvidence, latest: ReconcileCommand | null): number | null {
|
|
123
|
+
if (e.lifecycle === null || latest === null || NOT_LOSS.has(e.lifecycle.reason)) return null;
|
|
124
|
+
const closedAt = at(e.lifecycle.at);
|
|
125
|
+
return closedAt > at(latest.createdAt) ? closedAt : null;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function keep(why: string): ReconcileVerdict {
|
|
129
|
+
return { kind: "keep", why };
|
|
130
|
+
}
|