@iceinvein/agent-skills 0.1.39 → 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/README.md +18 -2
- package/dist/cli/index.js +105 -28
- package/package.json +1 -1
- package/skills/index.json +14 -2
- package/skills/magpie/README.md +2 -1
- package/skills/magpie/SKILL.md +138 -45
- package/skills/magpie/bin/magpie.ts +43 -0
- package/skills/magpie/fixtures/example-pr/brief.json +18 -0
- package/skills/magpie/fixtures/fake-gh-nodiff.sh +38 -0
- package/skills/magpie/fixtures/fake-gh.sh +14 -0
- package/skills/magpie/package.json +1 -1
- package/skills/magpie/references/peer-review.md +7 -2
- package/skills/magpie/references/scout.md +90 -0
- package/skills/magpie/references/specialists.md +117 -6
- package/skills/magpie/scripts/__tests__/cli.test.ts +101 -1
- package/skills/magpie/scripts/__tests__/dedupe-cmd.test.ts +187 -0
- package/skills/magpie/scripts/__tests__/diff-chunks.test.ts +51 -0
- package/skills/magpie/scripts/__tests__/filter-diff-preservation.test.ts +54 -0
- package/skills/magpie/scripts/__tests__/findings-files.test.ts +35 -0
- package/skills/magpie/scripts/__tests__/gh.test.ts +90 -0
- package/skills/magpie/scripts/__tests__/git-diff.test.ts +83 -0
- package/skills/magpie/scripts/__tests__/helpers/git-fixture.ts +47 -0
- package/skills/magpie/scripts/__tests__/path-filter.test.ts +27 -0
- package/skills/magpie/scripts/__tests__/preview-cmd.test.ts +16 -0
- package/skills/magpie/scripts/__tests__/refresh.test.ts +45 -0
- package/skills/magpie/scripts/__tests__/render-cmd.test.ts +173 -1
- package/skills/magpie/scripts/__tests__/render-findings.test.ts +151 -1
- package/skills/magpie/scripts/__tests__/render-progress.test.ts +42 -0
- package/skills/magpie/scripts/__tests__/setup-cmd.test.ts +83 -1
- package/skills/magpie/scripts/__tests__/shard.test.ts +165 -0
- package/skills/magpie/scripts/__tests__/skill-lint.test.ts +207 -7
- package/skills/magpie/scripts/__tests__/types.test.ts +47 -0
- package/skills/magpie/scripts/dedupe-cmd.ts +58 -3
- package/skills/magpie/scripts/diff-chunks.ts +28 -0
- package/skills/magpie/scripts/findings-files.ts +32 -0
- package/skills/magpie/scripts/gh.ts +68 -14
- package/skills/magpie/scripts/git-diff.ts +111 -0
- package/skills/magpie/scripts/path-filter.ts +9 -5
- package/skills/magpie/scripts/preview-cmd.ts +11 -1
- package/skills/magpie/scripts/refresh.ts +32 -3
- package/skills/magpie/scripts/render-cmd.ts +35 -11
- package/skills/magpie/scripts/render-findings.ts +78 -2
- package/skills/magpie/scripts/render-progress.ts +6 -1
- package/skills/magpie/scripts/setup-cmd.ts +38 -1
- package/skills/magpie/scripts/shard.ts +171 -0
- package/skills/magpie/scripts/status-cmd.ts +7 -4
- package/skills/magpie/scripts/types.ts +50 -0
- package/skills/magpie/skill.json +2 -2
- package/skills/magpie/templates/styles.css +66 -0
- package/skills/migrate/README.md +194 -0
- package/skills/migrate/SKILL.md +197 -0
- package/skills/migrate/bin/migrate +15 -0
- package/skills/migrate/bin/migrate.ts +309 -0
- package/skills/migrate/biome.json +35 -0
- package/skills/migrate/bun.lock +24 -0
- package/skills/migrate/docs/architecture.md +294 -0
- package/skills/migrate/docs/reference.md +590 -0
- package/skills/migrate/fixtures/tiny-express/GROUND-TRUTH.md +39 -0
- package/skills/migrate/fixtures/tiny-express/app.js +29 -0
- package/skills/migrate/fixtures/tiny-express/cron.js +6 -0
- package/skills/migrate/fixtures/tiny-express/reports/daily-users.json +6 -0
- package/skills/migrate/fixtures/tiny-express/schema.sql +12 -0
- package/skills/migrate/fixtures/tiny-express/settings.json +4 -0
- package/skills/migrate/fixtures/tiny-express/views/users.html +9 -0
- package/skills/migrate/fixtures/tiny-webforms/Controllers/UsersController.cs +68 -0
- package/skills/migrate/fixtures/tiny-webforms/Default.aspx +7 -0
- package/skills/migrate/fixtures/tiny-webforms/Default.aspx.cs +14 -0
- package/skills/migrate/fixtures/tiny-webforms/GROUND-TRUTH.md +50 -0
- package/skills/migrate/fixtures/tiny-webforms/Integrations/BillingClient.cs +16 -0
- package/skills/migrate/fixtures/tiny-webforms/Jobs/NightlyDigestJob.cs +33 -0
- package/skills/migrate/fixtures/tiny-webforms/Reports/DailyUsers.rdl +11 -0
- package/skills/migrate/fixtures/tiny-webforms/Schema.sql +12 -0
- package/skills/migrate/fixtures/tiny-webforms/Site.master +16 -0
- package/skills/migrate/fixtures/tiny-webforms/Users.aspx +8 -0
- package/skills/migrate/fixtures/tiny-webforms/Users.aspx.cs +14 -0
- package/skills/migrate/fixtures/tiny-webforms/web.config +10 -0
- package/skills/migrate/install.sh +68 -0
- package/skills/migrate/package.json +17 -0
- package/skills/migrate/references/phases/enumerate.md +291 -0
- package/skills/migrate/references/phases/extract.md +652 -0
- package/skills/migrate/references/phases/parity.md +275 -0
- package/skills/migrate/references/phases/probe.md +135 -0
- package/skills/migrate/references/phases/queue.md +242 -0
- package/skills/migrate/references/phases/seam.md +416 -0
- package/skills/migrate/references/recipes/README.md +116 -0
- package/skills/migrate/references/recipes/aspnet.md +287 -0
- package/skills/migrate/references/run-ops.md +280 -0
- package/skills/migrate/scripts/__tests__/census.test.ts +775 -0
- package/skills/migrate/scripts/__tests__/check.test.ts +458 -0
- package/skills/migrate/scripts/__tests__/citations.test.ts +156 -0
- package/skills/migrate/scripts/__tests__/cli.test.ts +183 -0
- package/skills/migrate/scripts/__tests__/concurrency.test.ts +164 -0
- package/skills/migrate/scripts/__tests__/config.test.ts +112 -0
- package/skills/migrate/scripts/__tests__/e2e-express.test.ts +1093 -0
- package/skills/migrate/scripts/__tests__/e2e-webforms.test.ts +1276 -0
- package/skills/migrate/scripts/__tests__/e2e.test.ts +320 -0
- package/skills/migrate/scripts/__tests__/ids.test.ts +38 -0
- package/skills/migrate/scripts/__tests__/import.test.ts +155 -0
- package/skills/migrate/scripts/__tests__/init.test.ts +192 -0
- package/skills/migrate/scripts/__tests__/leaks.test.ts +176 -0
- package/skills/migrate/scripts/__tests__/lock.test.ts +183 -0
- package/skills/migrate/scripts/__tests__/paths.test.ts +129 -0
- package/skills/migrate/scripts/__tests__/phase-cmd.test.ts +151 -0
- package/skills/migrate/scripts/__tests__/phases.test.ts +70 -0
- package/skills/migrate/scripts/__tests__/queue.test.ts +475 -0
- package/skills/migrate/scripts/__tests__/report.test.ts +150 -0
- package/skills/migrate/scripts/__tests__/run-state.test.ts +136 -0
- package/skills/migrate/scripts/__tests__/status-reset.test.ts +318 -0
- package/skills/migrate/scripts/__tests__/store.test.ts +132 -0
- package/skills/migrate/scripts/__tests__/validate.test.ts +54 -0
- package/skills/migrate/scripts/census-cmd.ts +109 -0
- package/skills/migrate/scripts/census.ts +342 -0
- package/skills/migrate/scripts/check-cmd.ts +24 -0
- package/skills/migrate/scripts/check.ts +376 -0
- package/skills/migrate/scripts/citations.ts +92 -0
- package/skills/migrate/scripts/config.ts +237 -0
- package/skills/migrate/scripts/ids.ts +31 -0
- package/skills/migrate/scripts/import-cmd.ts +141 -0
- package/skills/migrate/scripts/init-cmd.ts +118 -0
- package/skills/migrate/scripts/leaks.ts +184 -0
- package/skills/migrate/scripts/lock.ts +188 -0
- package/skills/migrate/scripts/paths.ts +103 -0
- package/skills/migrate/scripts/phase-cmd.ts +63 -0
- package/skills/migrate/scripts/phases.ts +113 -0
- package/skills/migrate/scripts/queue-cmd.ts +98 -0
- package/skills/migrate/scripts/queue.ts +258 -0
- package/skills/migrate/scripts/report-cmd.ts +47 -0
- package/skills/migrate/scripts/report.ts +131 -0
- package/skills/migrate/scripts/reset-cmd.ts +120 -0
- package/skills/migrate/scripts/status-cmd.ts +52 -0
- package/skills/migrate/scripts/store.ts +159 -0
- package/skills/migrate/scripts/types.ts +137 -0
- package/skills/migrate/scripts/validate.ts +221 -0
- package/skills/migrate/skill.json +33 -0
- package/skills/migrate/templates/config.toml +27 -0
- package/skills/migrate/templates/queue-item.md +17 -0
- package/skills/migrate/tsconfig.json +18 -0
- package/skills/migrate/uninstall.sh +31 -0
- package/skills/sluice/SKILL.md +82 -0
- package/skills/sluice/references/deep-channel.md +94 -0
- package/skills/sluice/references/finish.md +35 -0
- package/skills/sluice/references/intent.md +29 -0
- package/skills/sluice/references/review.md +42 -0
- package/skills/sluice/references/root-cause.md +38 -0
- package/skills/sluice/references/show-or-say.md +36 -0
- package/skills/sluice/references/test-first.md +35 -0
- package/skills/sluice/references/verify.md +26 -0
- package/skills/sluice/skill.json +32 -0
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs'
|
|
2
|
+
import { readFile } from 'node:fs/promises'
|
|
3
|
+
import { relative } from 'node:path'
|
|
4
|
+
import { storePaths } from './paths.ts'
|
|
5
|
+
import type { Violation } from './types.ts'
|
|
6
|
+
|
|
7
|
+
const MIN_SECRET_LENGTH = 8
|
|
8
|
+
|
|
9
|
+
export function parseEnv(text: string): Record<string, string> {
|
|
10
|
+
const env: Record<string, string> = {}
|
|
11
|
+
for (const rawLine of text.split('\n')) {
|
|
12
|
+
const line = rawLine.trim()
|
|
13
|
+
if (!line || line.startsWith('#')) continue
|
|
14
|
+
const withoutExport = line.startsWith('export ') ? line.slice(7) : line
|
|
15
|
+
const at = withoutExport.indexOf('=')
|
|
16
|
+
if (at === -1) continue
|
|
17
|
+
const key = withoutExport.slice(0, at).trim()
|
|
18
|
+
let value = withoutExport.slice(at + 1).trim()
|
|
19
|
+
if (
|
|
20
|
+
(value.startsWith('"') && value.endsWith('"') && value.length >= 2) ||
|
|
21
|
+
(value.startsWith("'") && value.endsWith("'") && value.length >= 2)
|
|
22
|
+
) {
|
|
23
|
+
value = value.slice(1, -1)
|
|
24
|
+
}
|
|
25
|
+
env[key] = value
|
|
26
|
+
}
|
|
27
|
+
return env
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// A URL is excluded because artifacts reference the environment by host on
|
|
31
|
+
// purpose; flagging it would make the gate cry wolf on correct prose.
|
|
32
|
+
//
|
|
33
|
+
// Everything below matches secrets by substring, which has two inherent
|
|
34
|
+
// blind spots that are not worth chasing: a secret that got line-wrapped
|
|
35
|
+
// when it was pasted (a newline now sits inside it) and a paste that differs
|
|
36
|
+
// from the source only in case. Widening the match to catch either would
|
|
37
|
+
// trade a rare miss for false positives common enough to make the gate
|
|
38
|
+
// ignorable, so both are accepted as known limits rather than bugs.
|
|
39
|
+
function isSecretValue(value: string): boolean {
|
|
40
|
+
if (value.length < MIN_SECRET_LENGTH) return false
|
|
41
|
+
if (/^https?:\/\//.test(value)) return false
|
|
42
|
+
return true
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function secretValues(env: Record<string, string>): string[] {
|
|
46
|
+
return secretEntries(env).map((entry) => entry.value)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
type SecretEntry = { value: string; names: string[] }
|
|
50
|
+
|
|
51
|
+
// Two env vars can legitimately hold the same value (a password reused
|
|
52
|
+
// between a normal and a read-only runtime account, for instance). Grouping
|
|
53
|
+
// by value up front means a shared value is reported once per file, or once
|
|
54
|
+
// per history, naming every variable that holds it, instead of once per
|
|
55
|
+
// variable with the wrong one picked.
|
|
56
|
+
function secretEntries(env: Record<string, string>): SecretEntry[] {
|
|
57
|
+
const byValue = new Map<string, string[]>()
|
|
58
|
+
for (const [name, value] of Object.entries(env)) {
|
|
59
|
+
if (!isSecretValue(value)) continue
|
|
60
|
+
const names = byValue.get(value)
|
|
61
|
+
if (names) names.push(name)
|
|
62
|
+
else byValue.set(value, [name])
|
|
63
|
+
}
|
|
64
|
+
return Array.from(byValue, ([value, names]) => ({ value, names }))
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function violationFor(entry: SecretEntry, where: string): Violation {
|
|
68
|
+
return { gate: 'leaks', message: `value of ${entry.names.join(', ')} appears in ${where}` }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function scanArtifacts(
|
|
72
|
+
dir: string,
|
|
73
|
+
root: string,
|
|
74
|
+
entries: SecretEntry[],
|
|
75
|
+
): Promise<Violation[]> {
|
|
76
|
+
const violations: Violation[] = []
|
|
77
|
+
const glob = new Bun.Glob('**/*')
|
|
78
|
+
for await (const rel of glob.scan({ cwd: dir, onlyFiles: true, dot: true })) {
|
|
79
|
+
if (rel === '.env') continue
|
|
80
|
+
const abs = `${dir}/${rel}`
|
|
81
|
+
// A file the scanner could not read is a file it did not scan. Treating
|
|
82
|
+
// that as a clean result would be a false negative, so an unreadable
|
|
83
|
+
// artifact is reported as a leaks violation rather than skipped in silence.
|
|
84
|
+
let text: string
|
|
85
|
+
try {
|
|
86
|
+
text = await readFile(abs, 'utf8')
|
|
87
|
+
} catch {
|
|
88
|
+
violations.push({
|
|
89
|
+
gate: 'leaks',
|
|
90
|
+
message: `${relative(root, abs)} could not be read, so it was not checked for leaked credentials`,
|
|
91
|
+
})
|
|
92
|
+
continue
|
|
93
|
+
}
|
|
94
|
+
for (const entry of entries) {
|
|
95
|
+
if (text.includes(entry.value)) violations.push(violationFor(entry, relative(root, abs)))
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return violations
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Reads a subprocess's stdout in whatever chunks the pipe delivers and tests
|
|
102
|
+
// each chunk for the secrets, without ever buffering the full history into
|
|
103
|
+
// one string. A secret can straddle the boundary between two chunks, so each
|
|
104
|
+
// pass keeps a tail of the combined text at least as long as the longest
|
|
105
|
+
// secret minus one character and prepends it to the next chunk; that is
|
|
106
|
+
// enough overlap for a split match to be whole again on the next check.
|
|
107
|
+
async function findInStream(
|
|
108
|
+
stream: ReadableStream<Uint8Array>,
|
|
109
|
+
entries: SecretEntry[],
|
|
110
|
+
): Promise<SecretEntry[]> {
|
|
111
|
+
const maxLen = Math.max(...entries.map((entry) => entry.value.length))
|
|
112
|
+
const remaining = new Map(entries.map((entry) => [entry.value, entry] as const))
|
|
113
|
+
const found: SecretEntry[] = []
|
|
114
|
+
const decoder = new TextDecoder()
|
|
115
|
+
let carry = ''
|
|
116
|
+
const reader = stream.getReader()
|
|
117
|
+
try {
|
|
118
|
+
for (;;) {
|
|
119
|
+
const { done, value } = await reader.read()
|
|
120
|
+
const chunkText = done ? decoder.decode() : decoder.decode(value, { stream: true })
|
|
121
|
+
const combined = carry + chunkText
|
|
122
|
+
for (const [secretValue, entry] of remaining) {
|
|
123
|
+
if (combined.includes(secretValue)) {
|
|
124
|
+
found.push(entry)
|
|
125
|
+
remaining.delete(secretValue)
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
if (done || remaining.size === 0) break
|
|
129
|
+
carry = combined.slice(-(maxLen - 1))
|
|
130
|
+
}
|
|
131
|
+
} finally {
|
|
132
|
+
await reader.cancel().catch(() => {})
|
|
133
|
+
}
|
|
134
|
+
return found
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// git log -S<secret> would put the raw value on the process argument list,
|
|
138
|
+
// readable by any other local user via ps or /proc for the life of the call.
|
|
139
|
+
// A module whose entire purpose is stopping credentials from escaping must
|
|
140
|
+
// not do that itself, so history is streamed instead: one subprocess for the
|
|
141
|
+
// whole run, no secret in its argv, matched against the streamed diff text
|
|
142
|
+
// in-process. --all covers a secret that only ever landed on a branch other
|
|
143
|
+
// than the one currently checked out, not just an older commit on this one.
|
|
144
|
+
async function scanGitHistory(
|
|
145
|
+
root: string,
|
|
146
|
+
gitBin: string,
|
|
147
|
+
entries: SecretEntry[],
|
|
148
|
+
): Promise<Violation[]> {
|
|
149
|
+
if (!existsSync(`${root}/.git`)) return []
|
|
150
|
+
|
|
151
|
+
let proc: Bun.Subprocess<'ignore', 'pipe', 'ignore'>
|
|
152
|
+
try {
|
|
153
|
+
proc = Bun.spawn([gitBin, 'log', '-p', '--all'], {
|
|
154
|
+
cwd: root,
|
|
155
|
+
stdin: 'ignore',
|
|
156
|
+
stdout: 'pipe',
|
|
157
|
+
stderr: 'ignore',
|
|
158
|
+
})
|
|
159
|
+
} catch {
|
|
160
|
+
return [{ gate: 'leaks', message: 'could not run git to check history for leaked credentials' }]
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
let found: SecretEntry[]
|
|
164
|
+
try {
|
|
165
|
+
found = await findInStream(proc.stdout, entries)
|
|
166
|
+
} finally {
|
|
167
|
+
proc.kill()
|
|
168
|
+
await proc.exited.catch(() => {})
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return found.map((entry) => violationFor(entry, 'git history'))
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export async function scanLeaks(opts: { root: string; gitBin?: string }): Promise<Violation[]> {
|
|
175
|
+
const paths = storePaths(opts.root)
|
|
176
|
+
if (!existsSync(paths.env)) return []
|
|
177
|
+
const env = parseEnv(await readFile(paths.env, 'utf8'))
|
|
178
|
+
const entries = secretEntries(env)
|
|
179
|
+
if (entries.length === 0) return []
|
|
180
|
+
|
|
181
|
+
const artifactViolations = await scanArtifacts(paths.dir, opts.root, entries)
|
|
182
|
+
const historyViolations = await scanGitHistory(opts.root, opts.gitBin ?? 'git', entries)
|
|
183
|
+
return [...artifactViolations, ...historyViolations]
|
|
184
|
+
}
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import { open, readFile, unlink } from 'node:fs/promises'
|
|
2
|
+
import { storePaths } from './paths.ts'
|
|
3
|
+
|
|
4
|
+
export type LockHolder = { pid: number; startedAt: string; cmd: string }
|
|
5
|
+
|
|
6
|
+
export class LockError extends Error {
|
|
7
|
+
constructor(
|
|
8
|
+
message: string,
|
|
9
|
+
readonly kind: 'timeout' | 'stale',
|
|
10
|
+
) {
|
|
11
|
+
super(message)
|
|
12
|
+
this.name = 'LockError'
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export type LockOptions = {
|
|
17
|
+
cmd: string
|
|
18
|
+
timeoutMs?: number
|
|
19
|
+
force?: boolean
|
|
20
|
+
alive?: (pid: number) => boolean
|
|
21
|
+
onWait?: (message: string) => void
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Thirty seconds is roughly two orders of magnitude above the expected hold
|
|
25
|
+
// time for a ten-row batch, so reaching it means something is wrong rather
|
|
26
|
+
// than merely busy.
|
|
27
|
+
const DEFAULT_TIMEOUT_MS = 30_000
|
|
28
|
+
|
|
29
|
+
// Five consecutive corrupt reads (see readHolder below for what counts as
|
|
30
|
+
// corrupt, as distinct from the merely transient absent state that never
|
|
31
|
+
// reaches this counter at all) is well past what a fluctuating filesystem
|
|
32
|
+
// state could produce by chance, so it means the lock file itself is broken,
|
|
33
|
+
// not merely between holders.
|
|
34
|
+
const UNREADABLE_TOLERANCE = 5
|
|
35
|
+
|
|
36
|
+
// signal 0 performs the permission and existence checks without delivering
|
|
37
|
+
// anything. ESRCH is the only code that proves the process is gone: EPERM
|
|
38
|
+
// means it exists but belongs to another user, which is still a live holder.
|
|
39
|
+
export function processIsAlive(pid: number): boolean {
|
|
40
|
+
try {
|
|
41
|
+
process.kill(pid, 0)
|
|
42
|
+
return true
|
|
43
|
+
} catch (e) {
|
|
44
|
+
return (e as { code?: string }).code === 'EPERM'
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function lockPath(root: string): string {
|
|
49
|
+
return `${storePaths(root).dir}/.lock`
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// A lock file passes through two ordinary transient states that are not
|
|
53
|
+
// evidence of anything wrong: missing (ENOENT, the gap between one holder's
|
|
54
|
+
// unlink and the next one's create) and present-but-empty (the gap between
|
|
55
|
+
// O_EXCL create and the holder record actually being written). Both are
|
|
56
|
+
// reported as 'absent' here and must never spend any of
|
|
57
|
+
// UNREADABLE_TOLERANCE's budget: under real contention with several
|
|
58
|
+
// short-lived holders cycling quickly, a handful of unlucky consecutive
|
|
59
|
+
// polls landing in one gap or the other is ordinary noise, not a stuck lock,
|
|
60
|
+
// and charging it against the same counter a genuinely corrupt file uses
|
|
61
|
+
// produced exactly that false positive. A file that exists, is non-empty,
|
|
62
|
+
// and still fails to parse (or parses to something with no numeric pid) is
|
|
63
|
+
// the only state that means a lock nobody will ever finish writing or
|
|
64
|
+
// clean up, so that is the only state 'corrupt' covers.
|
|
65
|
+
type HolderRead = { status: 'present'; holder: LockHolder } | { status: 'absent' | 'corrupt' }
|
|
66
|
+
|
|
67
|
+
async function readHolder(path: string): Promise<HolderRead> {
|
|
68
|
+
let text: string
|
|
69
|
+
try {
|
|
70
|
+
text = await readFile(path, 'utf8')
|
|
71
|
+
} catch {
|
|
72
|
+
return { status: 'absent' }
|
|
73
|
+
}
|
|
74
|
+
if (text.length === 0) return { status: 'absent' }
|
|
75
|
+
try {
|
|
76
|
+
const parsed = JSON.parse(text) as LockHolder
|
|
77
|
+
if (typeof parsed?.pid === 'number') return { status: 'present', holder: parsed }
|
|
78
|
+
} catch {
|
|
79
|
+
// falls through to 'corrupt' below
|
|
80
|
+
}
|
|
81
|
+
return { status: 'corrupt' }
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Serialises the read-modify-write that `import` and `census` both perform
|
|
85
|
+
// over the whole store file. Without it two subagents importing concurrently
|
|
86
|
+
// each read the same base, and whichever renames last silently discards the
|
|
87
|
+
// other's rows. The lock is deliberately coarse: one lock for the whole
|
|
88
|
+
// store, held for milliseconds, rather than per-file locks that would have to
|
|
89
|
+
// agree on an ordering to stay deadlock-free.
|
|
90
|
+
export async function withStoreLock<T>(
|
|
91
|
+
root: string,
|
|
92
|
+
fn: () => Promise<T>,
|
|
93
|
+
opts: LockOptions,
|
|
94
|
+
): Promise<T> {
|
|
95
|
+
const path = lockPath(root)
|
|
96
|
+
const alive = opts.alive ?? processIsAlive
|
|
97
|
+
const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS
|
|
98
|
+
const deadline = Date.now() + timeoutMs
|
|
99
|
+
if (opts.force) await unlink(path).catch(() => {})
|
|
100
|
+
|
|
101
|
+
let announced = false
|
|
102
|
+
let unreadable = 0
|
|
103
|
+
let delay = 25
|
|
104
|
+
for (;;) {
|
|
105
|
+
let handle: Awaited<ReturnType<typeof open>> | null = null
|
|
106
|
+
try {
|
|
107
|
+
handle = await open(path, 'wx')
|
|
108
|
+
} catch (e) {
|
|
109
|
+
if ((e as { code?: string }).code !== 'EEXIST') throw e
|
|
110
|
+
}
|
|
111
|
+
if (handle) {
|
|
112
|
+
let held = true
|
|
113
|
+
try {
|
|
114
|
+
const holder: LockHolder = {
|
|
115
|
+
pid: process.pid,
|
|
116
|
+
startedAt: new Date().toISOString(),
|
|
117
|
+
cmd: opts.cmd,
|
|
118
|
+
}
|
|
119
|
+
await handle.writeFile(`${JSON.stringify(holder)}\n`)
|
|
120
|
+
await handle.close()
|
|
121
|
+
held = false
|
|
122
|
+
return await fn()
|
|
123
|
+
} finally {
|
|
124
|
+
if (held) await handle.close().catch(() => {})
|
|
125
|
+
await unlink(path).catch(() => {})
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const read = await readHolder(path)
|
|
130
|
+
if (read.status === 'corrupt') {
|
|
131
|
+
unreadable += 1
|
|
132
|
+
if (unreadable >= UNREADABLE_TOLERANCE) {
|
|
133
|
+
throw new LockError(
|
|
134
|
+
`store lock at ${path} is unreadable; re-run with --force-unlock after confirming no other agent is writing`,
|
|
135
|
+
'stale',
|
|
136
|
+
)
|
|
137
|
+
}
|
|
138
|
+
} else if (read.status === 'present') {
|
|
139
|
+
unreadable = 0
|
|
140
|
+
const holder = read.holder
|
|
141
|
+
if (!alive(holder.pid)) {
|
|
142
|
+
// The holder read above may have already finished, unlinked the lock
|
|
143
|
+
// and exited by the time this check runs: that is a live release, not
|
|
144
|
+
// a stale one, and it must not be reported as the holder's pid being
|
|
145
|
+
// dead. Re-reading before concluding otherwise tells the two apart.
|
|
146
|
+
// If the file is now gone, or now names a different holder, the lock
|
|
147
|
+
// was released (or handed off) in the gap between the two reads, and
|
|
148
|
+
// the right move is to fall through and retry on the next iteration,
|
|
149
|
+
// not to throw about a holder that no longer holds anything. Only a
|
|
150
|
+
// second read that still shows the exact same dead pid means the
|
|
151
|
+
// holder actually exited without releasing.
|
|
152
|
+
const confirm = await readHolder(path)
|
|
153
|
+
if (confirm.status === 'present' && confirm.holder.pid === holder.pid) {
|
|
154
|
+
throw new LockError(
|
|
155
|
+
`store lock held by pid ${holder.pid}, which is not running. Re-run with --force-unlock after confirming no other agent is writing`,
|
|
156
|
+
'stale',
|
|
157
|
+
)
|
|
158
|
+
}
|
|
159
|
+
} else if (!announced) {
|
|
160
|
+
opts.onWait?.(
|
|
161
|
+
`waiting for store lock (held by pid ${holder.pid} since ${holder.startedAt})`,
|
|
162
|
+
)
|
|
163
|
+
announced = true
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
// read.status === 'absent' falls straight through to here: see
|
|
167
|
+
// readHolder's comment for why this must not touch `unreadable`.
|
|
168
|
+
//
|
|
169
|
+
// The deadline applies unconditionally, not only while a live holder is
|
|
170
|
+
// in view: a lock file stuck 'absent' or 'corrupt' for the whole timeout
|
|
171
|
+
// (its creator crashed after the O_EXCL create but before writing, say)
|
|
172
|
+
// must still surface as a timeout rather than loop forever, since
|
|
173
|
+
// neither status alone throws on its own schedule the way a confirmed
|
|
174
|
+
// dead holder or five straight corrupt reads do. The message names the
|
|
175
|
+
// holder when one was last seen, and falls back to naming the lock path
|
|
176
|
+
// when the wait never got that far.
|
|
177
|
+
if (Date.now() >= deadline) {
|
|
178
|
+
throw new LockError(
|
|
179
|
+
read.status === 'present'
|
|
180
|
+
? `timed out after ${timeoutMs}ms waiting for the store lock held by pid ${read.holder.pid} since ${read.holder.startedAt}`
|
|
181
|
+
: `timed out after ${timeoutMs}ms waiting for the store lock at ${path}`,
|
|
182
|
+
'timeout',
|
|
183
|
+
)
|
|
184
|
+
}
|
|
185
|
+
await Bun.sleep(delay)
|
|
186
|
+
delay = Math.min(Math.ceil(delay * 1.5), 250)
|
|
187
|
+
}
|
|
188
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs'
|
|
2
|
+
import { realpath } from 'node:fs/promises'
|
|
3
|
+
import { basename, dirname, join, relative, resolve, sep } from 'node:path'
|
|
4
|
+
|
|
5
|
+
export const STORE_DIR = '.migrate'
|
|
6
|
+
|
|
7
|
+
export type StorePaths = {
|
|
8
|
+
dir: string
|
|
9
|
+
config: string
|
|
10
|
+
elements: string
|
|
11
|
+
requirements: string
|
|
12
|
+
capabilities: string
|
|
13
|
+
deltas: string
|
|
14
|
+
census: string
|
|
15
|
+
phases: string
|
|
16
|
+
queueDir: string
|
|
17
|
+
seamJson: string
|
|
18
|
+
seamMd: string
|
|
19
|
+
parityBasis: string
|
|
20
|
+
env: string
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function storePaths(root: string): StorePaths {
|
|
24
|
+
const dir = join(root, STORE_DIR)
|
|
25
|
+
return {
|
|
26
|
+
dir,
|
|
27
|
+
config: join(dir, 'config.toml'),
|
|
28
|
+
elements: join(dir, 'elements.jsonl'),
|
|
29
|
+
requirements: join(dir, 'requirements.jsonl'),
|
|
30
|
+
capabilities: join(dir, 'capabilities.jsonl'),
|
|
31
|
+
deltas: join(dir, 'deltas.jsonl'),
|
|
32
|
+
census: join(dir, 'census.jsonl'),
|
|
33
|
+
phases: join(dir, 'phases.json'),
|
|
34
|
+
queueDir: join(dir, 'queue'),
|
|
35
|
+
seamJson: join(dir, 'seam.json'),
|
|
36
|
+
seamMd: join(dir, 'seam.md'),
|
|
37
|
+
parityBasis: join(dir, 'parity-basis.md'),
|
|
38
|
+
env: join(dir, '.env'),
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export async function findStoreRoot(startDir: string): Promise<string | null> {
|
|
43
|
+
let current = resolve(startDir)
|
|
44
|
+
for (;;) {
|
|
45
|
+
if (existsSync(join(current, STORE_DIR))) return current
|
|
46
|
+
const parent = dirname(current)
|
|
47
|
+
if (parent === current) return null
|
|
48
|
+
current = parent
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// A shared string prefix is not containment: legacy-notes/ is not inside legacy/.
|
|
53
|
+
// relative() gives '..' as its first segment for anything outside, and '' for the
|
|
54
|
+
// root itself, which counts as inside. This is the one containment predicate
|
|
55
|
+
// both the write side (this file) and the read side (citations.ts) use, so the
|
|
56
|
+
// two cannot drift into disagreeing again about what "inside" means.
|
|
57
|
+
export function isContained(inner: string, outer: string): boolean {
|
|
58
|
+
const rel = relative(outer, inner)
|
|
59
|
+
return rel === '' || !(rel.startsWith(`..${sep}`) || rel === '..')
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Resolves a path to its canonical real form -- following symlinks and, on a
|
|
63
|
+
// case-insensitive filesystem, normalizing to the on-disk case -- even when
|
|
64
|
+
// the path itself does not exist yet. A write target is normally exactly
|
|
65
|
+
// that: a file `writeAtomically` is about to create. realpath() alone would
|
|
66
|
+
// fail on it with ENOENT, so this walks up to the nearest ancestor that does
|
|
67
|
+
// exist, resolves that ancestor for real, and reattaches the non-existent
|
|
68
|
+
// remainder unchanged (a path segment that does not exist cannot itself be a
|
|
69
|
+
// symlink or a case variant, so there is nothing further to resolve about it).
|
|
70
|
+
// If no ancestor at all exists (a bogus root), it falls back to the lexically
|
|
71
|
+
// resolved path rather than throwing out of what is meant to be a yes/no check.
|
|
72
|
+
async function realOrNearestAncestor(target: string): Promise<string> {
|
|
73
|
+
const resolved = resolve(target)
|
|
74
|
+
let current = resolved
|
|
75
|
+
const suffix: string[] = []
|
|
76
|
+
for (;;) {
|
|
77
|
+
try {
|
|
78
|
+
const real = await realpath(current)
|
|
79
|
+
return suffix.length > 0 ? join(real, ...suffix.reverse()) : real
|
|
80
|
+
} catch {
|
|
81
|
+
const parent = dirname(current)
|
|
82
|
+
if (parent === current) return resolved
|
|
83
|
+
suffix.push(basename(current))
|
|
84
|
+
current = parent
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Lexical comparison alone (the previous implementation) is fooled two ways:
|
|
90
|
+
// a symlink inside the target tree that points into the source tree changes
|
|
91
|
+
// what a path really resolves to without changing its literal text, and on a
|
|
92
|
+
// case-insensitive volume two differently-cased spellings of the same
|
|
93
|
+
// directory compare as unrelated strings even though they are the same
|
|
94
|
+
// physical location. Both are closed by resolving real paths (which follow
|
|
95
|
+
// symlinks and normalize case) before the containment check, exactly as
|
|
96
|
+
// citations.ts already does on the read side.
|
|
97
|
+
export async function assertNotUnderSource(target: string, sourcePath: string): Promise<void> {
|
|
98
|
+
const realRoot = await realOrNearestAncestor(resolve(sourcePath))
|
|
99
|
+
const realTarget = await realOrNearestAncestor(resolve(target))
|
|
100
|
+
if (isContained(realTarget, realRoot)) {
|
|
101
|
+
throw new Error(`refusing to write inside the read-only source tree: ${target}`)
|
|
102
|
+
}
|
|
103
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { loadConfig } from './config.ts'
|
|
2
|
+
import { LockError } from './lock.ts'
|
|
3
|
+
import { isPhase, loadPhases, PHASES, type PhaseState, setPhaseStatus } from './phases.ts'
|
|
4
|
+
|
|
5
|
+
const STATUSES: readonly PhaseState['status'][] = ['pending', 'running', 'blocked', 'done']
|
|
6
|
+
|
|
7
|
+
function line(name: string, state: PhaseState): string {
|
|
8
|
+
return `${name.padEnd(11)} ${state.status.padEnd(8)} ${state.batches.length} batch(es)\n`
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
// Reading is the default because phase state should be inspectable before it
|
|
12
|
+
// is writable: an orchestrator resuming a run needs to see where it stopped
|
|
13
|
+
// far more often than it needs to move the marker.
|
|
14
|
+
export async function runPhase(opts: {
|
|
15
|
+
root: string
|
|
16
|
+
name?: string
|
|
17
|
+
status?: string
|
|
18
|
+
forceUnlock?: boolean
|
|
19
|
+
}): Promise<number> {
|
|
20
|
+
const phases = await loadPhases(opts.root)
|
|
21
|
+
if (!opts.name) {
|
|
22
|
+
for (const p of PHASES) process.stdout.write(line(p, phases[p]))
|
|
23
|
+
return 0
|
|
24
|
+
}
|
|
25
|
+
if (!isPhase(opts.name)) {
|
|
26
|
+
process.stderr.write(`phase: unknown phase ${opts.name}; want one of ${PHASES.join(', ')}\n`)
|
|
27
|
+
return 2
|
|
28
|
+
}
|
|
29
|
+
const name = opts.name
|
|
30
|
+
if (!opts.status) {
|
|
31
|
+
process.stdout.write(line(name, phases[name]))
|
|
32
|
+
return 0
|
|
33
|
+
}
|
|
34
|
+
if (!(STATUSES as readonly string[]).includes(opts.status)) {
|
|
35
|
+
process.stderr.write(
|
|
36
|
+
`phase: unknown status ${opts.status}; want one of ${STATUSES.join(', ')}\n`,
|
|
37
|
+
)
|
|
38
|
+
return 2
|
|
39
|
+
}
|
|
40
|
+
const cfg = await loadConfig(opts.root)
|
|
41
|
+
try {
|
|
42
|
+
await setPhaseStatus(
|
|
43
|
+
opts.root,
|
|
44
|
+
name,
|
|
45
|
+
opts.status as PhaseState['status'],
|
|
46
|
+
cfg.source.path,
|
|
47
|
+
opts.forceUnlock,
|
|
48
|
+
)
|
|
49
|
+
} catch (e) {
|
|
50
|
+
// A lock failure is neither a bad status value nor an unknown phase: the
|
|
51
|
+
// request is fine and would succeed on retry, so it gets the same
|
|
52
|
+
// dedicated exit code census-cmd.ts and import-cmd.ts already use for it
|
|
53
|
+
// (3), not the generic usage-error code (2) a thrown LockError would
|
|
54
|
+
// otherwise fall through to in main()'s catch-all.
|
|
55
|
+
if (e instanceof LockError) {
|
|
56
|
+
process.stderr.write(`phase: ${e.message}\n`)
|
|
57
|
+
return 3
|
|
58
|
+
}
|
|
59
|
+
throw e
|
|
60
|
+
}
|
|
61
|
+
process.stdout.write(`phase: ${name} is now ${opts.status}\n`)
|
|
62
|
+
return 0
|
|
63
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs'
|
|
2
|
+
import { readFile } from 'node:fs/promises'
|
|
3
|
+
import { withStoreLock } from './lock.ts'
|
|
4
|
+
import { storePaths } from './paths.ts'
|
|
5
|
+
import { writeAtomically } from './store.ts'
|
|
6
|
+
|
|
7
|
+
export const PHASES = [
|
|
8
|
+
'probe',
|
|
9
|
+
'enumerate',
|
|
10
|
+
'seam',
|
|
11
|
+
'extract',
|
|
12
|
+
'parity',
|
|
13
|
+
'queue',
|
|
14
|
+
'adjudicate',
|
|
15
|
+
'handoff',
|
|
16
|
+
] as const
|
|
17
|
+
|
|
18
|
+
export type Phase = (typeof PHASES)[number]
|
|
19
|
+
|
|
20
|
+
export type Batch = { id: string; count: number }
|
|
21
|
+
|
|
22
|
+
export type PhaseState = {
|
|
23
|
+
status: 'pending' | 'running' | 'blocked' | 'done'
|
|
24
|
+
batches: Batch[]
|
|
25
|
+
pending: string[]
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export type PhasesFile = { version: 1; phases: Record<Phase, PhaseState> }
|
|
29
|
+
|
|
30
|
+
export function isPhase(s: string): s is Phase {
|
|
31
|
+
return (PHASES as readonly string[]).includes(s)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function empty(): Record<Phase, PhaseState> {
|
|
35
|
+
const out = {} as Record<Phase, PhaseState>
|
|
36
|
+
for (const p of PHASES) out[p] = { status: 'pending', batches: [], pending: [] }
|
|
37
|
+
return out
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export async function loadPhases(root: string): Promise<Record<Phase, PhaseState>> {
|
|
41
|
+
const path = storePaths(root).phases
|
|
42
|
+
if (!existsSync(path)) return empty()
|
|
43
|
+
const text = await readFile(path, 'utf8')
|
|
44
|
+
let parsed: PhasesFile
|
|
45
|
+
try {
|
|
46
|
+
parsed = JSON.parse(text) as PhasesFile
|
|
47
|
+
} catch {
|
|
48
|
+
throw new Error(`${path}: malformed JSON`)
|
|
49
|
+
}
|
|
50
|
+
const state = empty()
|
|
51
|
+
for (const p of PHASES) {
|
|
52
|
+
const found = parsed.phases?.[p]
|
|
53
|
+
if (found)
|
|
54
|
+
state[p] = {
|
|
55
|
+
status: found.status,
|
|
56
|
+
batches: found.batches ?? [],
|
|
57
|
+
pending: found.pending ?? [],
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return state
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Writes phases.json atomically using temp-plus-rename. Atomicity alone does
|
|
64
|
+
// not make a read-modify-write safe: two callers can still read the same base
|
|
65
|
+
// and one rename still discards the other's batches. That is closed by the
|
|
66
|
+
// store lock, not here. This function deliberately does not take it, because
|
|
67
|
+
// every caller already holds it -- recordBatch runs inside import's and
|
|
68
|
+
// census's critical sections, setPhaseStatus takes it itself, and reset-cmd.ts
|
|
69
|
+
// wraps its whole mutation in it -- and withStoreLock is not reentrant, so a
|
|
70
|
+
// lock taken here would deadlock all three.
|
|
71
|
+
export async function savePhases(
|
|
72
|
+
root: string,
|
|
73
|
+
phases: Record<Phase, PhaseState>,
|
|
74
|
+
sourcePath: string,
|
|
75
|
+
): Promise<void> {
|
|
76
|
+
const path = storePaths(root).phases
|
|
77
|
+
const file: PhasesFile = { version: 1, phases }
|
|
78
|
+
await writeAtomically(path, `${JSON.stringify(file, null, 2)}\n`, sourcePath)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export async function recordBatch(
|
|
82
|
+
root: string,
|
|
83
|
+
phase: Phase,
|
|
84
|
+
batch: Batch,
|
|
85
|
+
sourcePath: string,
|
|
86
|
+
): Promise<void> {
|
|
87
|
+
const phases = await loadPhases(root)
|
|
88
|
+
const state = phases[phase]
|
|
89
|
+
if (!state.batches.some((b) => b.id === batch.id)) state.batches.push(batch)
|
|
90
|
+
if (state.status !== 'done') state.status = 'running'
|
|
91
|
+
await savePhases(root, phases, sourcePath)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export async function setPhaseStatus(
|
|
95
|
+
root: string,
|
|
96
|
+
phase: Phase,
|
|
97
|
+
status: PhaseState['status'],
|
|
98
|
+
sourcePath: string,
|
|
99
|
+
forceUnlock?: boolean,
|
|
100
|
+
): Promise<void> {
|
|
101
|
+
// Same read-modify-write hazard as recordBatch. The orchestrator is the
|
|
102
|
+
// only expected caller and is serial, but the cost of holding the lock for
|
|
103
|
+
// a status flip is a few milliseconds and it removes the question.
|
|
104
|
+
await withStoreLock(
|
|
105
|
+
root,
|
|
106
|
+
async () => {
|
|
107
|
+
const phases = await loadPhases(root)
|
|
108
|
+
phases[phase].status = status
|
|
109
|
+
await savePhases(root, phases, sourcePath)
|
|
110
|
+
},
|
|
111
|
+
{ cmd: 'phase', ...(forceUnlock ? { force: true } : {}) },
|
|
112
|
+
)
|
|
113
|
+
}
|