@erclx/canon 5.46.0 → 5.48.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/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/git-followup/SKILL.md +1 -0
- package/claude/skills/git-pr/REQUIREMENT.md +2 -2
- package/claude/skills/git-pr/SKILL.md +2 -1
- package/claude/skills/git-pr/references/closing-issues.md +2 -0
- package/claude/skills/git-pr/references/pr.md +58 -7
- package/claude/skills/review-address/SKILL.md +18 -6
- package/claude/skills/review-address/references/post-review-findings.md +4 -1
- package/claude/skills/review-address/references/rebase-only.md +14 -2
- package/claude/skills/review-pr/SKILL.md +9 -2
- package/claude/skills/review-pr/references/body-variants.md +29 -8
- package/claude/skills/review-pr/references/post.md +29 -10
- package/claude/skills/review-ui/REQUIREMENT.md +1 -1
- package/claude/skills/review-ui/SKILL.md +4 -3
- package/claude/skills/review-ui/references/post.md +16 -2
- package/claude/skills/review-ui/references/tick.md +7 -1
- package/claude/skills/role-orchestrator/references/orchestrator-poll.md +1 -1
- package/claude/skills/role-orchestrator/references/orchestrator-refill.md +4 -2
- package/claude/skills/role-reviewer/REQUIREMENT.md +1 -1
- package/claude/skills/role-reviewer/SKILL.md +4 -1
- package/claude/skills/role-worker/REQUIREMENT.md +1 -1
- package/claude/skills/role-worker/SKILL.md +1 -0
- package/claude/skills/task-board/references/decline.md +2 -2
- package/claude/skills/task-board/references/tasks.md +4 -2
- package/claude/skills/task-complete/SKILL.md +1 -0
- package/claude/skills/task-complete/references/archive.md +5 -3
- package/claude/skills/ui-checklist/SKILL.md +3 -3
- package/docs/agents/migrate.md +2 -2
- package/docs/target/migrations.md +4 -2
- package/docs/workflow/operating-model.md +2 -2
- package/docs/workflow/zshrc-aliases.md +3 -0
- package/package.json +1 -1
- package/scripts/core/bootstrap.sh +1 -0
- package/src/commands/claude.ts +4 -0
- package/src/commands/labels.ts +66 -1
- package/src/commands/migrate.ts +5 -4
- package/src/commands/pr.ts +405 -30
- package/src/commands/sessions.ts +25 -11
- package/src/commands/tasks.ts +11 -1
- package/src/labels/density.ts +363 -0
- package/src/migrate/session-defaults.ts +20 -5
- package/src/pr/evidence.ts +1 -1
- package/src/pr/lift.ts +19 -0
- package/src/pr/merge.ts +175 -0
- package/src/pr/rest.ts +3 -0
- package/src/pr/review-scope.ts +40 -1
- package/src/pr/tick.ts +109 -0
- package/src/sessions/live.ts +26 -0
- package/src/sessions/resolve.ts +62 -11
- package/src/tasks/archive.ts +32 -8
- package/src/tasks/closes.ts +9 -5
- package/src/tasks/validate.ts +4 -2
- package/tooling/base/seeds/.github/pull_request_template.md +1 -1
- package/tooling/claude/reference.md +3 -1
- package/tooling/claude/seeds/.claude/settings.json +1 -1
package/src/pr/merge.ts
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import type { ChecksGate } from '@/pr/lift'
|
|
2
|
+
import { readSection } from '@/pr/paths'
|
|
3
|
+
import { UI_PASS_MARKER } from '@/pr/tick'
|
|
4
|
+
|
|
5
|
+
/** Why the verb did not merge, in the order the refusals are read. */
|
|
6
|
+
export type MergeRefusal =
|
|
7
|
+
| 'pull-closed'
|
|
8
|
+
| 'pull-draft'
|
|
9
|
+
| 'missing-summary'
|
|
10
|
+
| 'missing-key-changes'
|
|
11
|
+
| 'ci-failing'
|
|
12
|
+
| 'ci-conflicted'
|
|
13
|
+
| 'ci-pending'
|
|
14
|
+
| 'head-moved'
|
|
15
|
+
| 'not-mergeable'
|
|
16
|
+
| 'gh-failed'
|
|
17
|
+
|
|
18
|
+
export interface MergePull {
|
|
19
|
+
readonly isOpen: boolean
|
|
20
|
+
readonly isDraft: boolean
|
|
21
|
+
readonly title: string
|
|
22
|
+
readonly body: string
|
|
23
|
+
/** The head the merge is pinned to, so a push after the read refuses. */
|
|
24
|
+
readonly head: string
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface MergeRequest {
|
|
28
|
+
readonly title: string
|
|
29
|
+
readonly description: string
|
|
30
|
+
readonly sha: string
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** What the merge endpoint answered, with 409 and 405 told apart from a merge. */
|
|
34
|
+
export type MergeOutcome =
|
|
35
|
+
| { readonly kind: 'merged'; readonly sha: string }
|
|
36
|
+
| { readonly kind: 'head-moved' }
|
|
37
|
+
| { readonly kind: 'not-mergeable' }
|
|
38
|
+
| { readonly kind: 'failed' }
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Everything the verb reads or writes, injected so every refusal runs without
|
|
42
|
+
* a network. A read that failed returns undefined, and the verb refuses
|
|
43
|
+
* `gh-failed` rather than reading absence as a pass.
|
|
44
|
+
*/
|
|
45
|
+
export interface MergeApi {
|
|
46
|
+
readPull(): Promise<MergePull | undefined>
|
|
47
|
+
readChecks(head: string): Promise<ChecksGate | undefined>
|
|
48
|
+
putMerge(request: MergeRequest): Promise<MergeOutcome>
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface MergeOptions {
|
|
52
|
+
readonly number: number
|
|
53
|
+
readonly isDryRun: boolean
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface MergeRecord {
|
|
57
|
+
readonly reason: 'merged' | 'dry-run' | MergeRefusal
|
|
58
|
+
readonly isOk: boolean
|
|
59
|
+
readonly number: number
|
|
60
|
+
readonly title?: string
|
|
61
|
+
readonly description?: string
|
|
62
|
+
/** The squash commit, present only on a merge. */
|
|
63
|
+
readonly sha?: string
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export type Composed =
|
|
67
|
+
| { readonly kind: 'composed'; readonly description: string }
|
|
68
|
+
| {
|
|
69
|
+
readonly kind: 'refused'
|
|
70
|
+
readonly reason: 'missing-summary' | 'missing-key-changes'
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** A section's text with the UI pass line dropped, or undefined when nothing is left. */
|
|
74
|
+
function keptSection(body: string, title: string): string | undefined {
|
|
75
|
+
const text = readSection(body, title)
|
|
76
|
+
?.split('\n')
|
|
77
|
+
.filter((line) => !line.includes(UI_PASS_MARKER))
|
|
78
|
+
.join('\n')
|
|
79
|
+
.trim()
|
|
80
|
+
return text === undefined || text === ''
|
|
81
|
+
? undefined
|
|
82
|
+
: `## ${title}\n\n${text}`
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* The squash description: Summary, Key changes, and Technical context, in that
|
|
87
|
+
* order, under their headings. Testing, the reviewer requests, and the UI pass
|
|
88
|
+
* line describe the review rather than the change, so none reaches the trunk.
|
|
89
|
+
* Technical context is optional, and an empty one leaves no bare heading.
|
|
90
|
+
*/
|
|
91
|
+
export function composeDescription(body: string): Composed {
|
|
92
|
+
const summary = keptSection(body, 'Summary')
|
|
93
|
+
if (summary === undefined)
|
|
94
|
+
return { kind: 'refused', reason: 'missing-summary' }
|
|
95
|
+
const keyChanges = keptSection(body, 'Key changes')
|
|
96
|
+
if (keyChanges === undefined) {
|
|
97
|
+
return { kind: 'refused', reason: 'missing-key-changes' }
|
|
98
|
+
}
|
|
99
|
+
const context = keptSection(body, 'Technical context')
|
|
100
|
+
return {
|
|
101
|
+
kind: 'composed',
|
|
102
|
+
description: [summary, keyChanges, context]
|
|
103
|
+
.filter((block) => block !== undefined)
|
|
104
|
+
.join('\n\n'),
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** The suffix the web merge adds and the trunk log carries. */
|
|
109
|
+
export function mergeTitle(title: string, number: number): string {
|
|
110
|
+
return `${title} (#${number})`
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function refused(
|
|
114
|
+
reason: MergeRefusal,
|
|
115
|
+
number: number,
|
|
116
|
+
message: { title?: string; description?: string } = {},
|
|
117
|
+
): MergeRecord {
|
|
118
|
+
return { reason, isOk: false, number, ...message }
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Squash-merges one ready pull request with a message carrying only the
|
|
123
|
+
* change. Every refusal is read before the one write, and the write sends the
|
|
124
|
+
* head it read as `sha`, so GitHub refuses rather than merging a commit pushed
|
|
125
|
+
* after the checks were read. The verb reads the draft flag and not the review
|
|
126
|
+
* comments, since a ready pull request already passed the lift's gates.
|
|
127
|
+
*/
|
|
128
|
+
export async function merge(
|
|
129
|
+
api: MergeApi,
|
|
130
|
+
opts: MergeOptions,
|
|
131
|
+
): Promise<MergeRecord> {
|
|
132
|
+
const { number } = opts
|
|
133
|
+
|
|
134
|
+
const pull = await api.readPull()
|
|
135
|
+
if (pull === undefined) return refused('gh-failed', number)
|
|
136
|
+
if (!pull.isOpen) return refused('pull-closed', number)
|
|
137
|
+
if (pull.isDraft) return refused('pull-draft', number)
|
|
138
|
+
|
|
139
|
+
const title = mergeTitle(pull.title, number)
|
|
140
|
+
const composed = composeDescription(pull.body)
|
|
141
|
+
if (composed.kind === 'refused') {
|
|
142
|
+
return refused(composed.reason, number, { title })
|
|
143
|
+
}
|
|
144
|
+
const message = { title, description: composed.description }
|
|
145
|
+
|
|
146
|
+
const checks = await api.readChecks(pull.head)
|
|
147
|
+
if (checks === undefined) return refused('gh-failed', number, message)
|
|
148
|
+
if (checks.state === 'failing') return refused('ci-failing', number, message)
|
|
149
|
+
if (checks.state !== 'passing') {
|
|
150
|
+
return refused(
|
|
151
|
+
checks.isConflicted ? 'ci-conflicted' : 'ci-pending',
|
|
152
|
+
number,
|
|
153
|
+
message,
|
|
154
|
+
)
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (opts.isDryRun)
|
|
158
|
+
return { reason: 'dry-run', isOk: true, number, ...message }
|
|
159
|
+
|
|
160
|
+
const outcome = await api.putMerge({ ...message, sha: pull.head })
|
|
161
|
+
if (outcome.kind === 'merged') {
|
|
162
|
+
return {
|
|
163
|
+
reason: 'merged',
|
|
164
|
+
isOk: true,
|
|
165
|
+
number,
|
|
166
|
+
...message,
|
|
167
|
+
sha: outcome.sha,
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return refused(
|
|
171
|
+
outcome.kind === 'failed' ? 'gh-failed' : outcome.kind,
|
|
172
|
+
number,
|
|
173
|
+
message,
|
|
174
|
+
)
|
|
175
|
+
}
|
package/src/pr/rest.ts
CHANGED
|
@@ -13,6 +13,7 @@ import type { ReviewRow } from '@/pr/review-scope'
|
|
|
13
13
|
/** One row of `repos/{owner}/{repo}/pulls/<n>` or of the pulls listing. */
|
|
14
14
|
export interface RestPull {
|
|
15
15
|
readonly number?: number
|
|
16
|
+
readonly title?: string
|
|
16
17
|
readonly body?: string | null
|
|
17
18
|
readonly head?: { readonly ref?: string; readonly sha?: string }
|
|
18
19
|
readonly base?: { readonly ref?: string }
|
|
@@ -42,6 +43,7 @@ export interface PullIdentity {
|
|
|
42
43
|
readonly head?: string
|
|
43
44
|
readonly base?: string
|
|
44
45
|
readonly mergeState?: string
|
|
46
|
+
readonly body?: string
|
|
45
47
|
}
|
|
46
48
|
|
|
47
49
|
export type BranchPull =
|
|
@@ -70,6 +72,7 @@ export function identityOf(row: RestPull): PullIdentity {
|
|
|
70
72
|
...(row.head?.sha !== undefined && { head: row.head.sha }),
|
|
71
73
|
...(row.base?.ref !== undefined && { base: row.base.ref }),
|
|
72
74
|
...(mergeState !== undefined && { mergeState }),
|
|
75
|
+
...(typeof row.body === 'string' && { body: row.body }),
|
|
73
76
|
}
|
|
74
77
|
}
|
|
75
78
|
|
package/src/pr/review-scope.ts
CHANGED
|
@@ -136,11 +136,21 @@ export const UI_HEADINGS = ['## UI review', '## UI review closed'] as const
|
|
|
136
136
|
|
|
137
137
|
export type UiHeading = (typeof UI_HEADINGS)[number]
|
|
138
138
|
|
|
139
|
+
/** The four totals on a verdict's count line. */
|
|
140
|
+
export interface UiCounts {
|
|
141
|
+
readonly failed: number
|
|
142
|
+
readonly eyes: number
|
|
143
|
+
readonly passed: number
|
|
144
|
+
readonly notDriven: number
|
|
145
|
+
}
|
|
146
|
+
|
|
139
147
|
export interface UiScope {
|
|
140
148
|
readonly heading?: UiHeading
|
|
141
149
|
readonly state: ReviewState
|
|
142
150
|
/** The head the verdict covered, a full or abbreviated sha. */
|
|
143
151
|
readonly commit?: string
|
|
152
|
+
/** The counts the verdict wrote, absent when its count line does not parse. */
|
|
153
|
+
readonly counts?: UiCounts
|
|
144
154
|
/** `marker` is the verdict's own record, `fallback` is GitHub's stamp. */
|
|
145
155
|
readonly source: ScopeSource
|
|
146
156
|
}
|
|
@@ -152,6 +162,32 @@ export interface UiScope {
|
|
|
152
162
|
*/
|
|
153
163
|
const UI_MARKER = /^<!--\s*review-ui:\s*head=([0-9a-f]{7,40})\s*-->$/
|
|
154
164
|
|
|
165
|
+
/**
|
|
166
|
+
* The count line `review-ui`'s `post.md` fixes as the verdict's first line
|
|
167
|
+
* under the heading. It is model-written prose, so a body that drifts from the
|
|
168
|
+
* form reads as no counts and a caller treats that as unchecked.
|
|
169
|
+
*/
|
|
170
|
+
const UI_COUNTS =
|
|
171
|
+
/^(\d+) failed · (\d+) need your eyes · (\d+) passed · (\d+) not driven$/
|
|
172
|
+
|
|
173
|
+
export function readUiCounts(body: string): UiCounts | undefined {
|
|
174
|
+
for (const raw of body.split('\n').slice(1, 6)) {
|
|
175
|
+
const match = UI_COUNTS.exec(raw.trim())
|
|
176
|
+
if (match === null) continue
|
|
177
|
+
const [, failed, eyes, passed, notDriven] = match.map(Number)
|
|
178
|
+
if (
|
|
179
|
+
failed === undefined ||
|
|
180
|
+
eyes === undefined ||
|
|
181
|
+
passed === undefined ||
|
|
182
|
+
notDriven === undefined
|
|
183
|
+
) {
|
|
184
|
+
return undefined
|
|
185
|
+
}
|
|
186
|
+
return { failed, eyes, passed, notDriven }
|
|
187
|
+
}
|
|
188
|
+
return undefined
|
|
189
|
+
}
|
|
190
|
+
|
|
155
191
|
function lastLine(body: string): string | undefined {
|
|
156
192
|
const lines = body.split('\n')
|
|
157
193
|
|
|
@@ -185,9 +221,11 @@ export function resolveUiScope(listing: ReviewListing): UiScope {
|
|
|
185
221
|
const line = lastLine(row.body ?? '')
|
|
186
222
|
const match = line === undefined ? null : UI_MARKER.exec(line)
|
|
187
223
|
const marked = match?.[1]
|
|
224
|
+
const counts = readUiCounts(row.body ?? '')
|
|
225
|
+
const counted = counts === undefined ? {} : { counts }
|
|
188
226
|
|
|
189
227
|
if (marked !== undefined) {
|
|
190
|
-
return { heading, state, commit: marked, source: 'marker' }
|
|
228
|
+
return { heading, state, commit: marked, ...counted, source: 'marker' }
|
|
191
229
|
}
|
|
192
230
|
|
|
193
231
|
const oid = row.commit?.oid
|
|
@@ -195,6 +233,7 @@ export function resolveUiScope(listing: ReviewListing): UiScope {
|
|
|
195
233
|
heading,
|
|
196
234
|
state,
|
|
197
235
|
...(oid === undefined || oid === '' ? {} : { commit: oid }),
|
|
236
|
+
...counted,
|
|
198
237
|
source: 'fallback',
|
|
199
238
|
}
|
|
200
239
|
}
|
package/src/pr/tick.ts
CHANGED
|
@@ -1,11 +1,120 @@
|
|
|
1
1
|
import {
|
|
2
|
+
type ChecklistBox,
|
|
2
3
|
CHECKLIST_END,
|
|
3
4
|
CHECKLIST_START,
|
|
4
5
|
parseBoxLine,
|
|
5
6
|
readChecklistBoxes,
|
|
7
|
+
SHORT_SHA_LENGTH,
|
|
6
8
|
writeBoxLine,
|
|
7
9
|
} from '@/pr/evidence'
|
|
8
10
|
|
|
11
|
+
export const UI_PASS_MARKER = '<!-- pr-ui-pass -->'
|
|
12
|
+
const TESTING_HEADING = /^## Testing\s*$/
|
|
13
|
+
const SECTION_HEADING = /^## /
|
|
14
|
+
const REVIEWER_HEADING = /^## For the reviewer\s*$/
|
|
15
|
+
const UI_PASS_HEAD = /at `([0-9a-f]+)`/
|
|
16
|
+
|
|
17
|
+
/** What the UI pass line reports, either a pass or a reset to a head nobody has driven. */
|
|
18
|
+
export type UiPassSummary =
|
|
19
|
+
| {
|
|
20
|
+
readonly kind: 'passed'
|
|
21
|
+
/** Driven boxes ticked, taste boxes excluded. */
|
|
22
|
+
readonly passed: number
|
|
23
|
+
/** Boxes a driver can run, taste boxes excluded. */
|
|
24
|
+
readonly total: number
|
|
25
|
+
readonly taste: number
|
|
26
|
+
readonly head: string
|
|
27
|
+
}
|
|
28
|
+
| { readonly kind: 'reset'; readonly head: string }
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The one `## Testing` line a UI pass owns. It is ticked only when every box
|
|
32
|
+
* a driver can run passed, so a failed box never reads as done, and taste
|
|
33
|
+
* boxes are counted apart so a full pass never reads as one box failed.
|
|
34
|
+
*/
|
|
35
|
+
export function uiPassLine(summary: UiPassSummary): string {
|
|
36
|
+
const at = `\`${summary.head.slice(0, SHORT_SHA_LENGTH)}\``
|
|
37
|
+
if (summary.kind === 'reset') {
|
|
38
|
+
return `- [ ] UI pass, not run at ${at} ${UI_PASS_MARKER}`
|
|
39
|
+
}
|
|
40
|
+
const taste =
|
|
41
|
+
summary.taste === 0
|
|
42
|
+
? ''
|
|
43
|
+
: `${summary.taste} taste ${summary.taste === 1 ? 'box' : 'boxes'} left to the operator`
|
|
44
|
+
if (summary.total === 0) {
|
|
45
|
+
return `- [ ] UI pass, no box a driver can run, ${taste} at ${at} ${UI_PASS_MARKER}`
|
|
46
|
+
}
|
|
47
|
+
const box = summary.passed === summary.total ? 'x' : ' '
|
|
48
|
+
const tail = taste === '' ? '' : `, ${taste}`
|
|
49
|
+
return `- [${box}] UI pass, ${summary.passed} of ${summary.total} boxes passed at ${at}${tail} ${UI_PASS_MARKER}`
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The driven boxes ticked at the head. A tick counts as this pass's only when
|
|
54
|
+
* stamped at the head, since an unstamped one is the author's claim and an
|
|
55
|
+
* older stamp names another head. Taste boxes are left out, as the body line
|
|
56
|
+
* leaves them out.
|
|
57
|
+
*/
|
|
58
|
+
export function countTicksAtHead(
|
|
59
|
+
boxes: readonly ChecklistBox[],
|
|
60
|
+
head: string,
|
|
61
|
+
): number {
|
|
62
|
+
return boxes.filter(
|
|
63
|
+
(box) =>
|
|
64
|
+
!box.isTaste &&
|
|
65
|
+
box.isTicked &&
|
|
66
|
+
box.stamp !== undefined &&
|
|
67
|
+
head.startsWith(box.stamp),
|
|
68
|
+
).length
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** The short sha the marked line names, absent when the body carries no such line. */
|
|
72
|
+
export function readUiPassHead(body: string): string | undefined {
|
|
73
|
+
const line = body.split('\n').find((row) => row.includes(UI_PASS_MARKER))
|
|
74
|
+
return line === undefined ? undefined : UI_PASS_HEAD.exec(line)?.[1]
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Puts the UI pass line in a pull request body and touches nothing else.
|
|
79
|
+
* The marked line is replaced where it stands, else appended to the end of
|
|
80
|
+
* `## Testing`, which is created ahead of `## For the reviewer` when absent.
|
|
81
|
+
*/
|
|
82
|
+
export function writeUiPassLine(body: string, summary: UiPassSummary): string {
|
|
83
|
+
const line = uiPassLine(summary)
|
|
84
|
+
const lines = body.split('\n')
|
|
85
|
+
|
|
86
|
+
const marked = lines.findIndex((row) => row.includes(UI_PASS_MARKER))
|
|
87
|
+
if (marked !== -1) {
|
|
88
|
+
lines[marked] = line
|
|
89
|
+
return lines.join('\n')
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const heading = lines.findIndex((row) => TESTING_HEADING.test(row))
|
|
93
|
+
if (heading === -1) {
|
|
94
|
+
const reviewer = lines.findIndex((row) => REVIEWER_HEADING.test(row))
|
|
95
|
+
if (reviewer === -1) {
|
|
96
|
+
return `${body.trimEnd()}\n\n## Testing\n\n${line}\n`
|
|
97
|
+
}
|
|
98
|
+
lines.splice(reviewer, 0, '## Testing', '', line, '')
|
|
99
|
+
return lines.join('\n')
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const next = lines.findIndex(
|
|
103
|
+
(row, index) => index > heading && SECTION_HEADING.test(row),
|
|
104
|
+
)
|
|
105
|
+
let last = next === -1 ? lines.length - 1 : next - 1
|
|
106
|
+
while (last > heading && lines[last]?.trim() === '') last -= 1
|
|
107
|
+
|
|
108
|
+
if (last > heading) {
|
|
109
|
+
lines.splice(last + 1, 0, line)
|
|
110
|
+
} else if (lines[heading + 1] === '') {
|
|
111
|
+
lines.splice(heading + 2, 0, line)
|
|
112
|
+
} else {
|
|
113
|
+
lines.splice(heading + 1, 0, '', line)
|
|
114
|
+
}
|
|
115
|
+
return lines.join('\n')
|
|
116
|
+
}
|
|
117
|
+
|
|
9
118
|
export type TickRefusal = 'no-checklist' | 'no-box' | 'taste-box'
|
|
10
119
|
|
|
11
120
|
export type TickResult =
|
package/src/sessions/live.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { existsSync, readFileSync, readlinkSync } from 'node:fs'
|
|
2
|
+
import { createConnection } from 'node:net'
|
|
2
3
|
import type { SessionRecord } from '@/sessions/registry'
|
|
3
4
|
|
|
4
5
|
/**
|
|
@@ -35,6 +36,12 @@ export interface LivenessProbes {
|
|
|
35
36
|
readonly ownDomain?: () => string | null
|
|
36
37
|
/** Whether the messaging socket a foreign record names still exists. */
|
|
37
38
|
readonly socketExists?: (path: string) => boolean
|
|
39
|
+
/**
|
|
40
|
+
* Whether something accepts a connection on the socket within the timeout.
|
|
41
|
+
* Async, so `liveness()` never calls it and a caller with no use for it, such
|
|
42
|
+
* as the gate's lock, states nothing.
|
|
43
|
+
*/
|
|
44
|
+
readonly listens?: (path: string, timeoutMs: number) => Promise<boolean>
|
|
38
45
|
}
|
|
39
46
|
|
|
40
47
|
/**
|
|
@@ -74,11 +81,30 @@ function ownDomain(): string | null {
|
|
|
74
81
|
}
|
|
75
82
|
}
|
|
76
83
|
|
|
84
|
+
/**
|
|
85
|
+
* Connects and closes without writing, so the peer sees no bytes. The socket is
|
|
86
|
+
* destroyed on connect rather than waited on, since a listener that accepts and
|
|
87
|
+
* never closes would otherwise hold the probe open past its timeout.
|
|
88
|
+
*/
|
|
89
|
+
function listens(path: string, timeoutMs: number): Promise<boolean> {
|
|
90
|
+
return new Promise((resolve) => {
|
|
91
|
+
const socket = createConnection(path)
|
|
92
|
+
const settle = (accepted: boolean): void => {
|
|
93
|
+
socket.destroy()
|
|
94
|
+
resolve(accepted)
|
|
95
|
+
}
|
|
96
|
+
socket.setTimeout(timeoutMs, () => settle(false))
|
|
97
|
+
socket.once('connect', () => settle(true))
|
|
98
|
+
socket.once('error', () => settle(false))
|
|
99
|
+
})
|
|
100
|
+
}
|
|
101
|
+
|
|
77
102
|
export const SYSTEM_PROBES: LivenessProbes = {
|
|
78
103
|
procStartOf,
|
|
79
104
|
responds,
|
|
80
105
|
ownDomain,
|
|
81
106
|
socketExists: existsSync,
|
|
107
|
+
listens,
|
|
82
108
|
}
|
|
83
109
|
|
|
84
110
|
/**
|
package/src/sessions/resolve.ts
CHANGED
|
@@ -23,11 +23,18 @@ export interface ResolvedSession {
|
|
|
23
23
|
readonly kind: string
|
|
24
24
|
readonly status: string
|
|
25
25
|
/**
|
|
26
|
-
* What decided the row was live. `
|
|
27
|
-
*
|
|
28
|
-
*
|
|
26
|
+
* What decided the row was live. `pid` is a start time matched or a pid that
|
|
27
|
+
* answered, `socket` means the record belongs to another pid namespace and
|
|
28
|
+
* only its messaging socket file existed, so the row can be a crashed
|
|
29
|
+
* session's leftover, and `listener` means the messaging socket accepted a
|
|
30
|
+
* connection, which confirms the row wherever the start time cannot be read.
|
|
29
31
|
*/
|
|
30
|
-
readonly via: 'pid' | 'socket'
|
|
32
|
+
readonly via: 'pid' | 'socket' | 'listener'
|
|
33
|
+
/**
|
|
34
|
+
* How this row alone was decided, so a caller scoping the roster derives its
|
|
35
|
+
* confidence over the rows it keeps rather than over the whole machine.
|
|
36
|
+
*/
|
|
37
|
+
readonly confidence: Confidence
|
|
31
38
|
readonly startedAt: string | null
|
|
32
39
|
/**
|
|
33
40
|
* The stamp the record carries beside `status`, kept alongside the dwell it
|
|
@@ -80,6 +87,22 @@ export type SessionReport =
|
|
|
80
87
|
readonly sessions: readonly ResolvedSession[]
|
|
81
88
|
}
|
|
82
89
|
|
|
90
|
+
/**
|
|
91
|
+
* The weakest confidence among the rows, so one read tells a caller whether
|
|
92
|
+
* every row it holds is an identity. A caller scoping the roster calls this on
|
|
93
|
+
* the rows it keeps, since one unrelated row it dropped must not weaken it.
|
|
94
|
+
*/
|
|
95
|
+
export function confidenceOf(
|
|
96
|
+
sessions: readonly Pick<ResolvedSession, 'confidence'>[],
|
|
97
|
+
): Confidence {
|
|
98
|
+
return sessions.every((session) => session.confidence === 'confirmed')
|
|
99
|
+
? 'confirmed'
|
|
100
|
+
: 'unverified'
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** A live socket here accepted in 2 ms, and every probe runs in parallel. */
|
|
104
|
+
const LISTEN_TIMEOUT_MS = 250
|
|
105
|
+
|
|
83
106
|
export interface ResolveOptions {
|
|
84
107
|
readonly dir?: string
|
|
85
108
|
readonly probes?: LivenessProbes
|
|
@@ -181,6 +204,7 @@ function present(
|
|
|
181
204
|
located: Located,
|
|
182
205
|
now: number,
|
|
183
206
|
via: ResolvedSession['via'],
|
|
207
|
+
confidence: Confidence,
|
|
184
208
|
): ResolvedSession {
|
|
185
209
|
return {
|
|
186
210
|
name: record.name,
|
|
@@ -190,6 +214,7 @@ function present(
|
|
|
190
214
|
kind: record.kind ?? 'unknown',
|
|
191
215
|
status: record.status ?? 'unknown',
|
|
192
216
|
via,
|
|
217
|
+
confidence,
|
|
193
218
|
startedAt:
|
|
194
219
|
record.startedAt === undefined
|
|
195
220
|
? null
|
|
@@ -228,8 +253,11 @@ export async function resolveSessions(
|
|
|
228
253
|
|
|
229
254
|
if (registry.kind === 'absent') return { kind: 'absent', dir: registry.dir }
|
|
230
255
|
|
|
231
|
-
const live: {
|
|
232
|
-
|
|
256
|
+
const live: {
|
|
257
|
+
record: SessionRecord
|
|
258
|
+
via: ResolvedSession['via']
|
|
259
|
+
confidence: Confidence
|
|
260
|
+
}[] = []
|
|
233
261
|
let foreignDead = 0
|
|
234
262
|
|
|
235
263
|
for (const record of registry.records) {
|
|
@@ -238,20 +266,43 @@ export async function resolveSessions(
|
|
|
238
266
|
if (state.foreign) foreignDead += 1
|
|
239
267
|
continue
|
|
240
268
|
}
|
|
241
|
-
|
|
242
|
-
|
|
269
|
+
live.push({
|
|
270
|
+
record,
|
|
271
|
+
via: state.foreign ? 'socket' : 'pid',
|
|
272
|
+
confidence: state.confidence,
|
|
273
|
+
})
|
|
243
274
|
}
|
|
244
275
|
|
|
276
|
+
// Only a row the start time left unverified is probed, so a machine that reads
|
|
277
|
+
// /proc pays nothing. An accept proves the session is running where a pid
|
|
278
|
+
// answering or a socket file existing does not.
|
|
279
|
+
const upgraded = await Promise.all(
|
|
280
|
+
live.map(async (row) => {
|
|
281
|
+
const socket = row.record.messagingSocketPath
|
|
282
|
+
if (
|
|
283
|
+
row.confidence === 'confirmed' ||
|
|
284
|
+
socket === undefined ||
|
|
285
|
+
probes.listens === undefined
|
|
286
|
+
) {
|
|
287
|
+
return row
|
|
288
|
+
}
|
|
289
|
+
const accepted = await probes.listens(socket, LISTEN_TIMEOUT_MS)
|
|
290
|
+
return accepted
|
|
291
|
+
? { ...row, via: 'listener' as const, confidence: 'confirmed' as const }
|
|
292
|
+
: row
|
|
293
|
+
}),
|
|
294
|
+
)
|
|
295
|
+
|
|
245
296
|
const sessions = await Promise.all(
|
|
246
|
-
|
|
247
|
-
present(record, await find(record.cwd), now, via),
|
|
297
|
+
upgraded.map(async ({ record, via, confidence }) =>
|
|
298
|
+
present(record, await find(record.cwd), now, via, confidence),
|
|
248
299
|
),
|
|
249
300
|
)
|
|
250
301
|
|
|
251
302
|
return {
|
|
252
303
|
kind: 'resolved',
|
|
253
304
|
dir: registry.dir,
|
|
254
|
-
confidence,
|
|
305
|
+
confidence: confidenceOf(sessions),
|
|
255
306
|
foreignDead,
|
|
256
307
|
sessions,
|
|
257
308
|
}
|