@huseynsnmz/pi-suite 0.0.0-stage → 0.2.0
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/CHANGELOG.md +28 -0
- package/LICENSE +21 -0
- package/README.md +88 -2
- package/SECURITY.md +5 -0
- package/package.json +112 -4
- package/packages/board/CHANGELOG.md +340 -0
- package/packages/board/CONTRIBUTING.md +7 -0
- package/packages/board/LICENSE +21 -0
- package/packages/board/README.md +88 -0
- package/packages/board/SECURITY.md +19 -0
- package/packages/board/docs/how-it-works.md +31 -0
- package/packages/board/docs/reference.md +278 -0
- package/packages/board/package.json +81 -0
- package/packages/board/runner/job-runner-legacy.mjs +68 -0
- package/packages/board/runner/job-runner.mjs +834 -0
- package/packages/board/runner/pty-runner-legacy.mjs +50 -0
- package/packages/board/runner/pty-runner.mjs +2175 -0
- package/packages/board/runner/state-coordinator.mjs +673 -0
- package/packages/board/runner/state-runner.mjs +203 -0
- package/packages/board/runner/title-runner.mjs +80 -0
- package/packages/board/src/commands/agent-board.ts +394 -0
- package/packages/board/src/commands/attach-decision.mjs +66 -0
- package/packages/board/src/commands/attach-flow.ts +251 -0
- package/packages/board/src/commands/bg.ts +103 -0
- package/packages/board/src/commands/board-model.ts +21 -0
- package/packages/board/src/core/atomic.mjs +184 -0
- package/packages/board/src/core/attach-delete.mjs +24 -0
- package/packages/board/src/core/attach-frame-proof.mjs +870 -0
- package/packages/board/src/core/attach-frame-reporter.mjs +648 -0
- package/packages/board/src/core/attach-frame-stream.mjs +266 -0
- package/packages/board/src/core/attach-view-proof.mjs +709 -0
- package/packages/board/src/core/auto-state.mjs +377 -0
- package/packages/board/src/core/background-runs.mjs +273 -0
- package/packages/board/src/core/code-refs-store.mjs +315 -0
- package/packages/board/src/core/code-refs.mjs +913 -0
- package/packages/board/src/core/config.mjs +77 -0
- package/packages/board/src/core/control-protocol.mjs +482 -0
- package/packages/board/src/core/coordinator-client.mjs +633 -0
- package/packages/board/src/core/coordinator-journal.mjs +282 -0
- package/packages/board/src/core/coordinator-protocol.mjs +14 -0
- package/packages/board/src/core/cwd-stats.mjs +114 -0
- package/packages/board/src/core/dashboard-render.mjs +10 -0
- package/packages/board/src/core/derive.mjs +117 -0
- package/packages/board/src/core/diagnostics.mjs +133 -0
- package/packages/board/src/core/double-press.mjs +26 -0
- package/packages/board/src/core/editor-state-reporter.mjs +112 -0
- package/packages/board/src/core/events.mjs +286 -0
- package/packages/board/src/core/evidence.mjs +245 -0
- package/packages/board/src/core/follow-up-queue.mjs +211 -0
- package/packages/board/src/core/foreground-preview-cache.mjs +117 -0
- package/packages/board/src/core/foreground-projection-cache.mjs +99 -0
- package/packages/board/src/core/frame-witness-update.mjs +35 -0
- package/packages/board/src/core/heuristics.mjs +313 -0
- package/packages/board/src/core/hold-input.mjs +27 -0
- package/packages/board/src/core/host-coordination.mjs +300 -0
- package/packages/board/src/core/host-crash.mjs +79 -0
- package/packages/board/src/core/host-probe.mjs +207 -0
- package/packages/board/src/core/host-protocol.mjs +73 -0
- package/packages/board/src/core/ids.mjs +35 -0
- package/packages/board/src/core/ime-cursor-coalesce.mjs +193 -0
- package/packages/board/src/core/invocation.mjs +43 -0
- package/packages/board/src/core/launch-options.mjs +418 -0
- package/packages/board/src/core/launch.mjs +157 -0
- package/packages/board/src/core/load-config.mjs +4 -0
- package/packages/board/src/core/locks.mjs +530 -0
- package/packages/board/src/core/paths.mjs +183 -0
- package/packages/board/src/core/pid.mjs +75 -0
- package/packages/board/src/core/prewarm-schedule.mjs +41 -0
- package/packages/board/src/core/process-identity-probe.mjs +228 -0
- package/packages/board/src/core/prompt-transport.mjs +13 -0
- package/packages/board/src/core/pty-attach-jiggle-controller.mjs +356 -0
- package/packages/board/src/core/pty-attach-jiggle-retry.mjs +109 -0
- package/packages/board/src/core/pty-attach-reconnect.mjs +61 -0
- package/packages/board/src/core/pty-attach-render.mjs +229 -0
- package/packages/board/src/core/pty-links.mjs +81 -0
- package/packages/board/src/core/pty-scroll.mjs +522 -0
- package/packages/board/src/core/pty-support.mjs +327 -0
- package/packages/board/src/core/repo.mjs +103 -0
- package/packages/board/src/core/rows.mjs +414 -0
- package/packages/board/src/core/screen-log-gc.mjs +198 -0
- package/packages/board/src/core/screen-log.mjs +160 -0
- package/packages/board/src/core/session-view.mjs +174 -0
- package/packages/board/src/core/slash-commands.mjs +52 -0
- package/packages/board/src/core/state-commands.mjs +757 -0
- package/packages/board/src/core/status-consistency.mjs +98 -0
- package/packages/board/src/core/steering-prompts.mjs +34 -0
- package/packages/board/src/core/steering.mjs +133 -0
- package/packages/board/src/core/stop-latch.mjs +41 -0
- package/packages/board/src/core/store.mjs +523 -0
- package/packages/board/src/core/suspend.mjs +36 -0
- package/packages/board/src/core/terminal-attach-client.mjs +981 -0
- package/packages/board/src/core/terminal-attach-protocol.mjs +460 -0
- package/packages/board/src/core/terminal-model.mjs +222 -0
- package/packages/board/src/core/terminal-query-sequences.mjs +239 -0
- package/packages/board/src/core/terminal-reply-guard.mjs +222 -0
- package/packages/board/src/core/terminal-snapshot.mjs +440 -0
- package/packages/board/src/core/test-knobs.mjs +28 -0
- package/packages/board/src/core/title.mjs +43 -0
- package/packages/board/src/core/types.mjs +414 -0
- package/packages/board/src/core/warm-host-sweeper.mjs +168 -0
- package/packages/board/src/core/worktree.mjs +65 -0
- package/packages/board/src/index.ts +303 -0
- package/packages/board/src/runtime/service.mjs +3222 -0
- package/packages/board/src/ui/chrome.ts +20 -0
- package/packages/board/src/ui/dashboard-decisions.mjs +55 -0
- package/packages/board/src/ui/dashboard-evidence.mjs +85 -0
- package/packages/board/src/ui/dashboard.ts +2237 -0
- package/packages/board/src/ui/double-left.ts +30 -0
- package/packages/board/src/ui/external-editor.ts +25 -0
- package/packages/board/src/ui/focus.ts +32 -0
- package/packages/board/src/ui/pty-attach.ts +3184 -0
- package/packages/board/src/ui/waiting.ts +30 -0
- package/packages/commands/LICENSE +21 -0
- package/packages/commands/README.md +19 -0
- package/packages/commands/package.json +22 -0
- package/packages/commands/src/index.ts +43 -0
- package/packages/commands/src/security-review.ts +109 -0
- package/packages/frame/CHANGELOG.md +113 -0
- package/packages/frame/CONTRIBUTING.md +7 -0
- package/packages/frame/LICENSE +22 -0
- package/packages/frame/README.md +105 -0
- package/packages/frame/SECURITY.md +16 -0
- package/packages/frame/docs/how-it-works.md +53 -0
- package/packages/frame/docs/reference.md +78 -0
- package/packages/frame/package.json +72 -0
- package/packages/frame/src/data/git.ts +90 -0
- package/packages/frame/src/data/github.ts +140 -0
- package/packages/frame/src/data/gitlab.ts +63 -0
- package/packages/frame/src/data/mode.ts +49 -0
- package/packages/frame/src/data/past-prompts.ts +57 -0
- package/packages/frame/src/data/runner.ts +32 -0
- package/packages/frame/src/data/snapshot.ts +223 -0
- package/packages/frame/src/data/start-notes.ts +104 -0
- package/packages/frame/src/data/telemetry.ts +337 -0
- package/packages/frame/src/data/todos.ts +162 -0
- package/packages/frame/src/data/totals.ts +94 -0
- package/packages/frame/src/data/visits.ts +45 -0
- package/packages/frame/src/index.ts +696 -0
- package/packages/frame/src/kit/LICENSE +21 -0
- package/packages/frame/src/kit/app/attribution.ts +30 -0
- package/packages/frame/src/kit/app/builder.ts +186 -0
- package/packages/frame/src/kit/app/claim.ts +27 -0
- package/packages/frame/src/kit/app/feature.ts +91 -0
- package/packages/frame/src/kit/app/host.ts +214 -0
- package/packages/frame/src/kit/app/renderers.ts +62 -0
- package/packages/frame/src/kit/app/scope.ts +125 -0
- package/packages/frame/src/kit/board/model.ts +337 -0
- package/packages/frame/src/kit/contract.ts +79 -0
- package/packages/frame/src/kit/contracts/accounts.ts +33 -0
- package/packages/frame/src/kit/contracts/ask.ts +120 -0
- package/packages/frame/src/kit/contracts/board.ts +6 -0
- package/packages/frame/src/kit/contracts/by.ts +22 -0
- package/packages/frame/src/kit/contracts/calls.ts +46 -0
- package/packages/frame/src/kit/contracts/events.ts +89 -0
- package/packages/frame/src/kit/contracts/mode.ts +26 -0
- package/packages/frame/src/kit/contracts/names.ts +12 -0
- package/packages/frame/src/kit/contracts/peers.ts +77 -0
- package/packages/frame/src/kit/contracts/prompts.ts +8 -0
- package/packages/frame/src/kit/contracts/screen.ts +75 -0
- package/packages/frame/src/kit/contracts/skills.ts +81 -0
- package/packages/frame/src/kit/contracts/sources.ts +81 -0
- package/packages/frame/src/kit/contracts/views.ts +42 -0
- package/packages/frame/src/kit/control.ts +59 -0
- package/packages/frame/src/kit/dark-theme.ts +41 -0
- package/packages/frame/src/kit/decode.ts +210 -0
- package/packages/frame/src/kit/events/log.ts +313 -0
- package/packages/frame/src/kit/format.ts +25 -0
- package/packages/frame/src/kit/host.ts +4 -0
- package/packages/frame/src/kit/index.ts +25 -0
- package/packages/frame/src/kit/legacy-tasks.ts +39 -0
- package/packages/frame/src/kit/overlay-images.ts +36 -0
- package/packages/frame/src/kit/paths.ts +16 -0
- package/packages/frame/src/kit/screen/client.ts +74 -0
- package/packages/frame/src/kit/screen/feature.ts +64 -0
- package/packages/frame/src/kit/screen/model.ts +182 -0
- package/packages/frame/src/kit/screen/view.ts +838 -0
- package/packages/frame/src/kit/settings/files.ts +90 -0
- package/packages/frame/src/kit/settings/setting.ts +57 -0
- package/packages/frame/src/kit/settings/store.ts +192 -0
- package/packages/frame/src/kit/sources/file.ts +129 -0
- package/packages/frame/src/kit/sources/follow.ts +155 -0
- package/packages/frame/src/kit/testing.ts +260 -0
- package/packages/frame/src/kit/totals.ts +72 -0
- package/packages/frame/src/kit/ui/entry.ts +75 -0
- package/packages/frame/src/kit/ui/frame.ts +212 -0
- package/packages/frame/src/kit/ui/glyphs.ts +132 -0
- package/packages/frame/src/kit/ui/scroll.ts +14 -0
- package/packages/frame/src/render/diff.ts +157 -0
- package/packages/frame/src/render/fit.ts +86 -0
- package/packages/frame/src/render/format.ts +108 -0
- package/packages/frame/src/render/frame.ts +106 -0
- package/packages/frame/src/render/glyphs.ts +189 -0
- package/packages/frame/src/render/keys.ts +43 -0
- package/packages/frame/src/render/lead.ts +37 -0
- package/packages/frame/src/render/paint.ts +135 -0
- package/packages/frame/src/render/safe.ts +19 -0
- package/packages/frame/src/render/segments.ts +735 -0
- package/packages/frame/src/render/streaming.ts +223 -0
- package/packages/frame/src/render/timing.ts +106 -0
- package/packages/frame/src/render/tools.ts +167 -0
- package/packages/frame/src/settings.ts +105 -0
- package/packages/frame/src/ui/editor.ts +272 -0
- package/packages/frame/src/ui/effort.ts +36 -0
- package/packages/frame/src/ui/footer.ts +165 -0
- package/packages/frame/src/ui/header.ts +362 -0
- package/packages/frame/src/ui/history-search.ts +23 -0
- package/packages/frame/src/ui/interrupt.ts +21 -0
- package/packages/frame/src/ui/outputs.ts +170 -0
- package/packages/frame/src/ui/pi-entries.ts +298 -0
- package/packages/frame/src/ui/previews.ts +72 -0
- package/packages/frame/src/ui/queue.ts +75 -0
- package/packages/frame/src/ui/screen.ts +59 -0
- package/packages/frame/src/ui/timings.ts +48 -0
- package/packages/frame/src/ui/todos.ts +205 -0
- package/packages/frame/src/ui/tool-frame.ts +595 -0
- package/packages/frame/src/ui/working.ts +25 -0
- package/packages/frame/src/view/state.ts +97 -0
- package/packages/frame/src/view/transcript.ts +175 -0
- package/packages/frame/src/view/view.ts +162 -0
- package/packages/memory/CONTRIBUTING.md +7 -0
- package/packages/memory/LICENSE +21 -0
- package/packages/memory/README.md +44 -0
- package/packages/memory/SECURITY.md +20 -0
- package/packages/memory/package.json +55 -0
- package/packages/memory/skills/review-memory/SKILL.md +48 -0
- package/packages/memory/src/index.ts +163 -0
- package/packages/memory/src/prompt.ts +25 -0
- package/packages/memory/src/review.ts +150 -0
- package/packages/memory/src/secrets.ts +22 -0
- package/packages/memory/src/store.ts +173 -0
- package/packages/modes/CHANGELOG.md +104 -0
- package/packages/modes/CONTRIBUTING.md +7 -0
- package/packages/modes/LICENSE +21 -0
- package/packages/modes/README.md +174 -0
- package/packages/modes/SECURITY.md +17 -0
- package/packages/modes/package.json +55 -0
- package/packages/modes/skills/review-permissions/SKILL.md +54 -0
- package/packages/modes/src/bash.ts +218 -0
- package/packages/modes/src/deny-off.ts +62 -0
- package/packages/modes/src/git-safety.ts +72 -0
- package/packages/modes/src/index.ts +338 -0
- package/packages/modes/src/interview.ts +206 -0
- package/packages/modes/src/judge-gate.ts +113 -0
- package/packages/modes/src/modes.ts +121 -0
- package/packages/modes/src/review/apply.mjs +156 -0
- package/packages/modes/src/review/collect.mjs +208 -0
- package/packages/modes/src/review/tools.ts +71 -0
- package/packages/modes/src/sandbox.ts +37 -0
- package/packages/router/CHANGELOG.md +8 -0
- package/packages/router/LICENSE +21 -0
- package/packages/router/README.md +90 -0
- package/packages/router/package.json +51 -0
- package/packages/router/src/config.ts +65 -0
- package/packages/router/src/index.ts +172 -0
- package/packages/router/src/judge.ts +51 -0
- package/packages/router/src/route.ts +67 -0
- package/packages/web/CHANGELOG.md +36 -0
- package/packages/web/LICENSE +21 -0
- package/packages/web/README.md +87 -0
- package/packages/web/SECURITY.md +13 -0
- package/packages/web/package.json +59 -0
- package/packages/web/src/config.ts +109 -0
- package/packages/web/src/dnsfilter.ts +97 -0
- package/packages/web/src/extract.ts +65 -0
- package/packages/web/src/index.ts +217 -0
- package/packages/web/src/markdown.ts +122 -0
- package/packages/web/src/net.ts +109 -0
- package/packages/web/src/reader.ts +73 -0
- package/packages/web/src/sanitize.ts +50 -0
- package/packages/web/src/search.ts +76 -0
- package/packages/web/src/secrets.ts +22 -0
- package/packages/web/src/validate.ts +186 -0
- package/src/index.ts +156 -0
- package/src/settings.ts +79 -0
|
@@ -0,0 +1,414 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure dashboard logic: turn store Rows into display view-models, group them by
|
|
3
|
+
* semantic state, and filter them. No pi-tui / theme coupling (the dashboard component
|
|
4
|
+
* applies colors); everything here is unit-tested.
|
|
5
|
+
*/
|
|
6
|
+
import { normalizeGenericStatusText } from "./derive.mjs";
|
|
7
|
+
import { baseName, relativeTime } from "./heuristics.mjs";
|
|
8
|
+
import { GROUP_LABELS, GROUP_ORDER, SEMANTIC_STATES } from "./types.mjs";
|
|
9
|
+
|
|
10
|
+
/** @typedef {import("./store.mjs").Row} Row */
|
|
11
|
+
/** @typedef {import("./types.mjs").SemanticState} SemanticState */
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Host heartbeat staleness threshold (ms). The pty-runner writes host.json on a
|
|
15
|
+
* 1 Hz heartbeat; when a crashed runner leaves host.json stuck on "alive", the
|
|
16
|
+
* heartbeat goes stale within seconds (issue #48 L4).
|
|
17
|
+
*/
|
|
18
|
+
const STALE_HOST_MS = 10_000;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* @typedef {Object} RowView
|
|
22
|
+
* @property {string} id
|
|
23
|
+
* @property {string} name
|
|
24
|
+
* @property {string} summary
|
|
25
|
+
* @property {string} age
|
|
26
|
+
* @property {string} place
|
|
27
|
+
* @property {string} folderKey
|
|
28
|
+
* @property {string} folderName
|
|
29
|
+
* @property {string} folderPath
|
|
30
|
+
* @property {boolean} pinned
|
|
31
|
+
* @property {SemanticState} state
|
|
32
|
+
* @property {boolean} alive
|
|
33
|
+
* @property {boolean} hostAlive
|
|
34
|
+
* @property {boolean} staleHost True when host.json claims alive but the runner
|
|
35
|
+
* process is gone and the heartbeat is stale (display-only crash marker).
|
|
36
|
+
* @property {boolean} needsInput
|
|
37
|
+
* @property {boolean} hasError
|
|
38
|
+
* @property {boolean} worktree
|
|
39
|
+
* @property {boolean} unread
|
|
40
|
+
* @property {boolean} reviewReady
|
|
41
|
+
* @property {number} evidenceErrorCount
|
|
42
|
+
* @property {boolean} diagnosticStalled
|
|
43
|
+
* @property {number} diagnosticErrorCount
|
|
44
|
+
* @property {number} followUpCount
|
|
45
|
+
* @property {string|null} followUpPreview
|
|
46
|
+
* @property {string} steeringState
|
|
47
|
+
* @property {string} refsBadge Inline issue/PR badge (e.g. "#40 ▸#45"); "" when there are no refs.
|
|
48
|
+
* @property {boolean} refsLowConfidence True when the winning issue or pr confidence is "low".
|
|
49
|
+
* @property {import("./types.mjs").CodeRefsSummary|null} codeRefs Compact ref summary the peek view consumes.
|
|
50
|
+
* @property {number} lastActivityAt
|
|
51
|
+
* @property {number} createdAt
|
|
52
|
+
*/
|
|
53
|
+
|
|
54
|
+
/** @param {Row} row @returns {SemanticState} */
|
|
55
|
+
export function rowState(row) {
|
|
56
|
+
return row.state?.semanticState ?? "queued";
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* State glyph (plain unicode; the dashboard colors it via theme).
|
|
61
|
+
* @param {SemanticState} state
|
|
62
|
+
* @param {boolean} alive
|
|
63
|
+
* @param {boolean} [hostAlive]
|
|
64
|
+
* @param {boolean} [unread]
|
|
65
|
+
* @returns {string}
|
|
66
|
+
*/
|
|
67
|
+
export function stateGlyph(state, alive, hostAlive = false, unread = false) {
|
|
68
|
+
switch (state) {
|
|
69
|
+
case "needs_input":
|
|
70
|
+
return unread ? "◆" : "◇";
|
|
71
|
+
case "working":
|
|
72
|
+
return alive ? (unread ? "◉" : "●") : (unread ? "◕" : "◐");
|
|
73
|
+
case "queued":
|
|
74
|
+
return hostAlive ? (unread ? "◍" : "◌") : (unread ? "◎" : "○");
|
|
75
|
+
case "completed":
|
|
76
|
+
return hostAlive ? (unread ? "◍" : "◌") : (unread ? "✔" : "✓");
|
|
77
|
+
case "failed":
|
|
78
|
+
return unread ? "✖" : "✗";
|
|
79
|
+
case "idle":
|
|
80
|
+
return unread ? "●" : "·";
|
|
81
|
+
case "holding":
|
|
82
|
+
return unread ? "◒" : "◑";
|
|
83
|
+
case "stopped":
|
|
84
|
+
return unread ? "■" : "▪";
|
|
85
|
+
default:
|
|
86
|
+
return "?";
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Theme color name appropriate for a state (consumed by the dashboard).
|
|
92
|
+
* @param {SemanticState} state
|
|
93
|
+
* @returns {string}
|
|
94
|
+
*/
|
|
95
|
+
export function stateColor(state) {
|
|
96
|
+
switch (state) {
|
|
97
|
+
case "needs_input":
|
|
98
|
+
return "warning";
|
|
99
|
+
case "working":
|
|
100
|
+
return "accent";
|
|
101
|
+
case "queued":
|
|
102
|
+
return "muted";
|
|
103
|
+
case "completed":
|
|
104
|
+
return "success";
|
|
105
|
+
case "failed":
|
|
106
|
+
return "error";
|
|
107
|
+
case "idle":
|
|
108
|
+
return "dim";
|
|
109
|
+
case "holding":
|
|
110
|
+
return "warning";
|
|
111
|
+
case "stopped":
|
|
112
|
+
return "muted";
|
|
113
|
+
default:
|
|
114
|
+
return "text";
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* @param {Row} row
|
|
120
|
+
* @param {number} now
|
|
121
|
+
* @returns {RowView}
|
|
122
|
+
*/
|
|
123
|
+
export function rowView(row, now) {
|
|
124
|
+
const state = rowState(row);
|
|
125
|
+
const summary = oneLine(normalizeGenericStatusText(state, row.state?.summary));
|
|
126
|
+
const lastActivityAt = row.state?.lastActivityAt ?? row.meta.updatedAt ?? row.meta.createdAt;
|
|
127
|
+
const worktree = row.meta.worktreeMode === "worktree";
|
|
128
|
+
const folderPath = row.meta.repoCwd || row.meta.cwd;
|
|
129
|
+
const folderName = baseName(folderPath) + (worktree ? "⌥" : "");
|
|
130
|
+
const folderKey = normalizeFolderKey(folderPath);
|
|
131
|
+
const place = folderName;
|
|
132
|
+
const lastVisitedAt = row.state?.lastVisitedAt ?? null;
|
|
133
|
+
const lastAgentActivityAt = row.state?.lastAgentActivityAt ?? null;
|
|
134
|
+
const refs = codeRefsBadge(row.codeRefs);
|
|
135
|
+
// Host-lost display marker (issue #48 L4): host.json still claims "alive"
|
|
136
|
+
// but the runner process is gone and the 1 Hz heartbeat went stale — the
|
|
137
|
+
// crash path leaves exactly this fingerprint (no exit message, no
|
|
138
|
+
// reconcile). Display-only; state.json is deliberately untouched.
|
|
139
|
+
const host = row.host;
|
|
140
|
+
const staleHost = Boolean(
|
|
141
|
+
host &&
|
|
142
|
+
host.state === "alive" &&
|
|
143
|
+
!row.hostAlive &&
|
|
144
|
+
typeof host.lastSeenAt === "number" &&
|
|
145
|
+
now - host.lastSeenAt > STALE_HOST_MS,
|
|
146
|
+
);
|
|
147
|
+
return {
|
|
148
|
+
id: row.meta.id,
|
|
149
|
+
name: row.meta.name,
|
|
150
|
+
summary,
|
|
151
|
+
age: relativeTime(lastActivityAt, now),
|
|
152
|
+
place,
|
|
153
|
+
folderKey,
|
|
154
|
+
folderName,
|
|
155
|
+
folderPath,
|
|
156
|
+
pinned: Boolean(row.meta.pinned),
|
|
157
|
+
order: row.meta.order ?? row.meta.createdAt ?? 0,
|
|
158
|
+
state,
|
|
159
|
+
alive: Boolean(row.alive),
|
|
160
|
+
hostAlive: Boolean(row.hostAlive),
|
|
161
|
+
staleHost,
|
|
162
|
+
needsInput: state === "needs_input",
|
|
163
|
+
hasError: state === "failed",
|
|
164
|
+
worktree,
|
|
165
|
+
unread: lastAgentActivityAt !== null && (lastVisitedAt === null || lastAgentActivityAt > lastVisitedAt),
|
|
166
|
+
reviewReady: Boolean(row.state?.review?.ready),
|
|
167
|
+
evidenceErrorCount: row.state?.review?.errorCount ?? 0,
|
|
168
|
+
diagnosticStalled: Boolean(row.state?.diagnostics?.stalled),
|
|
169
|
+
diagnosticErrorCount: row.state?.diagnostics?.errorCount ?? 0,
|
|
170
|
+
followUpCount: row.state?.followUps?.queuedCount ?? 0,
|
|
171
|
+
followUpPreview: row.state?.followUps?.lastQueuedPreview ?? null,
|
|
172
|
+
steeringState: row.state?.steering?.status ?? "none",
|
|
173
|
+
refsBadge: refs.refsBadge,
|
|
174
|
+
refsLowConfidence: refs.refsLowConfidence,
|
|
175
|
+
codeRefs: row.codeRefs ?? null,
|
|
176
|
+
lastActivityAt,
|
|
177
|
+
createdAt: row.meta.createdAt ?? 0,
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Map a CodeRefsSummary to the inline badge strings shown on dashboard rows:
|
|
183
|
+
* the winning issue and pr joined by a space, each prefixed with the provider
|
|
184
|
+
* prefix stored on the summary; plus the low-confidence flag that makes the
|
|
185
|
+
* dashboard dim the badge. Missing/garbage summaries yield an empty badge.
|
|
186
|
+
* @param {import("./types.mjs").CodeRefsSummary|null|undefined} summary
|
|
187
|
+
* @returns {{ refsBadge: string, refsLowConfidence: boolean }}
|
|
188
|
+
*/
|
|
189
|
+
function codeRefsBadge(summary) {
|
|
190
|
+
if (!summary) return { refsBadge: "", refsLowConfidence: false };
|
|
191
|
+
const issue = summary.issue;
|
|
192
|
+
const pr = summary.pr;
|
|
193
|
+
const parts = [];
|
|
194
|
+
if (issue && Number.isFinite(issue.number)) parts.push(`${summary.issuePrefix ?? "#"}${issue.number}`);
|
|
195
|
+
if (pr && Number.isFinite(pr.number)) parts.push(`${summary.prPrefix ?? "▸#"}${pr.number}`);
|
|
196
|
+
return {
|
|
197
|
+
refsBadge: parts.join(" "),
|
|
198
|
+
refsLowConfidence: issue?.confidence === "low" || pr?.confidence === "low",
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** @param {string} text */
|
|
203
|
+
function oneLine(text) {
|
|
204
|
+
return String(text || "").replace(/\s+/g, " ").trim() || "—";
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Group rows by semantic state in GROUP_ORDER. Within a group: pinned first, then
|
|
209
|
+
* creation order (oldest first) — the order is stable and never reshuffles on activity.
|
|
210
|
+
* Empty groups are omitted.
|
|
211
|
+
* @param {Row[]} rows
|
|
212
|
+
* @param {number} now
|
|
213
|
+
* @returns {Array<{ state: SemanticState, label: string, rows: RowView[] }>}
|
|
214
|
+
*/
|
|
215
|
+
export function groupRows(rows, now) {
|
|
216
|
+
const views = rows.map((r) => rowView(r, now));
|
|
217
|
+
/** @type {Array<{state: SemanticState, label: string, rows: RowView[]}>} */
|
|
218
|
+
const groups = [];
|
|
219
|
+
for (const state of GROUP_ORDER) {
|
|
220
|
+
const inGroup = sortRowViews(views.filter((v) => v.state === state));
|
|
221
|
+
if (inGroup.length > 0) groups.push({ state, label: GROUP_LABELS[state], rows: inGroup });
|
|
222
|
+
}
|
|
223
|
+
return groups;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Group rows first by semantic state, then by repo/folder within that state.
|
|
228
|
+
* `showFolders` is true only when a stage spans more than one folder, so a stage backed by
|
|
229
|
+
* a single folder renders as a flat list (no redundant folder header).
|
|
230
|
+
* @param {Row[]} rows
|
|
231
|
+
* @param {number} now
|
|
232
|
+
* @returns {Array<{ state: SemanticState, label: string, rowCount: number, showFolders: boolean, folders: Array<{ key: string, name: string, path: string, rows: RowView[], createdAt: number, pinned: boolean }> }>}
|
|
233
|
+
*/
|
|
234
|
+
export function groupRowsByFolder(rows, now) {
|
|
235
|
+
const views = rows.map((r) => rowView(r, now));
|
|
236
|
+
const groups = [];
|
|
237
|
+
for (const state of GROUP_ORDER) {
|
|
238
|
+
const inState = views.filter((v) => v.state === state);
|
|
239
|
+
if (inState.length === 0) continue;
|
|
240
|
+
const byFolder = new Map();
|
|
241
|
+
for (const view of inState) {
|
|
242
|
+
const key = view.folderKey;
|
|
243
|
+
const existing = byFolder.get(key);
|
|
244
|
+
if (existing) existing.rows.push(view);
|
|
245
|
+
else byFolder.set(key, { key, name: view.folderName, path: view.folderPath, rows: [view], createdAt: view.createdAt, pinned: view.pinned });
|
|
246
|
+
}
|
|
247
|
+
const folders = [...byFolder.values()].map((folder) => {
|
|
248
|
+
folder.rows = sortRowViews(folder.rows);
|
|
249
|
+
// Folder creation time = its first-created row, so folders keep a stable,
|
|
250
|
+
// creation-ordered position and don't jump when any row inside gets busy.
|
|
251
|
+
folder.createdAt = Math.min(...folder.rows.map((r) => r.createdAt));
|
|
252
|
+
folder.pinned = folder.rows.some((r) => r.pinned);
|
|
253
|
+
return folder;
|
|
254
|
+
}).sort((a, b) => Number(b.pinned) - Number(a.pinned) || a.createdAt - b.createdAt || a.name.localeCompare(b.name));
|
|
255
|
+
const showFolders = folders.length > 1;
|
|
256
|
+
groups.push({ state, label: GROUP_LABELS[state], rowCount: inState.length, showFolders, folders });
|
|
257
|
+
}
|
|
258
|
+
return groups;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* The other way to see the list: one group per directory, whatever state its rows are in.
|
|
263
|
+
* @param {Row[]} rows
|
|
264
|
+
* @param {number} now
|
|
265
|
+
* @returns {Array<{ key: string, name: string, path: string, rows: RowView[] }>}
|
|
266
|
+
*/
|
|
267
|
+
export function groupRowsByDirectory(rows, now) {
|
|
268
|
+
const byFolder = new Map();
|
|
269
|
+
for (const view of rows.map((r) => rowView(r, now))) {
|
|
270
|
+
const existing = byFolder.get(view.folderKey);
|
|
271
|
+
if (existing) existing.rows.push(view);
|
|
272
|
+
else byFolder.set(view.folderKey, { key: view.folderKey, name: view.folderName, path: view.folderPath, rows: [view] });
|
|
273
|
+
}
|
|
274
|
+
const folders = [...byFolder.values()];
|
|
275
|
+
for (const folder of folders) folder.rows = sortRowViews(folder.rows);
|
|
276
|
+
return folders.sort((a, b) => Math.min(...a.rows.map((r) => r.createdAt)) - Math.min(...b.rows.map((r) => r.createdAt)) || a.name.localeCompare(b.name));
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Issue #147: the row the board should select after `id` is removed — the
|
|
281
|
+
* next visible row in dashboard order, else the previous one, else null
|
|
282
|
+
* (caller falls back to the existing orderedIds[0] clamp). Uses the same
|
|
283
|
+
* groupRowsByFolder ordering as the dashboard's orderedIds.
|
|
284
|
+
* @param {Row[]} rows
|
|
285
|
+
* @param {string} id
|
|
286
|
+
* @param {number} now
|
|
287
|
+
* @returns {string | null}
|
|
288
|
+
*/
|
|
289
|
+
export function nextVisibleIdAfterRemoval(rows, id, now) {
|
|
290
|
+
const ordered = groupRowsByFolder(rows, now).flatMap((g) => g.folders.flatMap((f) => f.rows.map((r) => r.id)));
|
|
291
|
+
const groups = new Map(groupRowsByFolder(rows, now).flatMap((g) => g.folders.flatMap((f) => f.rows.map((r) => [r.id, g.state]))));
|
|
292
|
+
return neighborAfterRemoval(ordered, groups, id, (other) => other !== id);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Where the cursor goes when its row is gone: one up in the same group, or the
|
|
297
|
+
* next one down if it was that group's first. An emptied group falls through to
|
|
298
|
+
* the nearest row above, then below.
|
|
299
|
+
* @param {readonly string[]} ordered
|
|
300
|
+
* @param {ReadonlyMap<string, string>} groups
|
|
301
|
+
* @param {string} id
|
|
302
|
+
* @param {(other: string) => boolean} survives
|
|
303
|
+
* @returns {string | null}
|
|
304
|
+
*/
|
|
305
|
+
export function neighborAfterRemoval(ordered, groups, id, survives) {
|
|
306
|
+
const at = ordered.indexOf(id);
|
|
307
|
+
if (at === -1) return null;
|
|
308
|
+
const above = ordered.slice(0, at).reverse().filter(survives);
|
|
309
|
+
const below = ordered.slice(at + 1).filter(survives);
|
|
310
|
+
const sameGroup = (other) => groups.get(other) === groups.get(id);
|
|
311
|
+
return above.find(sameGroup) ?? below.find(sameGroup) ?? above[0] ?? below[0] ?? null;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Stable row order: pinned first, then creation order (oldest first), then id.
|
|
316
|
+
* Activity time deliberately does not participate, so rows never reshuffle while
|
|
317
|
+
* sessions are running.
|
|
318
|
+
* @param {RowView[]} views
|
|
319
|
+
*/
|
|
320
|
+
function sortRowViews(views) {
|
|
321
|
+
return views.sort(
|
|
322
|
+
(a, b) =>
|
|
323
|
+
Number(b.pinned) - Number(a.pinned) ||
|
|
324
|
+
a.order - b.order ||
|
|
325
|
+
a.createdAt - b.createdAt ||
|
|
326
|
+
a.id.localeCompare(b.id),
|
|
327
|
+
);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/** @param {string} path */
|
|
331
|
+
function normalizeFolderKey(path) {
|
|
332
|
+
return String(path || "").replace(/\\/g, "/").replace(/\/+$/, "") || "/";
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Parse a filter query into a state filter + free-text terms.
|
|
337
|
+
* Supports `s:<state>` (state prefix) and bare words (AND substring match).
|
|
338
|
+
* @param {string} query
|
|
339
|
+
* @returns {{ states: SemanticState[], terms: string[], reviewReady: boolean, diagStalled: boolean, evidenceError: boolean, queued: boolean, steering: string|null }}
|
|
340
|
+
*/
|
|
341
|
+
export function parseFilter(query) {
|
|
342
|
+
/** @type {Set<SemanticState>} */
|
|
343
|
+
const states = new Set();
|
|
344
|
+
/** @type {string[]} */
|
|
345
|
+
const terms = [];
|
|
346
|
+
let reviewReady = false;
|
|
347
|
+
let diagStalled = false;
|
|
348
|
+
let evidenceError = false;
|
|
349
|
+
let queued = false;
|
|
350
|
+
let steering = null;
|
|
351
|
+
for (const tok of String(query || "").trim().split(/\s+/).filter(Boolean)) {
|
|
352
|
+
const m = /^s:(.+)$/i.exec(tok);
|
|
353
|
+
if (m) {
|
|
354
|
+
const want = normalizeStateToken(m[1]);
|
|
355
|
+
for (const s of SEMANTIC_STATES) {
|
|
356
|
+
const aliases = [s, GROUP_LABELS[s]];
|
|
357
|
+
if (aliases.some((alias) => matchesStateToken(alias, want))) states.add(s);
|
|
358
|
+
}
|
|
359
|
+
} else if (/^review:ready$/i.test(tok)) {
|
|
360
|
+
reviewReady = true;
|
|
361
|
+
} else if (/^diag:stalled$/i.test(tok)) {
|
|
362
|
+
diagStalled = true;
|
|
363
|
+
} else if (/^evidence:error$/i.test(tok)) {
|
|
364
|
+
evidenceError = true;
|
|
365
|
+
} else if (/^queued:(true|yes|1)$/i.test(tok)) {
|
|
366
|
+
queued = true;
|
|
367
|
+
} else if (/^steer:/i.test(tok)) {
|
|
368
|
+
steering = normalizeStateToken(tok.replace(/^steer:/i, ""));
|
|
369
|
+
} else {
|
|
370
|
+
terms.push(tok.toLowerCase());
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
return { states: [...states], terms, reviewReady, diagStalled, evidenceError, queued, steering };
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/** @param {string} value */
|
|
377
|
+
function normalizeStateToken(value) {
|
|
378
|
+
return String(value || "").toLowerCase().replace(/[^a-z0-9]+/g, "");
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/** @param {string} alias @param {string} want */
|
|
382
|
+
function matchesStateToken(alias, want) {
|
|
383
|
+
const normalized = normalizeStateToken(alias);
|
|
384
|
+
return normalized === want || normalized.startsWith(want);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/** @param {string} query @returns {boolean} whether the text is a filter expression. */
|
|
388
|
+
export function isFilterQuery(query) {
|
|
389
|
+
return /(^|\s)(s:|review:|diag:|evidence:|queued:|steer:)/i.test(query || "");
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* Filter rows by a query string.
|
|
394
|
+
* @param {Row[]} rows
|
|
395
|
+
* @param {string} query
|
|
396
|
+
* @returns {Row[]}
|
|
397
|
+
*/
|
|
398
|
+
export function filterRows(rows, query) {
|
|
399
|
+
const { states, terms, reviewReady, diagStalled, evidenceError, queued, steering } = parseFilter(query);
|
|
400
|
+
if (states.length === 0 && terms.length === 0 && !reviewReady && !diagStalled && !evidenceError && !queued && !steering) return rows;
|
|
401
|
+
return rows.filter((row) => {
|
|
402
|
+
if (states.length > 0 && !states.includes(rowState(row))) return false;
|
|
403
|
+
if (reviewReady && !row.state?.review?.ready) return false;
|
|
404
|
+
if (diagStalled && !row.state?.diagnostics?.stalled) return false;
|
|
405
|
+
if (evidenceError && (row.state?.review?.errorCount ?? 0) <= 0) return false;
|
|
406
|
+
if (queued && (row.state?.followUps?.queuedCount ?? 0) <= 0) return false;
|
|
407
|
+
if (steering && normalizeStateToken(row.state?.steering?.status ?? "none") !== steering) return false;
|
|
408
|
+
if (terms.length === 0) return true;
|
|
409
|
+
const hay = [row.meta.name, row.state?.summary ?? "", row.meta.repoCwd, row.meta.cwd]
|
|
410
|
+
.join(" ")
|
|
411
|
+
.toLowerCase();
|
|
412
|
+
return terms.every((t) => hay.includes(t));
|
|
413
|
+
});
|
|
414
|
+
}
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Startup GC for per-view PTY replay logs.
|
|
3
|
+
*
|
|
4
|
+
* screen.log write-path growth is already bounded by screen-log.mjs (cap + tail
|
|
5
|
+
* compaction inside pty-runner). This module reclaims the other half: logs of
|
|
6
|
+
* views whose session ENDED long ago — no runner will ever touch them again,
|
|
7
|
+
* so without a sweep they sit on disk forever.
|
|
8
|
+
*
|
|
9
|
+
* Safety rules:
|
|
10
|
+
* - Only screen.log is removed; meta/state/evidence stay so the dashboard row survives.
|
|
11
|
+
* - Views with a live host (state alive/starting, endedAt null) are never touched:
|
|
12
|
+
* pty-runner holds an in-memory byte counter for its log and external mutation
|
|
13
|
+
* would race with it. Live logs are bounded by the runner's own cap.
|
|
14
|
+
*/
|
|
15
|
+
import { existsSync, readdirSync, statSync, unlinkSync } from "node:fs";
|
|
16
|
+
import { readJson } from "./atomic.mjs";
|
|
17
|
+
import * as P from "./paths.mjs";
|
|
18
|
+
import { isAlive } from "./pid.mjs";
|
|
19
|
+
|
|
20
|
+
export const DEFAULT_SCREEN_LOG_RETENTION_DAYS = 7;
|
|
21
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* A live pty-runner heartbeat-persists host.json every second, and a booting runner
|
|
25
|
+
* persists state "starting" before the service's own writeHost lands. A host.json
|
|
26
|
+
* modified within this window therefore means a runner is live or mid-launch.
|
|
27
|
+
*/
|
|
28
|
+
const HOST_FRESH_GRACE_MS = 10_000;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* An "alive"/"starting" claim older than this is not trusted: heartbeat-persisted
|
|
32
|
+
* lastSeenAt goes stale when a runner dies, and a dead pid can later be recycled by
|
|
33
|
+
* an unrelated process — pid liveness alone must not exempt a view forever.
|
|
34
|
+
*/
|
|
35
|
+
const ALIVE_CLAIM_MAX_AGE_MS = 3 * DAY_MS;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Shared input gate for the prefs normalizers: only finite numbers and non-blank
|
|
39
|
+
* numeric strings are meaningful. Anything else (booleans, arrays, "", " ",
|
|
40
|
+
* objects) is a hand-edit accident, not a value.
|
|
41
|
+
* @param {unknown} value
|
|
42
|
+
* @returns {number|null} the coerced finite number, or null when not usable
|
|
43
|
+
*/
|
|
44
|
+
function coerceUsableNumber(value) {
|
|
45
|
+
if (typeof value === "number") return Number.isFinite(value) ? value : null;
|
|
46
|
+
if (typeof value === "string" && value.trim() !== "") {
|
|
47
|
+
const n = Number(value);
|
|
48
|
+
return Number.isFinite(n) ? n : null;
|
|
49
|
+
}
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Normalize the `screenLogRetentionDays` pref.
|
|
55
|
+
* @param {unknown} value
|
|
56
|
+
* @returns {number|null} days, or null when GC is disabled (pref = 0)
|
|
57
|
+
*/
|
|
58
|
+
export function normalizeRetentionDays(value) {
|
|
59
|
+
const n = coerceUsableNumber(value);
|
|
60
|
+
if (n === null || n < 0) return DEFAULT_SCREEN_LOG_RETENTION_DAYS;
|
|
61
|
+
const days = Math.floor(n);
|
|
62
|
+
// Anything flooring to 0 (0, "0", "0.0", fractions in (0,1)) means "disabled".
|
|
63
|
+
// A 0-day retention would otherwise compute cutoff=now and delete EVERY ended log.
|
|
64
|
+
return days === 0 ? null : days;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Normalize the `screenLogMaxSize` pref.
|
|
69
|
+
* @param {unknown} value
|
|
70
|
+
* @returns {number|null} bytes, or null to keep the runner's built-in default
|
|
71
|
+
*/
|
|
72
|
+
export function normalizeScreenLogMaxBytes(value) {
|
|
73
|
+
const n = coerceUsableNumber(value);
|
|
74
|
+
if (n === null) return null;
|
|
75
|
+
const bytes = Math.floor(n);
|
|
76
|
+
return bytes > 0 ? bytes : null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Delete screen.log of ended views older than the retention window.
|
|
81
|
+
* Best-effort: a per-file failure is counted and skipped, never thrown.
|
|
82
|
+
* @param {string} root
|
|
83
|
+
* @param {{ retentionDays?: number|null, now?: number }} [opts]
|
|
84
|
+
* @returns {{ scanned: number, removed: number, skippedActive: number, skippedFresh: number, skippedForeign: number, bytesReclaimed: number, errors: number }}
|
|
85
|
+
*/
|
|
86
|
+
export function pruneScreenLogs(root, opts = {}) {
|
|
87
|
+
const stats = { scanned: 0, removed: 0, skippedActive: 0, skippedFresh: 0, skippedForeign: 0, bytesReclaimed: 0, errors: 0 };
|
|
88
|
+
const retentionDays = normalizeRetentionDays(opts.retentionDays);
|
|
89
|
+
if (retentionDays === null) return stats;
|
|
90
|
+
const now = Number.isFinite(opts.now) ? opts.now : Date.now();
|
|
91
|
+
const cutoff = now - retentionDays * DAY_MS;
|
|
92
|
+
/** @type {import("node:fs").Dirent[]} */
|
|
93
|
+
let entries;
|
|
94
|
+
try {
|
|
95
|
+
entries = readdirSync(P.viewsDir(root), { withFileTypes: true });
|
|
96
|
+
} catch {
|
|
97
|
+
return stats; // no views dir yet — nothing to do
|
|
98
|
+
}
|
|
99
|
+
for (const entry of entries) {
|
|
100
|
+
if (!entry.isDirectory()) continue;
|
|
101
|
+
if (!isViewDir(root, entry.name)) {
|
|
102
|
+
// Foreign dirs must never lose a file; count the ones holding a screen.log
|
|
103
|
+
// so the skip is visible in stats.
|
|
104
|
+
try {
|
|
105
|
+
if (statSync(P.screenLogPath(root, entry.name)).size > 0) stats.skippedForeign++;
|
|
106
|
+
} catch {}
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
const logFile = P.screenLogPath(root, entry.name);
|
|
110
|
+
/** @type {number} */
|
|
111
|
+
let size;
|
|
112
|
+
try {
|
|
113
|
+
size = statSync(logFile).size;
|
|
114
|
+
} catch {
|
|
115
|
+
continue; // no screen.log (job-runner views never have one)
|
|
116
|
+
}
|
|
117
|
+
if (size <= 0) continue;
|
|
118
|
+
stats.scanned++;
|
|
119
|
+
const basis = ageBasisMs(root, entry.name, logFile, now);
|
|
120
|
+
if (basis === "active") {
|
|
121
|
+
stats.skippedActive++;
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
if (basis === null || basis > cutoff) {
|
|
125
|
+
stats.skippedFresh++;
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
try {
|
|
129
|
+
unlinkSync(logFile);
|
|
130
|
+
stats.removed++;
|
|
131
|
+
stats.bytesReclaimed += size;
|
|
132
|
+
} catch (err) {
|
|
133
|
+
if (classifyUnlinkFailure(err) === "removed") stats.removed++;
|
|
134
|
+
else stats.errors++;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
return stats;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Age basis for one view's log: host endedAt when known, else the log's mtime.
|
|
142
|
+
* @param {string} root @param {string} viewId @param {string} logFile @param {number} now
|
|
143
|
+
* @returns {number|null|"active"} epoch ms, "active" for live views, null when unknown
|
|
144
|
+
*/
|
|
145
|
+
function ageBasisMs(root, viewId, logFile, now) {
|
|
146
|
+
const hostFile = P.hostPath(root, viewId);
|
|
147
|
+
const host = readJson(hostFile, null);
|
|
148
|
+
if (host) {
|
|
149
|
+
// Fresh host.json = a runner is heartbeating or mid-launch. Checking mtime first
|
|
150
|
+
// closes the launch TOCTOU window (runner boots and opens the log before the
|
|
151
|
+
// service's writeHost records "starting"): a stale read can never delete the
|
|
152
|
+
// log of a runner that just started.
|
|
153
|
+
try {
|
|
154
|
+
if (now - statSync(hostFile).mtimeMs < HOST_FRESH_GRACE_MS) return "active";
|
|
155
|
+
} catch {}
|
|
156
|
+
if (host.endedAt == null && (host.state === "alive" || host.state === "starting")) {
|
|
157
|
+
// A runner killed by SIGKILL/OOM leaves state "alive" on disk forever, and its
|
|
158
|
+
// pid may later be recycled by an unrelated long-lived process (isAlive also
|
|
159
|
+
// treats EPERM as alive). Trust the claim only when the heartbeat refreshed
|
|
160
|
+
// lastSeenAt recently AND the pid is actually alive.
|
|
161
|
+
const claimFresh = Number.isFinite(host.lastSeenAt) && now - host.lastSeenAt < ALIVE_CLAIM_MAX_AGE_MS;
|
|
162
|
+
if (claimFresh && Number.isInteger(host.runnerPid) && isAlive(host.runnerPid)) return "active";
|
|
163
|
+
}
|
|
164
|
+
if (Number.isFinite(host.endedAt)) return host.endedAt;
|
|
165
|
+
}
|
|
166
|
+
try {
|
|
167
|
+
return statSync(logFile).mtimeMs;
|
|
168
|
+
} catch {
|
|
169
|
+
return null;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Classify an unlinkSync failure. ENOENT after a successful stat means a concurrent
|
|
175
|
+
* sweep (a second dashboard sharing the root) won the unlink: the reclaim goal is
|
|
176
|
+
* achieved, so it counts as removed (without the bytes, which the winner accounted
|
|
177
|
+
* for), never as an error. Everything else is a real failure.
|
|
178
|
+
* Extracted as a pure predicate because the stat→unlink race cannot be reproduced
|
|
179
|
+
* deterministically in tests — the predicate is the part that can silently regress.
|
|
180
|
+
* @param {unknown} err
|
|
181
|
+
* @returns {"removed"|"error"}
|
|
182
|
+
*/
|
|
183
|
+
export function classifyUnlinkFailure(err) {
|
|
184
|
+
return err && err.code === "ENOENT" ? "removed" : "error";
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* A sweep-eligible directory is a real view: it carries meta.json, or — when
|
|
189
|
+
* meta.json was externally deleted — a host.json that is parseable AND names this
|
|
190
|
+
* directory as its view. A stray/garbage host.json in a foreign directory must not
|
|
191
|
+
* make that directory's screen.log a deletion candidate.
|
|
192
|
+
* @param {string} root @param {string} viewId
|
|
193
|
+
*/
|
|
194
|
+
function isViewDir(root, viewId) {
|
|
195
|
+
if (existsSync(P.metaPath(root, viewId))) return true;
|
|
196
|
+
const host = readJson(P.hostPath(root, viewId), null);
|
|
197
|
+
return Boolean(host && host.viewId === viewId);
|
|
198
|
+
}
|