@erclx/aitk 3.43.2 → 3.44.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/{toolkit-cli → aitk-cli}/REQUIREMENT.md +4 -4
- package/claude/skills/{toolkit-cli → aitk-cli}/SKILL.md +1 -1
- package/claude/skills/{toolkit-feedback → aitk-feedback-file}/REQUIREMENT.md +3 -3
- package/claude/skills/{toolkit-feedback → aitk-feedback-file}/SKILL.md +2 -2
- package/claude/skills/{toolkit-triage → aitk-feedback-triage}/REQUIREMENT.md +3 -3
- package/claude/skills/{toolkit-triage → aitk-feedback-triage}/SKILL.md +3 -3
- package/claude/skills/{toolkit-operator → aitk-operator}/REQUIREMENT.md +3 -3
- package/claude/skills/{toolkit-operator → aitk-operator}/SKILL.md +3 -3
- package/claude/skills/{claude-screencast → aitk-screencast}/REQUIREMENT.md +3 -3
- package/claude/skills/{claude-screencast → aitk-screencast}/SKILL.md +2 -2
- package/claude/skills/{claude-slides-draft → aitk-slides-draft}/REQUIREMENT.md +3 -3
- package/claude/skills/{claude-slides-draft → aitk-slides-draft}/SKILL.md +1 -1
- package/claude/skills/{cli-script → bash-cli-script}/REQUIREMENT.md +2 -2
- package/claude/skills/{cli-script → bash-cli-script}/SKILL.md +2 -2
- package/claude/skills/bash-script/REQUIREMENT.md +2 -2
- package/claude/skills/bash-script/SKILL.md +2 -2
- package/claude/skills/ci-workflow/REQUIREMENT.md +1 -1
- package/claude/skills/claude-memory-review/SKILL.md +2 -2
- package/claude/skills/claude-memory-review/references/receipt-format.md +1 -1
- package/claude/skills/claude-seed-sync/REQUIREMENT.md +1 -1
- package/claude/skills/claude-seed-sync/SKILL.md +1 -1
- package/claude/skills/git-issue/REQUIREMENT.md +2 -2
- package/claude/skills/git-issue/SKILL.md +1 -1
- package/claude/skills/{restate → restate-plainly}/REQUIREMENT.md +2 -2
- package/claude/skills/{restate → restate-plainly}/SKILL.md +2 -2
- package/claude/skills/setup-init/REQUIREMENT.md +1 -1
- package/claude/skills/setup-init/SKILL.md +1 -1
- package/claude/skills/write-human/REQUIREMENT.md +1 -1
- package/claude/skills/write-human/SKILL.md +1 -1
- package/docs/agents/demo.md +1 -1
- package/docs/agents/index.md +1 -0
- package/docs/agents/overview.md +2 -2
- package/docs/agents/scripting.md +1 -1
- package/docs/agents/sessions.md +11 -5
- package/docs/agents/targets.md +83 -0
- package/docs/ai-workflow.md +16 -16
- package/docs/target-projects.md +1 -1
- package/governance/rules/lang/120-bash.md +1 -1
- package/package.json +1 -1
- package/scripts/core/regen-tooling-paths.sh +1 -1
- package/scripts/core/verify.sh +1 -1
- package/src/claude/cases/authoring.ts +2 -2
- package/src/claude/cases/claude-workflow.ts +2 -2
- package/src/claude/cases/setup.ts +6 -6
- package/src/cli.ts +3 -0
- package/src/commands/demo.ts +1 -1
- package/src/commands/sessions.ts +24 -8
- package/src/commands/targets.ts +319 -0
- package/src/demo/beats.ts +1 -1
- package/src/sessions/claim.ts +7 -0
- package/src/sync/stamp.ts +9 -0
- package/src/targets/pulls.ts +250 -0
- package/src/targets/registry.ts +161 -0
- package/src/targets/resolve.ts +145 -0
- package/src/targets/sweep.ts +246 -0
- package/standards/issue.md +1 -1
- /package/claude/skills/{cli-script → bash-cli-script}/references/template.md +0 -0
package/src/sync/stamp.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { existsSync, readFileSync } from 'node:fs'
|
|
|
3
3
|
import { mkdir, writeFile } from 'node:fs/promises'
|
|
4
4
|
import { dirname, join, sep } from 'node:path'
|
|
5
5
|
import { execa } from 'execa'
|
|
6
|
+
import { recordTarget } from '@/targets/registry'
|
|
6
7
|
|
|
7
8
|
/**
|
|
8
9
|
* Domains the stamp can record. Governance attributes file by file through the
|
|
@@ -209,6 +210,14 @@ async function putDomain(
|
|
|
209
210
|
const path = stampPath(target)
|
|
210
211
|
await mkdir(dirname(path), { recursive: true })
|
|
211
212
|
await writeFile(path, `${JSON.stringify(stamp, null, 2)}\n`)
|
|
213
|
+
|
|
214
|
+
// Every install and sync that stamps a target passes through here, which is
|
|
215
|
+
// what makes this the one place the machine-level index can be kept without
|
|
216
|
+
// each command remembering to. Its outcome is dropped rather than reported:
|
|
217
|
+
// the stamp just written is the authoritative record of this install, the
|
|
218
|
+
// index is a cache over every such stamp, and a state folder nobody can
|
|
219
|
+
// write is not a reason to fail a sync that already landed its files.
|
|
220
|
+
recordTarget(target, now)
|
|
212
221
|
}
|
|
213
222
|
|
|
214
223
|
const commitCache = new Map<string, Promise<string | undefined>>()
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
import { execa } from 'execa'
|
|
2
|
+
import { gitEnv } from '@/git-env'
|
|
3
|
+
import { isDirectory } from '@/target'
|
|
4
|
+
|
|
5
|
+
const GH_TIMEOUT_MS = 30_000
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The two headings a review pass posts under.
|
|
9
|
+
*
|
|
10
|
+
* Owned by `claude-pr-review`, which states the full set once, and pinned here
|
|
11
|
+
* the way `claude-orchestrate/scripts/poll.sh` pins them. All three surfaces
|
|
12
|
+
* ship separately, so a heading added in that skill goes stale here with
|
|
13
|
+
* nothing comparing the copies.
|
|
14
|
+
*/
|
|
15
|
+
const REVIEW_OPEN = '## Review'
|
|
16
|
+
const REVIEW_CLOSED = '## Review closed'
|
|
17
|
+
|
|
18
|
+
/** Why a target produced no reading, so an unreachable one never reads as having no work. */
|
|
19
|
+
export type TargetRefusal = 'not-a-directory' | 'gh-unavailable' | 'list-failed'
|
|
20
|
+
|
|
21
|
+
export type ChecksState = 'passing' | 'failing' | 'pending'
|
|
22
|
+
|
|
23
|
+
/** Whether the newest review pass left work owed. */
|
|
24
|
+
export type ReviewState = 'open' | 'closed'
|
|
25
|
+
|
|
26
|
+
export interface PullState {
|
|
27
|
+
readonly number: number
|
|
28
|
+
readonly title: string
|
|
29
|
+
readonly url: string
|
|
30
|
+
readonly head: string
|
|
31
|
+
/** Null when GitHub reported no check at all, which is not the same answer as passing. */
|
|
32
|
+
readonly checks: ChecksState | null
|
|
33
|
+
/** Null when no pass carrying a review heading has landed on the thread. */
|
|
34
|
+
readonly review: ReviewState | null
|
|
35
|
+
/** False when the review read failed, leaving `review` covering nothing. */
|
|
36
|
+
readonly reviewReadable: boolean
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export type TargetPulls =
|
|
40
|
+
| {
|
|
41
|
+
readonly kind: 'refused'
|
|
42
|
+
readonly path: string
|
|
43
|
+
readonly reason: TargetRefusal
|
|
44
|
+
}
|
|
45
|
+
| {
|
|
46
|
+
readonly kind: 'read'
|
|
47
|
+
readonly path: string
|
|
48
|
+
readonly pulls: readonly PullState[]
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Runs one `gh` invocation in a target and hands back its stdout, or null when it failed. */
|
|
52
|
+
export type GhRunner = (
|
|
53
|
+
cwd: string,
|
|
54
|
+
args: readonly string[],
|
|
55
|
+
) => Promise<string | null>
|
|
56
|
+
|
|
57
|
+
export interface PullsOptions {
|
|
58
|
+
readonly run?: GhRunner
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* `gh` resolves its repository through the same environment variables git does
|
|
63
|
+
* and they beat `cwd`, so a run from inside a hook would read whichever
|
|
64
|
+
* repository that hook's environment names rather than the target handed here.
|
|
65
|
+
*/
|
|
66
|
+
const runGh: GhRunner = async (cwd, args) => {
|
|
67
|
+
if (Bun.which('gh') === null) return null
|
|
68
|
+
|
|
69
|
+
try {
|
|
70
|
+
const result = await execa('gh', [...args], {
|
|
71
|
+
cwd,
|
|
72
|
+
timeout: GH_TIMEOUT_MS,
|
|
73
|
+
env: gitEnv(),
|
|
74
|
+
extendEnv: false,
|
|
75
|
+
})
|
|
76
|
+
return result.stdout
|
|
77
|
+
} catch {
|
|
78
|
+
return null
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
interface RawPull {
|
|
83
|
+
readonly number?: number
|
|
84
|
+
readonly title?: string
|
|
85
|
+
readonly url?: string
|
|
86
|
+
readonly headRefOid?: string
|
|
87
|
+
readonly statusCheckRollup?: readonly RawCheck[]
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
interface RawCheck {
|
|
91
|
+
readonly status?: string
|
|
92
|
+
readonly conclusion?: string
|
|
93
|
+
readonly state?: string
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
interface RawReview {
|
|
97
|
+
readonly body?: string
|
|
98
|
+
readonly submittedAt?: string
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const FAILED = new Set([
|
|
102
|
+
'FAILURE',
|
|
103
|
+
'TIMED_OUT',
|
|
104
|
+
'CANCELLED',
|
|
105
|
+
'ACTION_REQUIRED',
|
|
106
|
+
'STARTUP_FAILURE',
|
|
107
|
+
'ERROR',
|
|
108
|
+
])
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Collapses every check on a head into one word.
|
|
112
|
+
*
|
|
113
|
+
* A failure outranks a pending one, because a run still going cannot clear a
|
|
114
|
+
* job that already failed and reporting the head as pending would invite a
|
|
115
|
+
* wait for an answer that has arrived.
|
|
116
|
+
*/
|
|
117
|
+
export function rollup(checks: readonly RawCheck[]): ChecksState | null {
|
|
118
|
+
if (checks.length === 0) return null
|
|
119
|
+
|
|
120
|
+
const verdicts = checks.map((check) => check.conclusion ?? check.state ?? '')
|
|
121
|
+
|
|
122
|
+
if (verdicts.some((verdict) => FAILED.has(verdict))) return 'failing'
|
|
123
|
+
|
|
124
|
+
const running = checks.some(
|
|
125
|
+
(check) =>
|
|
126
|
+
(check.status !== undefined && check.status !== 'COMPLETED') ||
|
|
127
|
+
check.state === 'PENDING' ||
|
|
128
|
+
(check.conclusion === undefined && check.state === undefined),
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
return running ? 'pending' : 'passing'
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Reads the heading of the newest pass carrying one, matching on the first line
|
|
136
|
+
* alone the way `poll.sh` does.
|
|
137
|
+
*
|
|
138
|
+
* The reviews arrive oldest first, so the last match is the current state of
|
|
139
|
+
* the thread. A pass carrying neither heading is somebody reviewing by hand and
|
|
140
|
+
* says nothing about whether the loop owes work.
|
|
141
|
+
*/
|
|
142
|
+
export function latestReview(
|
|
143
|
+
reviews: readonly RawReview[],
|
|
144
|
+
): ReviewState | null {
|
|
145
|
+
let state: ReviewState | null = null
|
|
146
|
+
|
|
147
|
+
for (const review of reviews) {
|
|
148
|
+
const first = (review.body ?? '').split('\n')[0]?.replace(/\r$/, '')
|
|
149
|
+
if (first === REVIEW_OPEN) state = 'open'
|
|
150
|
+
else if (first === REVIEW_CLOSED) state = 'closed'
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return state
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function parse<T>(text: string | null): T | null {
|
|
157
|
+
if (text === null) return null
|
|
158
|
+
try {
|
|
159
|
+
return JSON.parse(text) as T
|
|
160
|
+
} catch {
|
|
161
|
+
return null
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Reports the open pull requests in one target with their checks and the
|
|
167
|
+
* heading their newest review pass carries.
|
|
168
|
+
*
|
|
169
|
+
* A list that failed and a target with no open pull request are separated
|
|
170
|
+
* rather than collapsed, since reading the first as the second reports a target
|
|
171
|
+
* as done when nothing was read at all. That is the failure mode the shell loop
|
|
172
|
+
* this replaces had no way to surface.
|
|
173
|
+
*/
|
|
174
|
+
export async function readTargetPulls(
|
|
175
|
+
path: string,
|
|
176
|
+
opts: PullsOptions = {},
|
|
177
|
+
): Promise<TargetPulls> {
|
|
178
|
+
const run = opts.run ?? runGh
|
|
179
|
+
|
|
180
|
+
if (!isDirectory(path))
|
|
181
|
+
return { kind: 'refused', path, reason: 'not-a-directory' }
|
|
182
|
+
|
|
183
|
+
if (opts.run === undefined && Bun.which('gh') === null) {
|
|
184
|
+
return { kind: 'refused', path, reason: 'gh-unavailable' }
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const listed = parse<readonly RawPull[]>(
|
|
188
|
+
await run(path, [
|
|
189
|
+
'pr',
|
|
190
|
+
'list',
|
|
191
|
+
'--state',
|
|
192
|
+
'open',
|
|
193
|
+
'--json',
|
|
194
|
+
'number,title,url,headRefOid,statusCheckRollup',
|
|
195
|
+
]),
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
if (listed === null) return { kind: 'refused', path, reason: 'list-failed' }
|
|
199
|
+
|
|
200
|
+
const pulls = await Promise.all(
|
|
201
|
+
listed
|
|
202
|
+
.filter(
|
|
203
|
+
(raw): raw is RawPull & { number: number } =>
|
|
204
|
+
typeof raw.number === 'number',
|
|
205
|
+
)
|
|
206
|
+
.map(async (raw) => {
|
|
207
|
+
// One query per pull request, so a review read that failed surfaces on
|
|
208
|
+
// the thread it failed for rather than emptying the whole target.
|
|
209
|
+
const reviews = parse<{ reviews?: readonly RawReview[] }>(
|
|
210
|
+
await run(path, [
|
|
211
|
+
'pr',
|
|
212
|
+
'view',
|
|
213
|
+
String(raw.number),
|
|
214
|
+
'--json',
|
|
215
|
+
'reviews',
|
|
216
|
+
]),
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
return {
|
|
220
|
+
number: raw.number,
|
|
221
|
+
title: raw.title ?? '',
|
|
222
|
+
url: raw.url ?? '',
|
|
223
|
+
head: raw.headRefOid ?? '',
|
|
224
|
+
checks: rollup(raw.statusCheckRollup ?? []),
|
|
225
|
+
review: reviews === null ? null : latestReview(reviews.reviews ?? []),
|
|
226
|
+
reviewReadable: reviews !== null,
|
|
227
|
+
}
|
|
228
|
+
}),
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
return { kind: 'read', path, pulls }
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Reads every target, one at a time.
|
|
236
|
+
*
|
|
237
|
+
* The reads are serial rather than batched because each one spends a GitHub API
|
|
238
|
+
* quota shared across all of them, and a wave running over a dozen targets that
|
|
239
|
+
* fired them together would meet the secondary rate limit rather than an answer.
|
|
240
|
+
*/
|
|
241
|
+
export async function readPullsAcross(
|
|
242
|
+
paths: readonly string[],
|
|
243
|
+
opts: PullsOptions = {},
|
|
244
|
+
): Promise<readonly TargetPulls[]> {
|
|
245
|
+
const reports: TargetPulls[] = []
|
|
246
|
+
|
|
247
|
+
for (const path of paths) reports.push(await readTargetPulls(path, opts))
|
|
248
|
+
|
|
249
|
+
return reports
|
|
250
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
|
|
2
|
+
import { homedir } from 'node:os'
|
|
3
|
+
import { dirname, join, resolve } from 'node:path'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* One project the toolkit has installed into, as the install recorded it.
|
|
7
|
+
*
|
|
8
|
+
* The path is the target root rather than its git directory, because the
|
|
9
|
+
* record is written by a sync that knows where it wrote and not by anything
|
|
10
|
+
* that resolved a repository. Whether two entries are one project is a
|
|
11
|
+
* question about their origins, which `src/targets/sweep.ts` answers.
|
|
12
|
+
*/
|
|
13
|
+
export interface TargetRecord {
|
|
14
|
+
readonly path: string
|
|
15
|
+
/** ISO stamp of the most recent sync that recorded this target. */
|
|
16
|
+
readonly stampedAt: string
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* An absent file and an empty one are separate answers, the same split
|
|
21
|
+
* `src/sessions/registry.ts` draws.
|
|
22
|
+
*
|
|
23
|
+
* The first means no sync has ever recorded a target on this machine, so the
|
|
24
|
+
* population is unknown and the sweep is the only reading available. The
|
|
25
|
+
* second means the file was read and holds no usable row, which a caller
|
|
26
|
+
* should be able to tell apart from a lookup that never ran.
|
|
27
|
+
*/
|
|
28
|
+
export type TargetRegistry =
|
|
29
|
+
| { readonly kind: 'absent'; readonly path: string }
|
|
30
|
+
| {
|
|
31
|
+
readonly kind: 'read'
|
|
32
|
+
readonly path: string
|
|
33
|
+
readonly targets: readonly TargetRecord[]
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
interface StoredRegistry {
|
|
37
|
+
readonly version: number
|
|
38
|
+
readonly targets: readonly TargetRecord[]
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const VERSION = 1
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Resolves the folder holding this machine's toolkit state.
|
|
45
|
+
*
|
|
46
|
+
* Twin of `sandboxTree` in `src/commands/sandbox.ts`, which resolves the same
|
|
47
|
+
* three sources in the same order. The override exists so a test never writes
|
|
48
|
+
* into the home directory of whoever runs it.
|
|
49
|
+
*/
|
|
50
|
+
export function stateDir(): string {
|
|
51
|
+
const override = process.env.AITK_STATE_DIR
|
|
52
|
+
if (override !== undefined && override !== '') return override
|
|
53
|
+
|
|
54
|
+
const state = process.env.XDG_STATE_HOME
|
|
55
|
+
const base =
|
|
56
|
+
state !== undefined && state !== ''
|
|
57
|
+
? state
|
|
58
|
+
: join(homedir(), '.local', 'state')
|
|
59
|
+
|
|
60
|
+
return join(base, 'aitk')
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function registryPath(): string {
|
|
64
|
+
return join(stateDir(), 'targets.json')
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function isRecord(value: Partial<TargetRecord>): value is TargetRecord {
|
|
68
|
+
return (
|
|
69
|
+
typeof value.path === 'string' &&
|
|
70
|
+
value.path.length > 0 &&
|
|
71
|
+
typeof value.stampedAt === 'string' &&
|
|
72
|
+
value.stampedAt.length > 0
|
|
73
|
+
)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Reads every recorded target, sorted by path.
|
|
78
|
+
*
|
|
79
|
+
* A row missing either field is dropped rather than reported. This module is
|
|
80
|
+
* the file's only writer, so a malformed row is a hand edit or a truncated
|
|
81
|
+
* write and neither is a finding the caller can act on. What a caller can act
|
|
82
|
+
* on is the file being absent, which is its own kind above.
|
|
83
|
+
*/
|
|
84
|
+
export function readTargetRegistry(
|
|
85
|
+
path: string = registryPath(),
|
|
86
|
+
): TargetRegistry {
|
|
87
|
+
let text: string
|
|
88
|
+
try {
|
|
89
|
+
text = readFileSync(path, 'utf8')
|
|
90
|
+
} catch {
|
|
91
|
+
return { kind: 'absent', path }
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
let parsed: unknown
|
|
95
|
+
try {
|
|
96
|
+
parsed = JSON.parse(text)
|
|
97
|
+
} catch {
|
|
98
|
+
return { kind: 'read', path, targets: [] }
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (typeof parsed !== 'object' || parsed === null) {
|
|
102
|
+
return { kind: 'read', path, targets: [] }
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const stored = parsed as Partial<StoredRegistry>
|
|
106
|
+
const rows = Array.isArray(stored.targets) ? stored.targets : []
|
|
107
|
+
const targets = rows
|
|
108
|
+
.filter((row): row is TargetRecord =>
|
|
109
|
+
isRecord(row as Partial<TargetRecord>),
|
|
110
|
+
)
|
|
111
|
+
.sort((a, b) => a.path.localeCompare(b.path))
|
|
112
|
+
|
|
113
|
+
return { kind: 'read', path, targets }
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Why a record attempt did not land, so a caller can say so rather than assume it did. */
|
|
117
|
+
export type RecordOutcome = 'recorded' | 'unwritten'
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Records one target, keyed by its resolved path and replacing any row already
|
|
121
|
+
* held for it.
|
|
122
|
+
*
|
|
123
|
+
* The write is a temp file plus a rename, so a reader never meets a half
|
|
124
|
+
* written file. Two syncs finishing together still resolve last-writer-wins on
|
|
125
|
+
* the merged set, which can drop the row the loser added. That is left rather
|
|
126
|
+
* than locked: the authoritative record of an install is the stamp inside the
|
|
127
|
+
* target, this index is a cache over those, and the next sync of the dropped
|
|
128
|
+
* target restores its row.
|
|
129
|
+
*
|
|
130
|
+
* Nothing removes a row either, so a target that was deleted or that dropped
|
|
131
|
+
* the toolkit stays here and the count drifts upward. `aitk targets pulls`
|
|
132
|
+
* meets that on use, since it refuses a path it cannot open rather than
|
|
133
|
+
* reading it as a target with no work, but `aitk targets list` does not: it
|
|
134
|
+
* never opens a recorded path, and the count is its whole output.
|
|
135
|
+
*/
|
|
136
|
+
export function recordTarget(
|
|
137
|
+
target: string,
|
|
138
|
+
now: Date,
|
|
139
|
+
path: string = registryPath(),
|
|
140
|
+
): RecordOutcome {
|
|
141
|
+
const resolved = resolve(target)
|
|
142
|
+
const current = readTargetRegistry(path)
|
|
143
|
+
const existing = current.kind === 'read' ? current.targets : []
|
|
144
|
+
|
|
145
|
+
const targets = [
|
|
146
|
+
...existing.filter((row) => row.path !== resolved),
|
|
147
|
+
{ path: resolved, stampedAt: now.toISOString() },
|
|
148
|
+
].sort((a, b) => a.path.localeCompare(b.path))
|
|
149
|
+
|
|
150
|
+
const payload: StoredRegistry = { version: VERSION, targets }
|
|
151
|
+
const temp = `${path}.${process.pid}.tmp`
|
|
152
|
+
|
|
153
|
+
try {
|
|
154
|
+
mkdirSync(dirname(path), { recursive: true })
|
|
155
|
+
writeFileSync(temp, `${JSON.stringify(payload, null, 2)}\n`)
|
|
156
|
+
renameSync(temp, path)
|
|
157
|
+
return 'recorded'
|
|
158
|
+
} catch {
|
|
159
|
+
return 'unwritten'
|
|
160
|
+
}
|
|
161
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { resolve } from 'node:path'
|
|
2
|
+
import { isLegacyStamped } from '@/sync/stamp'
|
|
3
|
+
import {
|
|
4
|
+
readTargetRegistry,
|
|
5
|
+
type TargetRegistry,
|
|
6
|
+
registryPath,
|
|
7
|
+
} from '@/targets/registry'
|
|
8
|
+
import {
|
|
9
|
+
type SweepBound,
|
|
10
|
+
type SweepOptions,
|
|
11
|
+
sweepTargets,
|
|
12
|
+
} from '@/targets/sweep'
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Where a target came from, carried on the row rather than inferred.
|
|
16
|
+
*
|
|
17
|
+
* A caller deciding whether an answer is trustworthy needs to know which rows
|
|
18
|
+
* the machine recorded for itself and which a walk guessed at, and the two
|
|
19
|
+
* carry different bounds.
|
|
20
|
+
*/
|
|
21
|
+
export type TargetSource = 'given' | 'record' | 'sweep'
|
|
22
|
+
|
|
23
|
+
export interface KnownTarget {
|
|
24
|
+
/** Every checkout of this project on this machine, one for the ordinary case. */
|
|
25
|
+
readonly paths: readonly string[]
|
|
26
|
+
readonly origin: string | null
|
|
27
|
+
readonly source: TargetSource
|
|
28
|
+
/** When a sync last recorded this target, or null for a row only a sweep found. */
|
|
29
|
+
readonly stampedAt: string | null
|
|
30
|
+
/** True while the install stamp still sits at the retired path. */
|
|
31
|
+
readonly legacy: boolean
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface ResolvedTargets {
|
|
35
|
+
readonly targets: readonly KnownTarget[]
|
|
36
|
+
/** Null when the caller named its targets, so no registry read was attempted. */
|
|
37
|
+
readonly registry: TargetRegistry | null
|
|
38
|
+
/** Null when no sweep ran, which is the ordinary case. */
|
|
39
|
+
readonly bound: SweepBound | null
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface ResolveTargetsOptions extends SweepOptions {
|
|
43
|
+
/** Paths the caller named. These win outright and suppress both other sources. */
|
|
44
|
+
readonly paths?: readonly string[]
|
|
45
|
+
/** Roots to walk, supplementing the record rather than replacing it. */
|
|
46
|
+
readonly sweep?: readonly string[]
|
|
47
|
+
readonly registryFile?: string
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Answers which projects the toolkit has installed into.
|
|
52
|
+
*
|
|
53
|
+
* The record written at install time is the primary source and a walk is the
|
|
54
|
+
* fallback, which is the shape the population needs: a sweep alone cannot see
|
|
55
|
+
* another machine or a clone under a path nobody named, and that is exactly how
|
|
56
|
+
* the count moved from four to seven inside one pass and was then wrong in both
|
|
57
|
+
* directions at once.
|
|
58
|
+
*
|
|
59
|
+
* A caller naming paths gets those and no lookup at all, since it has already
|
|
60
|
+
* answered the question this resolves.
|
|
61
|
+
*/
|
|
62
|
+
export async function resolveTargets(
|
|
63
|
+
opts: ResolveTargetsOptions = {},
|
|
64
|
+
): Promise<ResolvedTargets> {
|
|
65
|
+
if (opts.paths !== undefined && opts.paths.length > 0) {
|
|
66
|
+
return {
|
|
67
|
+
targets: opts.paths.map((path) => given(resolve(path))),
|
|
68
|
+
registry: null,
|
|
69
|
+
bound: null,
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const file = opts.registryFile ?? registryPath()
|
|
74
|
+
const registry = readTargetRegistry(file)
|
|
75
|
+
|
|
76
|
+
const recorded: KnownTarget[] =
|
|
77
|
+
registry.kind === 'read'
|
|
78
|
+
? registry.targets.map((row) => ({
|
|
79
|
+
paths: [row.path],
|
|
80
|
+
origin: null,
|
|
81
|
+
source: 'record' as const,
|
|
82
|
+
stampedAt: row.stampedAt,
|
|
83
|
+
legacy: isLegacyStamped(row.path),
|
|
84
|
+
}))
|
|
85
|
+
: []
|
|
86
|
+
|
|
87
|
+
if (opts.sweep === undefined || opts.sweep.length === 0) {
|
|
88
|
+
return { targets: recorded, registry, bound: null }
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const swept = await sweepTargets(opts.sweep, opts)
|
|
92
|
+
const known = new Set(recorded.flatMap((target) => target.paths))
|
|
93
|
+
|
|
94
|
+
// A sweep row whose paths the record already holds is the same project read
|
|
95
|
+
// twice, so it adds nothing. A row holding one known path and one unknown one
|
|
96
|
+
// is the second-clone case, and it replaces the record's row rather than
|
|
97
|
+
// sitting beside it, since the sweep is the only source that can see both.
|
|
98
|
+
const added: KnownTarget[] = []
|
|
99
|
+
const superseded = new Set<string>()
|
|
100
|
+
|
|
101
|
+
for (const target of swept.targets) {
|
|
102
|
+
const overlap = target.paths.filter((path) => known.has(path))
|
|
103
|
+
|
|
104
|
+
if (overlap.length === target.paths.length) continue
|
|
105
|
+
|
|
106
|
+
for (const path of overlap) superseded.add(path)
|
|
107
|
+
|
|
108
|
+
// The recorded clone leads, because the record only names one a sync
|
|
109
|
+
// actually ran in, where the rest are checkouts a walk happened to find.
|
|
110
|
+
// Every caller reading a single path takes the first, and picking that by
|
|
111
|
+
// sort order is how a repair ran in one clone while the count was taken
|
|
112
|
+
// against another and the target read as untouched.
|
|
113
|
+
added.push({
|
|
114
|
+
paths: [...overlap, ...target.paths.filter((path) => !known.has(path))],
|
|
115
|
+
origin: target.origin,
|
|
116
|
+
source: overlap.length > 0 ? 'record' : 'sweep',
|
|
117
|
+
stampedAt:
|
|
118
|
+
recorded.find((row) => overlap.includes(row.paths[0] ?? ''))
|
|
119
|
+
?.stampedAt ?? null,
|
|
120
|
+
legacy: target.legacy,
|
|
121
|
+
})
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const kept = recorded.filter(
|
|
125
|
+
(row) => !row.paths.some((path) => superseded.has(path)),
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
return {
|
|
129
|
+
targets: [...kept, ...added].sort((a, b) =>
|
|
130
|
+
(a.paths[0] ?? '').localeCompare(b.paths[0] ?? ''),
|
|
131
|
+
),
|
|
132
|
+
registry,
|
|
133
|
+
bound: swept.bound,
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function given(path: string): KnownTarget {
|
|
138
|
+
return {
|
|
139
|
+
paths: [path],
|
|
140
|
+
origin: null,
|
|
141
|
+
source: 'given',
|
|
142
|
+
stampedAt: null,
|
|
143
|
+
legacy: isLegacyStamped(path),
|
|
144
|
+
}
|
|
145
|
+
}
|