@1aboveio/skills 1.0.10 → 1.0.11
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/.codex-plugin/plugin.json +1 -1
- package/.codex-plugin/resources.json +297 -216
- package/README.md +3 -3
- package/package.json +10 -8
- package/skills/app-dev/airflow/SKILL.md +1 -1
- package/skills/app-dev/android-foundation/SKILL.md +35 -37
- package/skills/app-dev/android-foundation/references/delivery-focus.md +4 -2
- package/skills/app-dev/android-foundation/references/feature-work.md +50 -0
- package/skills/app-dev/android-foundation/references/setup.md +24 -9
- package/skills/app-dev/android-foundation/references/workspace.md +81 -0
- package/skills/app-dev/android-foundation/scripts/workspace-runtime/bin/workspaces.mjs +52 -0
- package/skills/app-dev/android-foundation/scripts/workspace-runtime/native-workspace.mjs +239 -0
- package/skills/app-dev/android-foundation/scripts/workspace-runtime/workspace-lifecycle.mjs +2154 -0
- package/skills/app-dev/app-debug/SKILL.md +1 -1
- package/skills/app-dev/better-auth/SKILL.md +1 -1
- package/skills/app-dev/data-catalog/SKILL.md +1 -1
- package/skills/app-dev/data-quality/SKILL.md +1 -1
- package/skills/app-dev/ios-foundation/SKILL.md +15 -10
- package/skills/app-dev/ios-foundation/references/setup.md +25 -9
- package/skills/app-dev/ios-foundation/references/workspace.md +82 -0
- package/skills/app-dev/ios-foundation/scripts/workspace-runtime/bin/workspaces.mjs +52 -0
- package/skills/app-dev/ios-foundation/scripts/workspace-runtime/native-workspace.mjs +239 -0
- package/skills/app-dev/ios-foundation/scripts/workspace-runtime/workspace-lifecycle.mjs +2154 -0
- package/skills/app-dev/monorepo/SKILL.md +1 -1
- package/skills/app-dev/nextjs-fullstack/SKILL.md +1 -1
- package/skills/app-dev/prisma-setup/SKILL.md +1 -1
- package/skills/app-dev/pyspark/SKILL.md +1 -1
- package/skills/app-dev/python-backend/SKILL.md +1 -1
- package/skills/app-dev/shadcn/SKILL.md +1 -1
- package/skills/app-dev/zod-v4/SKILL.md +1 -1
- package/skills/cicd/cloud-build/SKILL.md +1 -1
- package/skills/cicd/cloud-debug/SKILL.md +1 -1
- package/skills/cicd/cloud-deploy/SKILL.md +1 -1
- package/skills/cicd/docker/SKILL.md +1 -1
- package/skills/cicd/github-actions/SKILL.md +1 -1
- package/skills/cicd/github-flow/SKILL.md +1 -1
- package/skills/cicd/google-cloud/SKILL.md +1 -1
- package/skills/cicd/mergify/SKILL.md +1 -1
- package/skills/cicd/podman/SKILL.md +1 -1
- package/skills/content/article-review/SKILL.md +1 -1
- package/skills/content/article-to-image/SKILL.md +1 -1
- package/skills/content/flowchart-svg/SKILL.md +1 -1
- package/skills/content/image-gen/SKILL.md +1 -1
- package/skills/content/mermaid/SKILL.md +1 -1
- package/skills/content/wechat-article-writer/SKILL.md +1 -1
- package/skills/content/wechat-publisher/SKILL.md +1 -1
- package/skills/content/wechat-search/SKILL.md +1 -1
- package/skills/content/xai-search/SKILL.md +1 -1
- package/skills/core/distill-writing/SKILL.md +3 -3
- package/skills/core/distill-writing/references/extraction.md +1 -1
- package/skills/core/doc-to-md/SKILL.md +1 -1
- package/skills/core/editorial-review/SKILL.md +82 -12
- package/skills/core/editorial-review/references/checklist.md +26 -1
- package/skills/core/house-style/SKILL.md +8 -3
- package/skills/core/house-style/references/analytical-reporting.md +123 -0
- package/skills/core/house-style/references/analytical-writing-patterns.md +79 -0
- package/skills/core/house-style/references/analytical-writing-research.md +129 -0
- package/skills/core/{write-doc → house-style}/references/bilingual.md +7 -5
- package/skills/core/house-style/references/definitions-and-metrics.md +86 -0
- package/skills/core/house-style/references/document-workflow.md +193 -0
- package/skills/core/house-style/references/initialization.md +66 -0
- package/skills/core/house-style/references/persistence.md +9 -4
- package/skills/core/house-style/references/rules.md +11 -4
- package/skills/core/house-style/scripts/init-guidance.mjs +213 -0
- package/skills/core/house-style/templates/analytical-report.md +54 -0
- package/skills/core/house-style/templates/gap-analysis.md +1 -1
- package/skills/core/plain-language/SKILL.md +2 -2
- package/skills/core/polish-cn/SKILL.md +9 -2
- package/skills/core/reg-parser/SKILL.md +3 -3
- package/skills/core/terminology/SKILL.md +8 -4
- package/skills/core/write-doc/SKILL.md +40 -151
- package/skills/core/write-report/SKILL.md +129 -0
- package/skills/core/{write-doc → write-report}/references/compliance-reports.md +4 -4
- package/skills/engineering/better-review/SKILL.md +37 -37
- package/skills/engineering/better-review/references/passes.md +35 -41
- package/skills/engineering/better-review/references/result.md +10 -9
- package/skills/engineering/e2e-test/SKILL.md +17 -17
- package/skills/engineering/e2e-test/references/authoring/playwright-config.md +11 -13
- package/skills/engineering/e2e-test/references/presentation-sweep.md +7 -7
- package/skills/engineering/e2e-test/references/reviewing.md +9 -11
- package/skills/engineering/e2e-test/references/route-manifest.md +1 -1
- package/skills/engineering/engineering/SKILL.md +124 -0
- package/skills/engineering/engineering/references/engineering-conventions.md +32 -0
- package/skills/engineering/engineering/references/feature-delivery.md +8 -0
- package/skills/engineering/engineering/references/init-scope.md +37 -0
- package/skills/engineering/engineering/scripts/init.mjs +160 -0
- package/skills/engineering/{contributing → engineering}/scripts/persist-conventions.mjs +1 -1
- package/skills/engineering/engineering-runtime/SKILL.md +1 -1
- package/skills/engineering/engineering-runtime/coherence/workflow.json +19 -19
- package/skills/engineering/engineering-runtime/scripts/workflow-policy.mjs +1 -1
- package/skills/engineering/ensure-coverage/SKILL.md +22 -109
- package/skills/engineering/ensure-coverage/references/breadth/coverage-ledger.md +7 -5
- package/skills/engineering/ensure-coverage/references/breadth/surface-baseline.md +4 -4
- package/skills/engineering/ensure-coverage/references/breadth/surface-discovery.md +3 -3
- package/skills/engineering/ensure-coverage/references/depth/characterization.md +4 -65
- package/skills/engineering/ensure-coverage/references/depth/grading.md +5 -7
- package/skills/engineering/ensure-coverage/references/depth/mock-policy.md +6 -12
- package/skills/engineering/ensure-coverage/references/enforcement/ci-contract.md +1 -1
- package/skills/engineering/ensure-coverage/references/presentation/axis.md +6 -6
- package/skills/engineering/ensure-coverage/references/process/audit-mode.md +7 -23
- package/skills/engineering/ensure-coverage/references/process/output-template.md +18 -133
- package/skills/engineering/ensure-coverage/references/process/test-strategy.md +7 -12
- package/skills/engineering/ensure-coverage/scripts/README.md +12 -4
- package/skills/engineering/golden-path/SKILL.md +1 -1
- package/skills/engineering/harness-runtime/SKILL.md +3 -1
- package/skills/engineering/harness-runtime/bin/workspaces.mjs +52 -0
- package/skills/engineering/harness-runtime/native-workspace.mjs +239 -0
- package/skills/engineering/harness-runtime/references/workspaces.md +78 -0
- package/skills/engineering/harness-runtime/workspace-lifecycle.mjs +2154 -0
- package/skills/engineering/resolve-issues/SKILL.md +40 -61
- package/skills/engineering/resolve-issues/references/ci.md +13 -3
- package/skills/engineering/resolve-issues/references/delivery-progress.md +4 -3
- package/skills/engineering/resolve-issues/references/done.md +11 -2
- package/skills/engineering/resolve-issues/references/expire.md +9 -2
- package/skills/engineering/resolve-issues/references/implementation.md +33 -17
- package/skills/engineering/resolve-issues/references/preflight.md +3 -1
- package/skills/engineering/resolve-issues/references/principles.md +52 -0
- package/skills/engineering/resolve-issues/references/review.md +25 -15
- package/skills/engineering/resolve-issues/references/task-plan.md +64 -27
- package/skills/engineering/resolve-issues/references/workspaces.md +164 -0
- package/skills/engineering/resolve-issues/scripts/cleanup-runs.mjs +185 -317
- package/skills/engineering/resolve-issues/scripts/next-actions.mjs +44 -7
- package/skills/engineering/resolve-issues/scripts/rehydrate.mjs +36 -2
- package/skills/engineering/resolve-issues/scripts/review-verdict.mjs +9 -8
- package/skills/engineering/resolve-issues/scripts/workspace-lifecycle.mjs +1 -0
- package/skills/engineering/resolve-release/SKILL.md +1 -1
- package/skills/engineering/rush-issues/SKILL.md +7 -5
- package/skills/engineering/rush-release/SKILL.md +1 -1
- package/skills/engineering/test-quality/SKILL.md +19 -4
- package/skills/engineering/test-quality/references/lint.md +1 -1
- package/skills/engineering/test-quality/references/principles.md +2 -2
- package/skills/engineering/test-quality/references/testing-conventions.md +1 -1
- package/skills/risk-compliance/compliance-audit/SKILL.md +4 -8
- package/skills/risk-compliance/compliance-audit/references/audience-and-reports.md +12 -0
- package/skills/risk-compliance/contract-compare/SKILL.md +17 -1
- package/skills/risk-compliance/contract-review/SKILL.md +6 -1
- package/skills/risk-compliance/contract-review/references/report-stage.md +17 -0
- package/skills/risk-compliance/doc-authenticity/SKILL.md +11 -1
- package/skills/risk-compliance/fraud-detection/LICENSE +3 -0
- package/skills/risk-compliance/fraud-detection/SKILL.md +148 -0
- package/skills/risk-compliance/{fraud-analysis → fraud-detection}/references/archetypes/first-party-fraud.md +3 -2
- package/skills/risk-compliance/fraud-detection/references/archetypes/third-party-fraud.md +47 -0
- package/skills/risk-compliance/fraud-detection/references/collection-payout-features.md +151 -0
- package/skills/risk-compliance/{fraud-analysis → fraud-detection}/references/contexts/bank-transfer.md +4 -3
- package/skills/risk-compliance/fraud-detection/references/contexts/card-payment.md +70 -0
- package/skills/risk-compliance/fraud-detection/references/contexts/payment-collection.md +34 -0
- package/skills/risk-compliance/fraud-detection/references/contexts/payout.md +34 -0
- package/skills/risk-compliance/fraud-detection/references/enrichment.md +74 -0
- package/skills/risk-compliance/fraud-detection/references/entities.md +48 -0
- package/skills/risk-compliance/{fraud-analysis → fraud-detection}/references/feature-engineering.md +69 -33
- package/skills/risk-compliance/fraud-detection/references/feature-sql.md +150 -0
- package/skills/risk-compliance/{fraud-analysis → fraud-detection}/references/report-rationale.md +4 -2
- package/skills/risk-compliance/{fraud-analysis → fraud-detection}/references/report-template.md +45 -23
- package/skills/risk-compliance/{fraud-analysis → fraud-detection}/references/review-checklist.md +60 -20
- package/skills/risk-compliance/fraud-detection/references/workflow.md +216 -0
- package/skills/risk-compliance/fraud-detection/release-check.mjs +95 -0
- package/skills/risk-compliance/payment-analysis/SKILL.md +36 -17
- package/skills/risk-compliance/payment-analysis/references/auth-rate-actions.md +2 -2
- package/skills/risk-compliance/payment-analysis/references/report-template.md +10 -2
- package/skills/risk-compliance/payment-analysis/references/visualization.md +11 -1
- package/skills/risk-compliance/portfolio-payment-analysis/LICENSE +3 -0
- package/skills/risk-compliance/portfolio-payment-analysis/SKILL.md +170 -0
- package/skills/risk-compliance/portfolio-payment-analysis/references/identity-and-reconciliation.md +46 -0
- package/skills/risk-compliance/portfolio-payment-analysis/references/metrics-and-reporting.md +170 -0
- package/skills/risk-compliance/portfolio-payment-analysis/references/risk-history.md +89 -0
- package/skills/risk-compliance/portfolio-payment-analysis/release-check.mjs +95 -0
- package/skills/risk-compliance/portfolio-payment-analysis/scripts/analyze.py +636 -0
- package/skills/risk-compliance/portfolio-payment-analysis/scripts/build_report.py +386 -0
- package/skills/risk-compliance/portfolio-payment-analysis/scripts/comparison_charts.py +425 -0
- package/skills/risk-compliance/portfolio-payment-analysis/scripts/csv_export.py +52 -0
- package/skills/risk-compliance/portfolio-payment-analysis/scripts/render.py +160 -0
- package/skills/risk-compliance/portfolio-payment-analysis/scripts/risk_history.py +435 -0
- package/skills/risk-compliance/similar-domain/SKILL.md +9 -4
- package/skills/risk-compliance/tld-detection/SKILL.md +124 -239
- package/skills/risk-compliance/tld-detection/agents/openai.yaml +1 -1
- package/skills/risk-compliance/tld-detection/references/clean.md +18 -51
- package/skills/risk-compliance/tld-detection/references/fx-conversion.md +56 -219
- package/skills/risk-compliance/tld-detection/references/metric-contract.md +109 -0
- package/skills/risk-compliance/tld-detection/references/pattern.md +99 -0
- package/skills/risk-compliance/tld-detection/references/report-template.md +49 -272
- package/skills/risk-compliance/tld-detection/scripts/detect_ladders.py +46 -13
- package/skills/risk-compliance/tld-detection/scripts/detect_patterns.py +346 -0
- package/skills/risk-compliance/tld-detection/scripts/detect_streams.py +51 -19
- package/skills/risk-compliance/tld-detection/scripts/profile_groups.py +109 -0
- package/skills/risk-compliance/tld-detection/scripts/render_charts.py +218 -0
- package/skills/risk-compliance/tld-detection/scripts/tld_common.py +243 -0
- package/skills/engineering/contributing/SKILL.md +0 -70
- package/skills/risk-compliance/fraud-analysis/SKILL.md +0 -114
- package/skills/risk-compliance/fraud-analysis/references/archetypes/third-party-fraud.md +0 -27
- package/skills/risk-compliance/fraud-analysis/references/contexts/card-payment.md +0 -30
- package/skills/risk-compliance/fraud-analysis/references/contexts/payment-collection.md +0 -20
- package/skills/risk-compliance/fraud-analysis/references/contexts/payout.md +0 -20
- package/skills/risk-compliance/fraud-analysis/references/workflow.md +0 -175
- package/skills/risk-compliance/tld-detection/references/chinese-polish.md +0 -55
- package/skills/risk-compliance/tld-detection/references/clustering.md +0 -235
- package/skills/risk-compliance/tld-detection/references/decision.md +0 -189
- package/skills/risk-compliance/tld-detection/references/streams.md +0 -141
- package/skills/risk-compliance/tld-detection/references/terminology.md +0 -95
- package/skills/risk-compliance/tld-detection/references/workflow.md +0 -95
- /package/skills/{engineering/contributing → core/write-report}/LICENSE +0 -0
- /package/skills/core/{write-doc → write-report}/references/reports/audit-report-template.md +0 -0
- /package/skills/core/{write-doc → write-report}/references/reports/external-assessment-template.md +0 -0
- /package/skills/core/{write-doc → write-report}/references/reports/management-report-template.md +0 -0
- /package/skills/core/{write-doc → write-report}/references/reports/vars-report-template.md +0 -0
- /package/skills/{engineering/contributing → core/write-report}/release-check.mjs +0 -0
- /package/skills/{risk-compliance/fraud-analysis → engineering/engineering}/LICENSE +0 -0
- /package/skills/engineering/{contributing → engineering}/references/tracker-conventions.md +0 -0
- /package/skills/{risk-compliance/fraud-analysis → engineering/engineering}/release-check.mjs +0 -0
- /package/skills/engineering/{contributing → engineering}/scripts/persist-conventions-core.mjs +0 -0
- /package/skills/risk-compliance/{fraud-analysis → fraud-detection}/references/archetypes/authorized-payment-scam.md +0 -0
- /package/skills/risk-compliance/{fraud-analysis → fraud-detection}/references/mechanisms/account-takeover.md +0 -0
- /package/skills/risk-compliance/{fraud-analysis → fraud-detection}/references/taxonomy.md +0 -0
- /package/skills/risk-compliance/{fraud-analysis → fraud-detection}/references/terminology.md +0 -0
|
@@ -0,0 +1,2154 @@
|
|
|
1
|
+
// Shared owned-workspace mechanics for workflow and standalone native callers.
|
|
2
|
+
//
|
|
3
|
+
// This module is deliberately a small support module for cleanup-runs.mjs. It does NOT
|
|
4
|
+
// write task-plan.json: the coordinator owns every plan transition through update_plan.
|
|
5
|
+
// Every exported operation returns observations and the next lifecycle value as JSON; the
|
|
6
|
+
// coordinator decides whether to persist it.
|
|
7
|
+
//
|
|
8
|
+
// Ownership authority is the existing task plan. A unit owns exactly what the plan records
|
|
9
|
+
// in `unit.workspace` plus `unit.lifecycle.resources`; anything else under a run is
|
|
10
|
+
// inventory only and is never removed by a glob or by age.
|
|
11
|
+
//
|
|
12
|
+
// Lifecycle shape (version 1):
|
|
13
|
+
//
|
|
14
|
+
// lifecycle: {
|
|
15
|
+
// version: 1,
|
|
16
|
+
// workspace: {
|
|
17
|
+
// path, kind: 'worktree' | 'clone', state: 'preparing' | 'ready',
|
|
18
|
+
// baseSha: <exact 40 or 64 hex>,
|
|
19
|
+
// identity: { realPath, dev, ino, gitCommonDir, headSha }
|
|
20
|
+
// },
|
|
21
|
+
// resources: [
|
|
22
|
+
// { path, kind: 'directory' | 'clone' | 'worktree',
|
|
23
|
+
// role: 'scratch' | 'tools' | 'review', disposable: true,
|
|
24
|
+
// state: 'preparing' | 'ready',
|
|
25
|
+
// identity: { realPath, dev, ino, gitCommonDir? } }
|
|
26
|
+
// ],
|
|
27
|
+
// cleanup: { state: 'pending' | 'applying' | 'complete' | 'retained',
|
|
28
|
+
// headSha, reason?, completedAt?, receipt? }
|
|
29
|
+
// }
|
|
30
|
+
//
|
|
31
|
+
// Safety rules are fail-closed. A path we cannot identify, a tree we cannot read, a worker
|
|
32
|
+
// recorded for this owner or overlapping paths, a process still using the tree, unknown ignored files,
|
|
33
|
+
// nested repositories, stashes, rebases/merges in progress, any unpublished local ref or
|
|
34
|
+
// reflog commit, or evidence that still lives inside the workspace all retain the whole
|
|
35
|
+
// environment. There is no ignored-file stripping.
|
|
36
|
+
|
|
37
|
+
import { execFileSync } from 'node:child_process'
|
|
38
|
+
import {
|
|
39
|
+
existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, readlinkSync,
|
|
40
|
+
realpathSync, renameSync, rmSync, rmdirSync, statSync, unlinkSync, writeFileSync,
|
|
41
|
+
} from 'node:fs'
|
|
42
|
+
import { dirname, isAbsolute, join, resolve, sep } from 'node:path'
|
|
43
|
+
|
|
44
|
+
export const LIFECYCLE_VERSION = 1
|
|
45
|
+
export const CLEANUP_STATES = new Set(['pending', 'applying', 'complete', 'retained'])
|
|
46
|
+
export const WORKSPACE_KINDS = new Set(['worktree', 'clone'])
|
|
47
|
+
export const WORKSPACE_STATES = new Set(['preparing', 'ready'])
|
|
48
|
+
export const RESOURCE_KINDS = new Set(['directory', 'clone', 'worktree'])
|
|
49
|
+
export const RESOURCE_ROLES = new Set(['scratch', 'tools', 'review'])
|
|
50
|
+
export const RECEIPT_VERSION = 1
|
|
51
|
+
|
|
52
|
+
// Bounds for the read-only inventory and for nested-repository / liveness walks. They are
|
|
53
|
+
// deliberately small: this helper runs at the start of a run, not in a filesystem audit.
|
|
54
|
+
export const INVENTORY_MAX_ENTRIES = 5000
|
|
55
|
+
export const INVENTORY_MAX_DEPTH = 8
|
|
56
|
+
const NESTED_SCAN_MAX_ENTRIES = 20000
|
|
57
|
+
const NESTED_SCAN_MAX_DEPTH = 12
|
|
58
|
+
const PROC_SCAN_MAX_PIDS = 5000
|
|
59
|
+
const PROC_SCAN_MAX_FDS = 20000
|
|
60
|
+
const LSOF_TIMEOUT_MS = 3000
|
|
61
|
+
|
|
62
|
+
const RESOLVE_PATH = realpathSync.native ?? realpathSync
|
|
63
|
+
const isObject = (v) => v != null && typeof v === 'object' && !Array.isArray(v)
|
|
64
|
+
const nonEmpty = (v) => typeof v === 'string' && v.trim().length > 0
|
|
65
|
+
|
|
66
|
+
export class LifecycleError extends Error {
|
|
67
|
+
constructor(code, message) {
|
|
68
|
+
super(message)
|
|
69
|
+
this.code = code
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ---- paths and identity ----
|
|
74
|
+
|
|
75
|
+
export function normPath(input) {
|
|
76
|
+
const s = String(input ?? '')
|
|
77
|
+
const stripped = s.replace(/\/+$/, '')
|
|
78
|
+
return stripped || (s.startsWith('/') ? '/' : '')
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function containsPath(parent, child) {
|
|
82
|
+
const p = normPath(parent)
|
|
83
|
+
const c = normPath(child)
|
|
84
|
+
if (!p || !c) return false
|
|
85
|
+
return p === c || (p === '/' ? c.startsWith('/') : c.startsWith(`${p}${sep}`))
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function isHexSha(value) {
|
|
89
|
+
return typeof value === 'string' && (/^[0-9a-f]{40}$/.test(value) || /^[0-9a-f]{64}$/.test(value))
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function pathIdentity(input) {
|
|
93
|
+
const recorded = normPath(input)
|
|
94
|
+
const unresolved = (reason) => ({ recorded, real: null, resolved: false, exists: false, isSymlink: false, reason })
|
|
95
|
+
if (!recorded) return unresolved('an empty path names no tree')
|
|
96
|
+
if (!isAbsolute(recorded)) return unresolved(`${recorded} is not an absolute path`)
|
|
97
|
+
|
|
98
|
+
let isSymlink = false
|
|
99
|
+
try {
|
|
100
|
+
isSymlink = lstatSync(recorded).isSymbolicLink()
|
|
101
|
+
} catch { /* missing is handled below */ }
|
|
102
|
+
if (isSymlink) return unresolved(`${recorded} is a symbolic link; owned workspaces and resources must be real directories`)
|
|
103
|
+
|
|
104
|
+
const trailing = []
|
|
105
|
+
let cur = recorded
|
|
106
|
+
for (let depth = 0; depth < 64; depth++) {
|
|
107
|
+
try {
|
|
108
|
+
const real = RESOLVE_PATH(cur)
|
|
109
|
+
return {
|
|
110
|
+
recorded,
|
|
111
|
+
real: normPath(trailing.length ? join(real, ...trailing) : real) || '/',
|
|
112
|
+
resolved: true,
|
|
113
|
+
exists: trailing.length === 0 && existsSync(recorded),
|
|
114
|
+
isSymlink: false,
|
|
115
|
+
reason: null,
|
|
116
|
+
}
|
|
117
|
+
} catch (error) {
|
|
118
|
+
if (error?.code !== 'ENOENT') return unresolved(`cannot resolve ${cur}: ${error?.code ?? error?.message ?? 'unknown error'}`)
|
|
119
|
+
let dangling = false
|
|
120
|
+
try { dangling = lstatSync(cur).isSymbolicLink() } catch { dangling = false }
|
|
121
|
+
if (dangling) return unresolved(`${cur} is a symbolic link whose target does not exist`)
|
|
122
|
+
const parent = dirname(cur)
|
|
123
|
+
if (!parent || parent === cur) return unresolved(`no existing ancestor of ${recorded} could be resolved`)
|
|
124
|
+
trailing.unshift(cur.slice(parent.length + 1))
|
|
125
|
+
cur = parent
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return unresolved(`gave up resolving ${recorded} after 64 levels`)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// A symlink anywhere inside the managed root would be a second spelling of an owned path.
|
|
132
|
+
// Ancestors outside the root (macOS `/tmp` -> `/private/tmp`) are normal and are handled by
|
|
133
|
+
// comparing real paths; only the final path and segments below the root are rejected.
|
|
134
|
+
function symlinkBelow(root, candidate) {
|
|
135
|
+
const rootReal = pathIdentity(root)
|
|
136
|
+
const candidateReal = pathIdentity(candidate)
|
|
137
|
+
if (!rootReal.resolved || !candidateReal.resolved) return true
|
|
138
|
+
if (!containsPath(rootReal.real, candidateReal.real)) return true
|
|
139
|
+
if (candidateReal.real === rootReal.real) return false
|
|
140
|
+
// Find the original spelling of the root by walking ancestors, then inspect
|
|
141
|
+
// each lexical child. This permits OS aliases above the root (/var on macOS)
|
|
142
|
+
// without letting realpath erase an owned-root symlink such as run/alias/cache.
|
|
143
|
+
let ancestor = resolve(candidate)
|
|
144
|
+
const tail = []
|
|
145
|
+
while (pathIdentity(ancestor).real !== rootReal.real) {
|
|
146
|
+
const parent = dirname(ancestor)
|
|
147
|
+
if (parent === ancestor) return true
|
|
148
|
+
tail.unshift(ancestor.slice(parent.length + 1))
|
|
149
|
+
ancestor = parent
|
|
150
|
+
}
|
|
151
|
+
try { if (lstatSync(ancestor).isSymbolicLink()) return true } catch { return true }
|
|
152
|
+
let cur = ancestor
|
|
153
|
+
for (const part of tail) {
|
|
154
|
+
cur = join(cur, part)
|
|
155
|
+
try {
|
|
156
|
+
if (lstatSync(cur).isSymbolicLink()) return true
|
|
157
|
+
} catch (error) {
|
|
158
|
+
// A path that does not exist yet is not a symlink. Only an unreadable existing
|
|
159
|
+
// segment is uncertainty, and uncertainty is refused.
|
|
160
|
+
if (error?.code === 'ENOENT') return false
|
|
161
|
+
return true
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
return false
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function fileIdentity(path) {
|
|
168
|
+
const st = lstatSync(path)
|
|
169
|
+
return {
|
|
170
|
+
realPath: pathIdentity(path).real,
|
|
171
|
+
dev: typeof st.dev === 'number' ? st.dev : null,
|
|
172
|
+
ino: typeof st.ino === 'number' ? st.ino : null,
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function sameIdentity(recorded, current) {
|
|
177
|
+
if (!isObject(recorded) || !isObject(current)) return false
|
|
178
|
+
if (recorded.realPath !== current.realPath) return false
|
|
179
|
+
for (const key of ['dev', 'ino']) {
|
|
180
|
+
if (!Number.isSafeInteger(recorded[key]) || recorded[key] !== current[key]) return false
|
|
181
|
+
}
|
|
182
|
+
return true
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// ---- git ----
|
|
186
|
+
|
|
187
|
+
function git(dir, args, { timeout = 30000, maxBuffer = 16 * 1024 * 1024 } = {}) {
|
|
188
|
+
try {
|
|
189
|
+
const out = execFileSync('git', ['-C', dir, ...args], {
|
|
190
|
+
encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], timeout, maxBuffer,
|
|
191
|
+
// A dry run must not refresh the index merely by reading it. This disables optional
|
|
192
|
+
// locks/writes; commands that must write (fetch, worktree add/remove) still do.
|
|
193
|
+
env: { ...process.env, GIT_OPTIONAL_LOCKS: '0', GIT_NO_REPLACE_OBJECTS: '1' },
|
|
194
|
+
})
|
|
195
|
+
return { ok: true, out: String(out).trimEnd() }
|
|
196
|
+
} catch (error) {
|
|
197
|
+
return { ok: false, out: String(error?.stdout ?? '').trimEnd(), err: String(error?.stderr ?? error?.message ?? '').trim() }
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function gitToplevel(dir) {
|
|
202
|
+
const r = git(dir, ['rev-parse', '--show-toplevel'])
|
|
203
|
+
return r.ok ? r.out : null
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function gitCommonDir(dir) {
|
|
207
|
+
const r = git(dir, ['rev-parse', '--path-format=absolute', '--git-common-dir'])
|
|
208
|
+
return r.ok ? normPath(r.out) : null
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function resolveCommit(repo, ref) {
|
|
212
|
+
if (!nonEmpty(ref)) return null
|
|
213
|
+
const r = git(repo, ['rev-parse', '--verify', '--quiet', `${ref}^{commit}`])
|
|
214
|
+
return r.ok && isHexSha(r.out) ? r.out : null
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function currentHead(dir) {
|
|
218
|
+
const r = git(dir, ['rev-parse', '--verify', 'HEAD'])
|
|
219
|
+
return r.ok && isHexSha(r.out) ? r.out : null
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function currentBranch(dir) {
|
|
223
|
+
const r = git(dir, ['rev-parse', '--abbrev-ref', 'HEAD'])
|
|
224
|
+
return r.ok ? r.out : null
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function remoteUrl(dir, name = 'origin') {
|
|
228
|
+
// Preserve repository identity before Git's insteadOf transport rewrite.
|
|
229
|
+
const r = git(dir, ['config', '--get', `remote.${name}.url`])
|
|
230
|
+
return r.ok && nonEmpty(r.out) ? r.out : null
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function sameRepoUrl(a, b) {
|
|
234
|
+
if (!a || !b) return false
|
|
235
|
+
const clean = (value) => String(value).trim().replace(/^file:\/\//i, '').replace(/\.git$/i, '').replace(/\/+$/, '')
|
|
236
|
+
const left = clean(a)
|
|
237
|
+
const right = clean(b)
|
|
238
|
+
if (left === right) return true
|
|
239
|
+
// Compare local paths as trees, so `/tmp/x` and `/private/tmp/x` are one remote.
|
|
240
|
+
try {
|
|
241
|
+
const l = pathIdentity(left)
|
|
242
|
+
const r = pathIdentity(right)
|
|
243
|
+
if (l.resolved && r.resolved) return l.real === r.real
|
|
244
|
+
} catch { /* fall through to the text compare */ }
|
|
245
|
+
return false
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function slugFromUrl(url) {
|
|
249
|
+
if (!url) return null
|
|
250
|
+
const text = String(url).trim().replace(/\.git$/i, '').replace(/\/+$/, '')
|
|
251
|
+
const match = text.match(/(?:github\.com[:/])([^/]+)\/([^/]+)$/i)
|
|
252
|
+
return match ? `${match[1]}/${match[2]}` : null
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function worktreeList(repo) {
|
|
256
|
+
const r = git(repo, ['worktree', 'list', '--porcelain'])
|
|
257
|
+
if (!r.ok) return null
|
|
258
|
+
return r.out.split('\n').filter((l) => l.startsWith('worktree ')).map((l) => normPath(l.slice('worktree '.length)))
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function isRegisteredWorktree(repo, workspace) {
|
|
262
|
+
const list = worktreeList(repo)
|
|
263
|
+
if (!list) return false
|
|
264
|
+
const target = pathIdentity(workspace)
|
|
265
|
+
if (!target.resolved) return false
|
|
266
|
+
return list.some((entry) => {
|
|
267
|
+
const ident = pathIdentity(entry)
|
|
268
|
+
return ident.resolved && ident.real === target.real
|
|
269
|
+
})
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// ---- plan reading, ownership, and validation ----
|
|
273
|
+
|
|
274
|
+
export function planPathFor(run) {
|
|
275
|
+
return join(resolve(run), 'task-plan.json')
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
export function readPlan(run) {
|
|
279
|
+
const path = planPathFor(run)
|
|
280
|
+
if (!existsSync(path)) throw new LifecycleError('plan-missing', `no task plan at ${path}`)
|
|
281
|
+
let plan
|
|
282
|
+
try {
|
|
283
|
+
plan = JSON.parse(readFileSync(path, 'utf8'))
|
|
284
|
+
} catch (error) {
|
|
285
|
+
throw new LifecycleError('plan-invalid', `cannot read ${path}: ${error.message}`)
|
|
286
|
+
}
|
|
287
|
+
if (!isObject(plan)) throw new LifecycleError('plan-invalid', `${path} is not a JSON object`)
|
|
288
|
+
if (plan.units !== undefined && !Array.isArray(plan.units)) throw new LifecycleError('plan-invalid', `${path} has a non-array units field`)
|
|
289
|
+
if (plan.issues !== undefined && !Array.isArray(plan.issues)) throw new LifecycleError('plan-invalid', `${path} has a non-array issues field`)
|
|
290
|
+
if (Array.isArray(plan.units) && Array.isArray(plan.issues) && JSON.stringify(plan.units) !== JSON.stringify(plan.issues)) {
|
|
291
|
+
throw new LifecycleError('plan-contradictory', `${path} has both units and issues, and they disagree; the plan does not say which roster owns the run`)
|
|
292
|
+
}
|
|
293
|
+
const units = Array.isArray(plan.units) ? plan.units : (Array.isArray(plan.issues) ? plan.issues : [])
|
|
294
|
+
if (!Array.isArray(units) || units.length === 0) throw new LifecycleError('plan-invalid', `${path} has no units`)
|
|
295
|
+
return { path, plan, units }
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
export function unitOf(plan, unitId) {
|
|
299
|
+
const units = Array.isArray(plan?.units) ? plan.units : (Array.isArray(plan?.issues) ? plan.issues : [])
|
|
300
|
+
const matches = units.filter((u) => isObject(u) && String(u.id) === String(unitId))
|
|
301
|
+
if (matches.length === 0) throw new LifecycleError('unit-missing', `unit ${unitId} is not in the plan`)
|
|
302
|
+
if (matches.length > 1) throw new LifecycleError('unit-duplicate', `unit ${unitId} appears ${matches.length} times in the plan`)
|
|
303
|
+
return matches[0]
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
export function planRevisionOf(plan) {
|
|
307
|
+
if (!Number.isSafeInteger(plan?.planRevision) || plan.planRevision < 0) {
|
|
308
|
+
throw new LifecycleError('revision-missing', 'the plan has no nonnegative integer planRevision; bind it through update_plan first')
|
|
309
|
+
}
|
|
310
|
+
return plan.planRevision
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
export function requireRevision(plan, revision) {
|
|
314
|
+
const expected = planRevisionOf(plan)
|
|
315
|
+
const actual = Number(revision)
|
|
316
|
+
if (!Number.isSafeInteger(actual) || actual < 0) throw new LifecycleError('revision-invalid', `--revision must be a nonnegative integer: ${JSON.stringify(revision)}`)
|
|
317
|
+
if (actual !== expected) {
|
|
318
|
+
throw new LifecycleError('revision-mismatch', `plan revision is ${expected}, not ${actual}; reread the plan and retry with its persisted revision`)
|
|
319
|
+
}
|
|
320
|
+
return expected
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// The repository a run belongs to: plan.repoRoot when present, else the nearest ancestor
|
|
324
|
+
// holding `.resolve-issues/rush`. The run must actually sit under that root.
|
|
325
|
+
export function repositoryOf(plan, run) {
|
|
326
|
+
const runReal = pathIdentity(run)
|
|
327
|
+
if (!runReal.resolved) throw new LifecycleError('run-unresolved', `cannot resolve run directory ${run}: ${runReal.reason}`)
|
|
328
|
+
if (nonEmpty(plan?.repoRoot) && !isAbsolute(plan.repoRoot)) {
|
|
329
|
+
throw new LifecycleError('no-owner', `plan.repoRoot must be an absolute path, not ${JSON.stringify(plan.repoRoot)}`)
|
|
330
|
+
}
|
|
331
|
+
let repo = nonEmpty(plan?.repoRoot) ? resolve(plan.repoRoot) : null
|
|
332
|
+
if (!repo) {
|
|
333
|
+
const marker = `${sep}.resolve-issues${sep}rush${sep}`
|
|
334
|
+
const at = `${runReal.real}${sep}`.indexOf(marker)
|
|
335
|
+
if (at >= 0) repo = runReal.real.slice(0, at)
|
|
336
|
+
}
|
|
337
|
+
if (!repo) throw new LifecycleError('no-owner', `cannot tell which repository owns ${run}, and the plan records no repoRoot`)
|
|
338
|
+
const repoReal = pathIdentity(repo)
|
|
339
|
+
if (!repoReal.resolved) throw new LifecycleError('no-owner', `the repository root ${repo} does not resolve: ${repoReal.reason}`)
|
|
340
|
+
const runsRoot = pathIdentity(join(repoReal.real, '.resolve-issues', 'rush'))
|
|
341
|
+
if (!runsRoot.resolved || !containsPath(runsRoot.real, runReal.real) || !containsPath(repoReal.real, runReal.real)) {
|
|
342
|
+
throw new LifecycleError('no-owner', `${run} is not under ${join(repo, '.resolve-issues', 'rush')}`)
|
|
343
|
+
}
|
|
344
|
+
return repoReal.real
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
export function canonicalRunDir(repo, run) {
|
|
348
|
+
const runReal = pathIdentity(run)
|
|
349
|
+
if (!runReal.resolved) throw new LifecycleError('run-unresolved', `cannot resolve run directory ${run}: ${runReal.reason}`)
|
|
350
|
+
const runsRoot = pathIdentity(join(repo, '.resolve-issues', 'rush'))
|
|
351
|
+
if (!runsRoot.resolved || !containsPath(runsRoot.real, runReal.real)) {
|
|
352
|
+
throw new LifecycleError('containment', `${run} is outside ${join(repo, '.resolve-issues', 'rush')}`)
|
|
353
|
+
}
|
|
354
|
+
return runReal.real
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
const lifecycleResources = (unit) => {
|
|
358
|
+
const fromLifecycle = unit?.lifecycle?.resources
|
|
359
|
+
if (Array.isArray(fromLifecycle)) return fromLifecycle
|
|
360
|
+
if (Array.isArray(unit?.resources)) return unit.resources
|
|
361
|
+
return []
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function validateLifecycleShape(lifecycle, unit) {
|
|
365
|
+
const errors = []
|
|
366
|
+
if (lifecycle === undefined || lifecycle === null) return errors
|
|
367
|
+
if (!isObject(lifecycle)) return [{ code: 'lifecycle-invalid', message: `unit ${unit} has a non-object lifecycle` }]
|
|
368
|
+
if (lifecycle.version !== LIFECYCLE_VERSION) errors.push({ code: 'lifecycle-version', message: `unit ${unit} lifecycle.version must be ${LIFECYCLE_VERSION}` })
|
|
369
|
+
const ws = lifecycle.workspace
|
|
370
|
+
if (!isObject(ws)) errors.push({ code: 'lifecycle-workspace', message: `unit ${unit} lifecycle.workspace is missing` })
|
|
371
|
+
else {
|
|
372
|
+
if (!WORKSPACE_KINDS.has(ws.kind)) errors.push({ code: 'lifecycle-workspace-kind', message: `unit ${unit} lifecycle.workspace.kind must be worktree or clone` })
|
|
373
|
+
if (!WORKSPACE_STATES.has(ws.state)) errors.push({ code: 'lifecycle-workspace-state', message: `unit ${unit} lifecycle.workspace.state must be preparing or ready` })
|
|
374
|
+
if (ws.baseSha !== undefined && !isHexSha(ws.baseSha)) errors.push({ code: 'lifecycle-base-sha', message: `unit ${unit} lifecycle.workspace.baseSha must be an exact 40 or 64 character hex SHA` })
|
|
375
|
+
if (ws.identity !== undefined && !isObject(ws.identity)) errors.push({ code: 'lifecycle-identity', message: `unit ${unit} lifecycle.workspace.identity must be an object` })
|
|
376
|
+
}
|
|
377
|
+
if (lifecycle.resources !== undefined && !Array.isArray(lifecycle.resources)) {
|
|
378
|
+
errors.push({ code: 'lifecycle-resources', message: `unit ${unit} lifecycle.resources must be an array` })
|
|
379
|
+
}
|
|
380
|
+
for (const [index, resource] of (Array.isArray(lifecycle.resources) ? lifecycle.resources : []).entries()) {
|
|
381
|
+
if (!isObject(resource)) { errors.push({ code: 'lifecycle-resource', message: `unit ${unit} lifecycle.resources[${index}] is not an object` }); continue }
|
|
382
|
+
if (!nonEmpty(resource.path) || !isAbsolute(resource.path)) errors.push({ code: 'lifecycle-resource-path', message: `unit ${unit} lifecycle.resources[${index}].path must be an absolute path` })
|
|
383
|
+
if (!RESOURCE_KINDS.has(resource.kind)) errors.push({ code: 'lifecycle-resource-kind', message: `unit ${unit} lifecycle.resources[${index}].kind must be directory, clone, or worktree` })
|
|
384
|
+
if (!RESOURCE_ROLES.has(resource.role)) errors.push({ code: 'lifecycle-resource-role', message: `unit ${unit} lifecycle.resources[${index}].role must be scratch, tools, or review` })
|
|
385
|
+
if (resource.disposable !== true) errors.push({ code: 'lifecycle-resource-disposable', message: `unit ${unit} lifecycle.resources[${index}].disposable must be true` })
|
|
386
|
+
if (!WORKSPACE_STATES.has(resource.state)) errors.push({ code: 'lifecycle-resource-state', message: `unit ${unit} lifecycle.resources[${index}].state must be preparing or ready` })
|
|
387
|
+
if (resource.identity !== undefined && !isObject(resource.identity)) errors.push({ code: 'lifecycle-resource-identity', message: `unit ${unit} lifecycle.resources[${index}].identity must be an object` })
|
|
388
|
+
}
|
|
389
|
+
const cleanup = lifecycle.cleanup
|
|
390
|
+
if (cleanup !== undefined) {
|
|
391
|
+
if (!isObject(cleanup)) errors.push({ code: 'lifecycle-cleanup', message: `unit ${unit} lifecycle.cleanup must be an object` })
|
|
392
|
+
else {
|
|
393
|
+
if (!CLEANUP_STATES.has(cleanup.state)) errors.push({ code: 'lifecycle-cleanup-state', message: `unit ${unit} lifecycle.cleanup.state must be pending, applying, complete, or retained` })
|
|
394
|
+
if (cleanup.headSha !== undefined && !isHexSha(cleanup.headSha)) errors.push({ code: 'lifecycle-cleanup-head', message: `unit ${unit} lifecycle.cleanup.headSha must be an exact 40 or 64 character hex SHA` })
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
return errors
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
// Validate ownership before any mutation. This runs for prepare and cleanup, and it only
|
|
401
|
+
// reads the plan and the filesystem.
|
|
402
|
+
export function validateUnit({ repo, run, plan, unit }) {
|
|
403
|
+
const errors = []
|
|
404
|
+
const unitId = String(unit?.id ?? '')
|
|
405
|
+
if (!isObject(unit)) throw new LifecycleError('unit-missing', 'unit is not an object')
|
|
406
|
+
if (!nonEmpty(unitId) || unitId.trim() !== unitId || /[\x00-\x1f\x7f]/.test(unitId) || unitId === '.' || unitId === '..' || unitId.includes('/') || unitId.includes('\\')) {
|
|
407
|
+
errors.push({ code: 'unit-id', message: `unit id ${JSON.stringify(unitId)} cannot name a workspace directory` })
|
|
408
|
+
}
|
|
409
|
+
if (!nonEmpty(unit.workspace) || !isAbsolute(unit.workspace)) {
|
|
410
|
+
errors.push({ code: 'workspace-missing', message: `unit ${unitId} has no absolute unit.workspace; plan the workspace before prepare` })
|
|
411
|
+
}
|
|
412
|
+
if (!nonEmpty(unit.branch)) errors.push({ code: 'branch-missing', message: `unit ${unitId} has no unit.branch` })
|
|
413
|
+
if (!nonEmpty(unit.base)) errors.push({ code: 'base-missing', message: `unit ${unitId} has no unit.base` })
|
|
414
|
+
errors.push(...validateLifecycleShape(unit.lifecycle, unitId))
|
|
415
|
+
|
|
416
|
+
const runDir = canonicalRunDir(repo, run)
|
|
417
|
+
const runIdent = pathIdentity(runDir)
|
|
418
|
+
if (!runIdent.resolved) {
|
|
419
|
+
errors.push({ code: 'run-unresolved', message: `run directory ${runDir} cannot be resolved: ${runIdent.reason}` })
|
|
420
|
+
return { errors, runDir }
|
|
421
|
+
}
|
|
422
|
+
const repoIdent = pathIdentity(repo)
|
|
423
|
+
if (!repoIdent.resolved) {
|
|
424
|
+
errors.push({ code: 'repo-unresolved', message: `repository ${repo} cannot be resolved: ${repoIdent.reason}` })
|
|
425
|
+
return { errors, runDir }
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
const allUnits = Array.isArray(plan?.units) ? plan.units : (Array.isArray(plan?.issues) ? plan.issues : [])
|
|
429
|
+
const seen = new Map()
|
|
430
|
+
const claim = (path, owner, role) => {
|
|
431
|
+
if (!nonEmpty(path) || !isAbsolute(path)) return
|
|
432
|
+
if (normPath(path) !== resolve(path)) {
|
|
433
|
+
errors.push({ code: 'path-spelling', message: `${role} ${path} must use a canonical spelling without dot-segment traversal` })
|
|
434
|
+
return
|
|
435
|
+
}
|
|
436
|
+
const ident = pathIdentity(path)
|
|
437
|
+
if (!ident.resolved) {
|
|
438
|
+
errors.push({ code: 'path-unresolved', message: `${role} ${path} cannot be resolved: ${ident.reason}` })
|
|
439
|
+
return
|
|
440
|
+
}
|
|
441
|
+
if (ident.real === repoIdent.real) errors.push({ code: 'project-root', message: `${role} ${path} is the project root; an owned workspace is not the repository checkout` })
|
|
442
|
+
if (containsPath(ident.real, repoIdent.real) && ident.real !== repoIdent.real) errors.push({ code: 'project-root', message: `${role} ${path} contains the project root` })
|
|
443
|
+
if (containsPath(repoIdent.real, ident.real) && ident.real !== repoIdent.real && !containsPath(runIdent.real, ident.real)) {
|
|
444
|
+
errors.push({ code: 'project-root', message: `${role} ${path} is inside the project checkout but outside the run directory` })
|
|
445
|
+
}
|
|
446
|
+
if (!containsPath(runIdent.real, ident.real)) errors.push({ code: 'containment', message: `${role} ${path} is outside the canonical run directory ${runDir}` })
|
|
447
|
+
if (ident.real === runIdent.real) errors.push({ code: 'broad-parent', message: `${role} ${path} is the run directory; only a unit's own workspace or resource may be removed` })
|
|
448
|
+
if (ident.real === '/' || containsPath(ident.real, runIdent.real)) errors.push({ code: 'broad-parent', message: `${role} ${path} contains the run directory or the filesystem root` })
|
|
449
|
+
if (symlinkBelow(runDir, path)) errors.push({ code: 'symlink', message: `${role} ${path} is a symbolic link, or passes through one below ${runDir}` })
|
|
450
|
+
const key = ident.real
|
|
451
|
+
for (const [claimed, claimant] of seen) {
|
|
452
|
+
if (claimant.owner !== owner && (containsPath(claimed, key) || containsPath(key, claimed))) {
|
|
453
|
+
errors.push({ code: 'overlapping-path', message: `${role} ${path} overlaps ${claimant.owner}'s ${claimed}` })
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
if (seen.has(key)) {
|
|
457
|
+
errors.push({ code: 'duplicate-path', message: `${role} ${path} is claimed by both ${seen.get(key).owner} and ${owner}` })
|
|
458
|
+
} else {
|
|
459
|
+
seen.set(key, { owner, role })
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
// Cross-unit references: every unit's workspace and resources are collected, so two units
|
|
464
|
+
// cannot claim one tree and a resource cannot alias another unit's workspace.
|
|
465
|
+
for (const other of allUnits) {
|
|
466
|
+
if (!isObject(other)) continue
|
|
467
|
+
if (nonEmpty(other.workspace) && isAbsolute(other.workspace)) claim(other.workspace, `unit ${other.id}`, 'workspace')
|
|
468
|
+
for (const resource of lifecycleResources(other)) {
|
|
469
|
+
if (isObject(resource) && nonEmpty(resource.path) && isAbsolute(resource.path)) claim(resource.path, `unit ${other.id}`, `resource:${resource.role ?? 'unknown'}`)
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
if (nonEmpty(unit.workspace) && isAbsolute(unit.workspace)) {
|
|
474
|
+
const ws = pathIdentity(unit.workspace)
|
|
475
|
+
if (ws.resolved) {
|
|
476
|
+
const expectedRoot = pathIdentity(join(runDir, 'units', String(unit.id)))
|
|
477
|
+
if (!expectedRoot.resolved || expectedRoot.real !== ws.real) {
|
|
478
|
+
errors.push({ code: 'workspace-layout', message: `unit ${unitId} workspace ${unit.workspace} is not under ${join(runDir, 'units', String(unit.id))}` })
|
|
479
|
+
}
|
|
480
|
+
const runroot = pathIdentity(runDir)
|
|
481
|
+
if (runroot.resolved && ws.real === runroot.real) errors.push({ code: 'workspace-layout', message: `unit ${unitId} workspace is the run directory` })
|
|
482
|
+
}
|
|
483
|
+
if (existsSync(unit.workspace) && lstatSync(unit.workspace).isSymbolicLink()) {
|
|
484
|
+
errors.push({ code: 'symlink', message: `unit ${unitId} workspace ${unit.workspace} is a symbolic link` })
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
const allowedResourcePrefixes = [
|
|
489
|
+
pathIdentity(join(runDir, 'resources', String(unit.id))),
|
|
490
|
+
pathIdentity(join(runDir, 'reviews', String(unit.id))),
|
|
491
|
+
pathIdentity(unit.workspace),
|
|
492
|
+
].filter((ident) => ident.resolved).map((ident) => ident.real)
|
|
493
|
+
for (const resource of lifecycleResources(unit)) {
|
|
494
|
+
if (!isObject(resource) || !nonEmpty(resource.path) || !isAbsolute(resource.path)) continue
|
|
495
|
+
const ident = pathIdentity(resource.path)
|
|
496
|
+
if (!ident.resolved) continue
|
|
497
|
+
const underWorkspace = nonEmpty(unit.workspace) && containsPath(pathIdentity(unit.workspace).real, ident.real) && ident.real !== pathIdentity(unit.workspace).real
|
|
498
|
+
const underOwnedRoot = allowedResourcePrefixes.some((prefix) => containsPath(prefix, ident.real) && ident.real !== prefix)
|
|
499
|
+
const reviewRoot = pathIdentity(join(runDir, 'reviews', String(unit.id))).real
|
|
500
|
+
const reviewClone = pathIdentity(join(runDir, 'reviews', String(unit.id), 'clone')).real
|
|
501
|
+
if ((!underWorkspace && !underOwnedRoot) || (containsPath(reviewRoot, ident.real) && ident.real !== reviewClone)) {
|
|
502
|
+
errors.push({ code: 'resource-layout', message: `unit ${unitId} resource ${resource.path} is not a workspace child, not under ${join(runDir, 'resources', String(unit.id))}, and not under ${join(runDir, 'reviews', String(unit.id))}` })
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
return { errors, runDir }
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
function assertValid(result) {
|
|
510
|
+
if (result.errors.length) {
|
|
511
|
+
const error = new LifecycleError(result.errors[0].code, result.errors.map((e) => e.message).join('; '))
|
|
512
|
+
error.details = result.errors
|
|
513
|
+
throw error
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
// ---- locking ----
|
|
518
|
+
|
|
519
|
+
function safeName(value) {
|
|
520
|
+
return encodeURIComponent(String(value))
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
function acquireLock(runDir, unitId) {
|
|
524
|
+
const dir = join(runDir, '.workspace-locks')
|
|
525
|
+
if (symlinkBelow(runDir, dir)) throw new LifecycleError('symlink', 'workspace lock directory passes through a symbolic link')
|
|
526
|
+
mkdirSync(dir, { recursive: true })
|
|
527
|
+
const path = join(dir, `${safeName(unitId)}.lock`)
|
|
528
|
+
try {
|
|
529
|
+
mkdirSync(path)
|
|
530
|
+
} catch (error) {
|
|
531
|
+
if (error?.code === 'EEXIST') {
|
|
532
|
+
throw new LifecycleError('locked', `workspace ${unitId} is locked at ${path}; another prepare or cleanup owns it. The lock is never reclaimed by age`)
|
|
533
|
+
}
|
|
534
|
+
throw error
|
|
535
|
+
}
|
|
536
|
+
const owner = join(path, 'owner.json')
|
|
537
|
+
try {
|
|
538
|
+
writeFileSync(owner, `${JSON.stringify({ pid: process.pid, unit: String(unitId), at: new Date().toISOString() }, null, 2)}\n`)
|
|
539
|
+
} catch { /* the directory itself is the lock */ }
|
|
540
|
+
let released = false
|
|
541
|
+
return {
|
|
542
|
+
path,
|
|
543
|
+
release() {
|
|
544
|
+
if (released) return
|
|
545
|
+
released = true
|
|
546
|
+
try { unlinkSync(owner) } catch { /* may be absent */ }
|
|
547
|
+
try { rmSync(path, { recursive: true, force: true }) } catch { /* a lock left behind is visible in recover */ }
|
|
548
|
+
// Remove the empty lock directory too, so a read-only dry run after a completed
|
|
549
|
+
// prepare leaves no lock residue behind.
|
|
550
|
+
try { rmdirSync(dir) } catch { /* another unit's lock is still there */ }
|
|
551
|
+
},
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
// ---- workspace creation ----
|
|
556
|
+
|
|
557
|
+
function defaultKind(unit, repo) {
|
|
558
|
+
const planned = unit?.lifecycle?.workspace?.kind ?? unit?.workspaceKind
|
|
559
|
+
if (planned !== undefined) return planned
|
|
560
|
+
return gitToplevel(repo) ? 'worktree' : 'clone'
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
function baseShaFor(repo, unit) {
|
|
564
|
+
const planned = unit?.lifecycle?.workspace?.baseSha
|
|
565
|
+
if (planned !== undefined) {
|
|
566
|
+
if (!isHexSha(planned)) throw new LifecycleError('lifecycle-base-sha', `unit ${unit.id} lifecycle.workspace.baseSha is not an exact 40 or 64 character hex SHA`)
|
|
567
|
+
const exists = git(repo, ['cat-file', '-e', `${planned}^{commit}`]).ok
|
|
568
|
+
if (!exists) throw new LifecycleError('base-missing', `unit ${unit.id} baseSha ${planned} is not a commit in ${repo}`)
|
|
569
|
+
return planned
|
|
570
|
+
}
|
|
571
|
+
const sha = resolveCommit(repo, unit.base)
|
|
572
|
+
if (!sha) throw new LifecycleError('base-missing', `unit ${unit.id} base ${JSON.stringify(unit.base)} does not resolve to a commit in ${repo}`)
|
|
573
|
+
return sha
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
function createWorktree({ repo, workspace, branch, baseSha }) {
|
|
577
|
+
const branchExists = git(repo, ['show-ref', '--verify', '--quiet', `refs/heads/${branch}`]).ok
|
|
578
|
+
if (branchExists && resolveCommit(repo, `refs/heads/${branch}`) !== baseSha) {
|
|
579
|
+
throw new LifecycleError('base-mismatch', `existing branch ${branch} is not at the planned base ${baseSha}`)
|
|
580
|
+
}
|
|
581
|
+
const args = ['worktree', 'add']
|
|
582
|
+
if (branchExists) args.push(workspace, branch)
|
|
583
|
+
else args.push('-b', branch, workspace, baseSha)
|
|
584
|
+
const result = git(repo, args)
|
|
585
|
+
if (!result.ok) throw new LifecycleError('worktree-create', `git worktree add failed for ${workspace}: ${result.err || result.out}`)
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
function createClone({ repo, workspace, branch, baseSha, source = repo }) {
|
|
589
|
+
// Fetch only the prepared commit from the local source. Cloning that source wholesale
|
|
590
|
+
// would relabel its private branches as origin/* when the origin URL is changed.
|
|
591
|
+
const origin = remoteUrl(repo, 'origin')
|
|
592
|
+
if (!origin) throw new LifecycleError('clone-origin', 'the repository has no recorded origin')
|
|
593
|
+
const existing = resolveCommit(repo, `refs/heads/${branch}`)
|
|
594
|
+
if (existing && existing !== baseSha) throw new LifecycleError('base-mismatch', `existing branch ${branch} is not at the planned base ${baseSha}`)
|
|
595
|
+
const init = git(dirname(workspace), ['init', '--quiet', workspace])
|
|
596
|
+
if (!init.ok) throw new LifecycleError('clone-create', init.err)
|
|
597
|
+
for (const args of [
|
|
598
|
+
['remote', 'add', 'origin', origin],
|
|
599
|
+
['fetch', '--quiet', '--no-tags', '--no-write-fetch-head', source, baseSha],
|
|
600
|
+
['checkout', '--quiet', '-b', branch, baseSha],
|
|
601
|
+
]) {
|
|
602
|
+
const result = git(workspace, args)
|
|
603
|
+
if (!result.ok) throw new LifecycleError('clone-create', `${workspace}: ${result.err || result.out}`)
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
function workspaceKindOf(path) {
|
|
608
|
+
const dotGit = join(path, '.git')
|
|
609
|
+
if (!existsSync(dotGit)) return null
|
|
610
|
+
try {
|
|
611
|
+
return lstatSync(dotGit).isFile() ? 'worktree' : 'clone'
|
|
612
|
+
} catch {
|
|
613
|
+
return null
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
function workspaceIdentity(workspace) {
|
|
618
|
+
const ident = fileIdentity(workspace)
|
|
619
|
+
return {
|
|
620
|
+
...ident,
|
|
621
|
+
gitCommonDir: gitCommonDir(workspace),
|
|
622
|
+
headSha: currentHead(workspace),
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
function resourceIdentity(path) {
|
|
627
|
+
const ident = fileIdentity(path)
|
|
628
|
+
const kind = existsSync(join(path, '.git')) ? workspaceKindOf(path) : 'directory'
|
|
629
|
+
return kind && kind !== 'directory'
|
|
630
|
+
? { ...ident, gitCommonDir: gitCommonDir(path) }
|
|
631
|
+
: ident
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
function ensureDirectory(path) {
|
|
635
|
+
if (existsSync(path)) {
|
|
636
|
+
if (lstatSync(path).isSymbolicLink() || !lstatSync(path).isDirectory()) {
|
|
637
|
+
throw new LifecycleError('resource-exists', `${path} exists but is not a real directory`)
|
|
638
|
+
}
|
|
639
|
+
return
|
|
640
|
+
}
|
|
641
|
+
mkdirSync(path, { recursive: true })
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
function createResources({ unit, repo, workspace }) {
|
|
645
|
+
const resources = []
|
|
646
|
+
for (const planned of lifecycleResources(unit)) {
|
|
647
|
+
if (!isObject(planned)) continue
|
|
648
|
+
const path = planned.path
|
|
649
|
+
if (planned.state === 'ready' && !existsSync(path)) {
|
|
650
|
+
throw new LifecycleError('resource-missing', `unit ${unit.id} recorded ready resource ${path} is missing; not recreating over a lost tree`)
|
|
651
|
+
}
|
|
652
|
+
if (existsSync(path)) {
|
|
653
|
+
if (lstatSync(path).isSymbolicLink()) throw new LifecycleError('symlink', `resource ${path} is a symbolic link`)
|
|
654
|
+
if (planned.state === 'ready') {
|
|
655
|
+
const current = resourceIdentity(path)
|
|
656
|
+
if (!sameIdentity(planned.identity, current) || (planned.identity.gitCommonDir && planned.identity.gitCommonDir !== current.gitCommonDir)) {
|
|
657
|
+
throw new LifecycleError('resource-identity', `resource ${path} does not match its recorded identity; refusing to adopt a replacement tree`)
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
} else {
|
|
661
|
+
mkdirSync(dirname(path), { recursive: true })
|
|
662
|
+
if (planned.kind === 'clone') {
|
|
663
|
+
createClone({ repo, source: workspace, workspace: path, branch: `review-${safeName(unit.id)}`, baseSha: currentHead(workspace) })
|
|
664
|
+
} else if (planned.kind === 'worktree') {
|
|
665
|
+
const created = git(repo, ['worktree', 'add', '--quiet', '--detach', path, currentHead(workspace)])
|
|
666
|
+
if (!created.ok) throw new LifecycleError('resource-create', created.err)
|
|
667
|
+
} else ensureDirectory(path)
|
|
668
|
+
}
|
|
669
|
+
const actualKind = workspaceKindOf(path) ?? 'directory'
|
|
670
|
+
if (actualKind !== planned.kind) throw new LifecycleError('resource-kind', `resource ${path} is ${actualKind}, not ${planned.kind}; prepare cannot mark an empty directory as a clone`)
|
|
671
|
+
const identity = resourceIdentity(path)
|
|
672
|
+
resources.push({
|
|
673
|
+
...planned,
|
|
674
|
+
path: normPath(path),
|
|
675
|
+
kind: RESOURCE_KINDS.has(planned.kind) ? planned.kind : 'directory',
|
|
676
|
+
role: RESOURCE_ROLES.has(planned.role) ? planned.role : 'scratch',
|
|
677
|
+
disposable: true,
|
|
678
|
+
state: 'ready',
|
|
679
|
+
identity,
|
|
680
|
+
})
|
|
681
|
+
}
|
|
682
|
+
// A review clone is conventionally `reviews/<unit>` even when it is not listed in the
|
|
683
|
+
// planned resources: older plans recorded it only by path. It is not adopted or created
|
|
684
|
+
// here; it is simply never treated as a workspace.
|
|
685
|
+
return resources
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
function cleanupFor(unit, headSha) {
|
|
689
|
+
const previous = isObject(unit?.lifecycle?.cleanup) ? unit.lifecycle.cleanup : {}
|
|
690
|
+
return {
|
|
691
|
+
...previous,
|
|
692
|
+
state: previous.state && CLEANUP_STATES.has(previous.state) ? previous.state : 'pending',
|
|
693
|
+
headSha: isHexSha(previous.headSha) ? previous.headSha : headSha,
|
|
694
|
+
...(previous.reason ? { reason: previous.reason } : {}),
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
export function prepare({
|
|
699
|
+
run, unit: unitId, revision, apply = false, kind = null,
|
|
700
|
+
}) {
|
|
701
|
+
run = resolve(run)
|
|
702
|
+
const first = readPlan(run)
|
|
703
|
+
const repo = repositoryOf(first.plan, run)
|
|
704
|
+
requireRevision(first.plan, revision)
|
|
705
|
+
const unit = unitOf(first.plan, unitId)
|
|
706
|
+
assertValid(validateUnit({ repo, run, plan: first.plan, unit }))
|
|
707
|
+
const resolvedKind = kind ?? defaultKind(unit, repo)
|
|
708
|
+
if (!WORKSPACE_KINDS.has(resolvedKind)) throw new LifecycleError('kind-invalid', `--kind must be worktree or clone, not ${JSON.stringify(kind)}`)
|
|
709
|
+
if (unit.lifecycle?.workspace?.kind && resolvedKind !== unit.lifecycle.workspace.kind) {
|
|
710
|
+
throw new LifecycleError('kind-mismatch', `the plan records ${unit.lifecycle.workspace.kind}, not ${resolvedKind}`)
|
|
711
|
+
}
|
|
712
|
+
const baseSha = baseShaFor(repo, unit)
|
|
713
|
+
const workspace = normPath(unit.workspace)
|
|
714
|
+
|
|
715
|
+
if (!apply) {
|
|
716
|
+
return {
|
|
717
|
+
version: 1,
|
|
718
|
+
command: 'prepare',
|
|
719
|
+
ok: true,
|
|
720
|
+
applied: false,
|
|
721
|
+
action: existsSync(workspace) ? 'verify' : 'create',
|
|
722
|
+
repo,
|
|
723
|
+
run: canonicalRunDir(repo, run),
|
|
724
|
+
unit: String(unitId),
|
|
725
|
+
revision: planRevisionOf(first.plan),
|
|
726
|
+
workspace: {
|
|
727
|
+
path: workspace,
|
|
728
|
+
kind: resolvedKind,
|
|
729
|
+
state: existsSync(workspace) ? 'ready' : 'preparing',
|
|
730
|
+
baseSha,
|
|
731
|
+
identity: existsSync(workspace) ? workspaceIdentity(workspace) : null,
|
|
732
|
+
exists: existsSync(workspace),
|
|
733
|
+
},
|
|
734
|
+
resources: lifecycleResources(unit).map((r) => ({
|
|
735
|
+
path: r.path, kind: r.kind ?? 'directory', role: r.role ?? 'scratch', disposable: true,
|
|
736
|
+
state: existsSync(r.path) ? 'ready' : 'preparing', identity: existsSync(r.path) ? resourceIdentity(r.path) : null,
|
|
737
|
+
exists: existsSync(r.path),
|
|
738
|
+
})),
|
|
739
|
+
nextLifecycle: {
|
|
740
|
+
version: 1,
|
|
741
|
+
workspace: { path: workspace, kind: resolvedKind, state: 'preparing', baseSha, identity: null },
|
|
742
|
+
resources: lifecycleResources(unit).map((r) => ({ ...r, state: 'preparing', identity: null })),
|
|
743
|
+
cleanup: cleanupFor(unit, existsSync(workspace) ? currentHead(workspace) : null),
|
|
744
|
+
},
|
|
745
|
+
checks: [{ name: 'plan', ok: true, detail: `revision ${planRevisionOf(first.plan)}` }],
|
|
746
|
+
errors: [],
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
const runDir = canonicalRunDir(repo, run)
|
|
751
|
+
const lock = acquireLock(runDir, unitId)
|
|
752
|
+
try {
|
|
753
|
+
// Re-read under the lock: a concurrent plan transition or another prepare must not be
|
|
754
|
+
// overwritten by a decision made against a stale revision.
|
|
755
|
+
const current = readPlan(run)
|
|
756
|
+
requireRevision(current.plan, revision)
|
|
757
|
+
const freshUnit = unitOf(current.plan, unitId)
|
|
758
|
+
assertValid(validateUnit({ repo, run, plan: current.plan, unit: freshUnit }))
|
|
759
|
+
if (JSON.stringify(current.plan) !== JSON.stringify(first.plan)) throw new LifecycleError('revision-mismatch', 'plan changed before prepare acquired its lock')
|
|
760
|
+
const freshBase = baseShaFor(repo, freshUnit)
|
|
761
|
+
if (freshBase !== baseSha) throw new LifecycleError('revision-mismatch', `unit ${unitId} base changed from ${baseSha} to ${freshBase}; reread the plan`)
|
|
762
|
+
if (freshUnit.lifecycle?.cleanup?.state === 'complete' && existsSync(workspace)) {
|
|
763
|
+
throw new LifecycleError('cleanup-final', `unit ${unitId} is recorded complete, but its workspace still exists; the plan and the disk disagree`)
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
let existing = existsSync(workspace)
|
|
767
|
+
// An empty directory at the planned path is not a used tree: the coordinator planned the
|
|
768
|
+
// path, and prepare is the operation that fills it. Only an empty directory is replaced;
|
|
769
|
+
// anything else must already be the recorded workspace.
|
|
770
|
+
if (existing && !lstatSync(workspace).isDirectory()) {
|
|
771
|
+
throw new LifecycleError('workspace-invalid', `planned workspace ${workspace} exists but is not a directory`)
|
|
772
|
+
}
|
|
773
|
+
if (existing && readdirSync(workspace).length === 0 && !(freshUnit.lifecycle?.workspace?.state === 'ready')) {
|
|
774
|
+
rmdirSync(workspace)
|
|
775
|
+
existing = false
|
|
776
|
+
}
|
|
777
|
+
let action = 'verify'
|
|
778
|
+
if (existing) {
|
|
779
|
+
if (lstatSync(workspace).isSymbolicLink()) throw new LifecycleError('symlink', `workspace ${workspace} is a symbolic link`)
|
|
780
|
+
if (!freshUnit.lifecycle) throw new LifecycleError('unowned-tree', `workspace ${workspace} exists but the plan records no lifecycle for unit ${unitId}; refusing to adopt it`)
|
|
781
|
+
if (freshUnit.lifecycle.workspace?.state === 'ready' && !existsSync(join(workspace, '.git'))) {
|
|
782
|
+
throw new LifecycleError('workspace-missing-git', `workspace ${workspace} is recorded ready but is not a Git workspace`)
|
|
783
|
+
}
|
|
784
|
+
const actualKind = workspaceKindOf(workspace)
|
|
785
|
+
if (!actualKind) throw new LifecycleError('workspace-invalid', `workspace ${workspace} exists but is not a Git worktree or clone`)
|
|
786
|
+
if (actualKind !== resolvedKind) throw new LifecycleError('kind-mismatch', `workspace ${workspace} is a ${actualKind}, not the recorded ${resolvedKind}`)
|
|
787
|
+
const branch = currentBranch(workspace)
|
|
788
|
+
if (branch !== freshUnit.branch) throw new LifecycleError('branch-mismatch', `workspace ${workspace} is on ${branch ?? 'an unknown branch'}, not ${freshUnit.branch}`)
|
|
789
|
+
if (!git(workspace, ['merge-base', '--is-ancestor', freshBase, 'HEAD']).ok && currentHead(workspace) !== freshBase) {
|
|
790
|
+
throw new LifecycleError('base-mismatch', `workspace ${workspace} does not contain its recorded base ${freshBase}; refusing to reset a used tree`)
|
|
791
|
+
}
|
|
792
|
+
const currentIdentity = workspaceIdentity(workspace)
|
|
793
|
+
if (freshUnit.lifecycle.workspace?.state === 'ready' && (!sameIdentity(freshUnit.lifecycle.workspace.identity, currentIdentity)
|
|
794
|
+
|| freshUnit.lifecycle.workspace.identity.gitCommonDir !== currentIdentity.gitCommonDir)) {
|
|
795
|
+
throw new LifecycleError('workspace-identity', `workspace ${workspace} does not match its recorded identity; refusing to adopt a replacement tree`)
|
|
796
|
+
}
|
|
797
|
+
} else {
|
|
798
|
+
if (freshUnit.lifecycle?.workspace?.state === 'ready') {
|
|
799
|
+
throw new LifecycleError('workspace-lost', `unit ${unitId} records a ready workspace at ${workspace}, but it is missing; not recreating it`)
|
|
800
|
+
}
|
|
801
|
+
if (freshUnit.lifecycle?.cleanup?.state === 'complete' || freshUnit.lifecycle?.cleanup?.state === 'retained') {
|
|
802
|
+
throw new LifecycleError('cleanup-final', `unit ${unitId} is already ${freshUnit.lifecycle.cleanup.state}; prepare will not recreate its workspace`)
|
|
803
|
+
}
|
|
804
|
+
action = 'create'
|
|
805
|
+
mkdirSync(dirname(workspace), { recursive: true })
|
|
806
|
+
try {
|
|
807
|
+
if (resolvedKind === 'worktree') createWorktree({ repo, workspace, branch: freshUnit.branch, baseSha: freshBase })
|
|
808
|
+
else createClone({ repo, workspace, branch: freshUnit.branch, baseSha: freshBase })
|
|
809
|
+
} catch (error) {
|
|
810
|
+
// A Pi sandbox can deny linked `.git` files. If the plan did not choose the kind and
|
|
811
|
+
// `git worktree add` failed before leaving a tree we can identify, fall back to an
|
|
812
|
+
// independent clone. Never fall back over an existing tree.
|
|
813
|
+
const branchExistsInRepo = git(repo, ['show-ref', '--verify', '--quiet', `refs/heads/${freshUnit.branch}`]).ok
|
|
814
|
+
const absentOrEmpty = !existsSync(workspace) || (lstatSync(workspace).isDirectory() && readdirSync(workspace).length === 0)
|
|
815
|
+
if (resolvedKind === 'worktree' && !kind && !freshUnit.lifecycle?.workspace?.kind && !branchExistsInRepo && absentOrEmpty) {
|
|
816
|
+
if (existsSync(workspace)) rmdirSync(workspace)
|
|
817
|
+
createClone({ repo, workspace, branch: freshUnit.branch, baseSha: freshBase })
|
|
818
|
+
} else {
|
|
819
|
+
throw error
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
const actualKind = workspaceKindOf(workspace)
|
|
825
|
+
const identity = workspaceIdentity(workspace)
|
|
826
|
+
if (!identity.headSha) throw new LifecycleError('workspace-invalid', `workspace ${workspace} has no readable HEAD`)
|
|
827
|
+
const resources = createResources({ unit: freshUnit, repo, workspace })
|
|
828
|
+
const lifecycle = {
|
|
829
|
+
...freshUnit.lifecycle,
|
|
830
|
+
version: LIFECYCLE_VERSION,
|
|
831
|
+
workspace: {
|
|
832
|
+
path: workspace,
|
|
833
|
+
kind: actualKind,
|
|
834
|
+
state: 'ready',
|
|
835
|
+
baseSha: freshBase,
|
|
836
|
+
identity,
|
|
837
|
+
},
|
|
838
|
+
resources,
|
|
839
|
+
cleanup: cleanupFor(freshUnit, identity.headSha),
|
|
840
|
+
}
|
|
841
|
+
return {
|
|
842
|
+
version: 1,
|
|
843
|
+
command: 'prepare',
|
|
844
|
+
ok: true,
|
|
845
|
+
applied: true,
|
|
846
|
+
action,
|
|
847
|
+
repo,
|
|
848
|
+
run: runDir,
|
|
849
|
+
unit: String(unitId),
|
|
850
|
+
revision: planRevisionOf(current.plan),
|
|
851
|
+
workspace: { ...lifecycle.workspace, exists: true },
|
|
852
|
+
resources: resources.map((r) => ({ ...r, exists: true })),
|
|
853
|
+
lifecycle,
|
|
854
|
+
nextLifecycle: lifecycle,
|
|
855
|
+
checks: [
|
|
856
|
+
{ name: 'plan', ok: true, detail: `revision ${planRevisionOf(current.plan)}` },
|
|
857
|
+
{ name: 'workspace', ok: true, detail: `${actualKind} ${workspace}` },
|
|
858
|
+
{ name: 'base', ok: true, detail: freshBase },
|
|
859
|
+
],
|
|
860
|
+
errors: [],
|
|
861
|
+
}
|
|
862
|
+
} finally {
|
|
863
|
+
lock.release()
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
// ---- liveness and process use ----
|
|
868
|
+
|
|
869
|
+
// Linux can return EACCES when a task exits during a /proc read. Only signal-0 ESRCH
|
|
870
|
+
// proves that exact PID is gone and permits one fresh scan. All other uncertainty
|
|
871
|
+
// retains the environment (ADR 0023).
|
|
872
|
+
function readProcLink(path, pid) {
|
|
873
|
+
try { return { link: readlinkSync(path) } } catch (error) {
|
|
874
|
+
const errno = error?.code ?? 'unknown'
|
|
875
|
+
if (errno === 'ENOENT' || errno === 'ESRCH') return { link: null }
|
|
876
|
+
return { failure: { path, operation: 'readlink', errno, pid } }
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
function probePid(pid) {
|
|
881
|
+
const operation = `process.kill(${pid}, 0)`
|
|
882
|
+
const numeric = Number(pid)
|
|
883
|
+
if (!Number.isSafeInteger(numeric) || numeric <= 0) return { exited: false, operation, errno: 'EINVAL' }
|
|
884
|
+
try {
|
|
885
|
+
process.kill(numeric, 0)
|
|
886
|
+
return { exited: false, operation, errno: null }
|
|
887
|
+
} catch (error) {
|
|
888
|
+
const errno = error?.code ?? 'unknown'
|
|
889
|
+
return { exited: errno === 'ESRCH', operation, errno }
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
// Only an EACCES read attributed to one exact pid may be probed. A failure without a pid
|
|
894
|
+
// (the /proc listing itself) or with another errno is a refusal with no escape hatch.
|
|
895
|
+
function procRefusal(failure) {
|
|
896
|
+
return {
|
|
897
|
+
refusal: failure,
|
|
898
|
+
...(failure.errno === 'EACCES' && failure.pid ? { probe: String(failure.pid) } : {}),
|
|
899
|
+
}
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
// One complete, bounded pass over /proc. Every call builds its own pid set, enumeration and
|
|
903
|
+
// counters, so a restart can never inherit the abandoned pass's budget or findings.
|
|
904
|
+
function scanProcOnce(targets) {
|
|
905
|
+
const pids = new Set()
|
|
906
|
+
let fdsSeen = 0
|
|
907
|
+
let procCount = 0
|
|
908
|
+
let names = []
|
|
909
|
+
try { names = readdirSync('/proc') } catch (error) {
|
|
910
|
+
return { refusal: { path: '/proc', operation: 'readdir', errno: error?.code ?? 'unknown' } }
|
|
911
|
+
}
|
|
912
|
+
const overPids = () => ({ refusal: { path: '/proc', operation: 'enumerate-pids', errno: 'PROC_SCAN_MAX_PIDS', limit: PROC_SCAN_MAX_PIDS } })
|
|
913
|
+
const overFds = (name) => ({ refusal: { path: `/proc/${name}/fd`, operation: 'enumerate-fds', errno: 'PROC_SCAN_MAX_FDS', limit: PROC_SCAN_MAX_FDS } })
|
|
914
|
+
for (const name of names) {
|
|
915
|
+
if (!/^\d+$/.test(name)) continue
|
|
916
|
+
if (procCount++ >= PROC_SCAN_MAX_PIDS) return overPids()
|
|
917
|
+
const cwd = readProcLink(`/proc/${name}/cwd`, name)
|
|
918
|
+
if (cwd.failure) return procRefusal(cwd.failure)
|
|
919
|
+
if (cwd.link && targets.some((target) => containsPath(target, cwd.link))) { pids.add(name); continue }
|
|
920
|
+
let fds = []
|
|
921
|
+
try { fds = readdirSync(`/proc/${name}/fd`) } catch (error) {
|
|
922
|
+
const errno = error?.code ?? 'unknown'
|
|
923
|
+
if (errno === 'ENOENT' || errno === 'ESRCH') continue
|
|
924
|
+
return procRefusal({ path: `/proc/${name}/fd`, operation: 'readdir', errno, pid: name })
|
|
925
|
+
}
|
|
926
|
+
for (const fd of fds) {
|
|
927
|
+
if (fdsSeen++ >= PROC_SCAN_MAX_FDS) return overFds(name)
|
|
928
|
+
const link = readProcLink(`/proc/${name}/fd/${fd}`, name)
|
|
929
|
+
if (link.failure) return procRefusal(link.failure)
|
|
930
|
+
if (link.link && targets.some((target) => containsPath(target, link.link))) { pids.add(name); break }
|
|
931
|
+
}
|
|
932
|
+
if (fdsSeen > PROC_SCAN_MAX_FDS) return overFds(name)
|
|
933
|
+
}
|
|
934
|
+
return { pids: [...pids] }
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
function linuxProcessesUsing(paths, onUncertain) {
|
|
938
|
+
if (!existsSync('/proc')) return null
|
|
939
|
+
const targets = paths.map((p) => pathIdentity(p)).filter((i) => i.resolved).map((i) => i.real)
|
|
940
|
+
if (!targets.length) return []
|
|
941
|
+
let restarts = 0
|
|
942
|
+
for (;;) {
|
|
943
|
+
const scan = scanProcOnce(targets)
|
|
944
|
+
if (scan.pids) return scan.pids
|
|
945
|
+
if (!scan.probe || restarts > 0) {
|
|
946
|
+
onUncertain?.(scan.refusal)
|
|
947
|
+
return 'unreadable'
|
|
948
|
+
}
|
|
949
|
+
const probe = probePid(scan.probe)
|
|
950
|
+
if (!probe.exited) {
|
|
951
|
+
onUncertain?.({ ...scan.refusal, probe })
|
|
952
|
+
return 'unreadable'
|
|
953
|
+
}
|
|
954
|
+
restarts += 1
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
function lsofProcessesUsing(paths) {
|
|
959
|
+
try {
|
|
960
|
+
// Enumerate open files once, bounded by time and output. No recursive lsof +D walk.
|
|
961
|
+
const out = execFileSync('lsof', ['-nP', '-Fn'], {
|
|
962
|
+
encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: LSOF_TIMEOUT_MS, maxBuffer: 32 * 1024 * 1024,
|
|
963
|
+
})
|
|
964
|
+
const targets = paths.map((p) => pathIdentity(p)).filter((i) => i.resolved).map((i) => i.real)
|
|
965
|
+
const pids = new Set()
|
|
966
|
+
let pid = null
|
|
967
|
+
for (const line of String(out).split('\n')) {
|
|
968
|
+
if (line.startsWith('p')) pid = line.slice(1).trim()
|
|
969
|
+
else if (line.startsWith('n') && pid) {
|
|
970
|
+
const cwd = line.slice(1)
|
|
971
|
+
if (targets.some((target) => containsPath(target, cwd))) pids.add(pid)
|
|
972
|
+
}
|
|
973
|
+
}
|
|
974
|
+
return [...pids]
|
|
975
|
+
} catch (error) {
|
|
976
|
+
if (error?.code === 'ENOENT' || error?.status === 127) return 'unavailable'
|
|
977
|
+
if (error?.status === 1 && !String(error?.stdout ?? '').trim()) return []
|
|
978
|
+
return 'unreadable'
|
|
979
|
+
}
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
export function liveProcessesUsing(paths, options = {}) {
|
|
983
|
+
const onUncertain = typeof options?.onUncertain === 'function' ? options.onUncertain : null
|
|
984
|
+
const linux = linuxProcessesUsing(paths, onUncertain)
|
|
985
|
+
if (linux !== null) return linux
|
|
986
|
+
return lsofProcessesUsing(paths)
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
// ---- unpublished work, evidence, nested repos ----
|
|
990
|
+
|
|
991
|
+
function hasRecordedWorker(plan, unit) {
|
|
992
|
+
const rosters = [...(plan?.units ?? []), ...(plan?.issues ?? [])]
|
|
993
|
+
const rows = [...rosters, ...(Array.isArray(plan?.fleet) ? plan.fleet : [])]
|
|
994
|
+
if (Array.isArray(plan?.workers)) rows.push(...plan.workers)
|
|
995
|
+
if (isObject(plan?.workers)) rows.push(...Object.values(plan.workers))
|
|
996
|
+
const paths = [unit.workspace, ...lifecycleResources(unit).map(r => r.path)].map(p => pathIdentity(p).real).filter(Boolean)
|
|
997
|
+
return rows.filter(row => {
|
|
998
|
+
if (!isObject(row) || row.worker == null || row.worker === '') return false
|
|
999
|
+
const owner = row.unit ?? row.unitId ?? (rosters.includes(row) ? row.id : null)
|
|
1000
|
+
const claims = [row.workspace, ...lifecycleResources(row).map(r => r.path)].filter(nonEmpty).map(p => pathIdentity(p).real).filter(Boolean)
|
|
1001
|
+
if (claims.some(p => paths.some(q => containsPath(p, q) || containsPath(q, p)))) return true
|
|
1002
|
+
// A handle without an owner or path is ambiguous, not an unrelated worker.
|
|
1003
|
+
return owner == null || String(owner) === String(unit.id)
|
|
1004
|
+
}).map(row => String(row.worker))
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
function statusLines(workspace) {
|
|
1008
|
+
const r = git(workspace, ['status', '--porcelain=v1', '--ignored=matching'])
|
|
1009
|
+
if (!r.ok) return null
|
|
1010
|
+
return r.out.split('\n').map((l) => l.replace(/\s+$/, '')).filter(Boolean)
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
function interruptedGitState(workspace) {
|
|
1014
|
+
const checks = ['rebase-merge', 'rebase-apply', 'MERGE_HEAD', 'CHERRY_PICK_HEAD', 'REVERT_HEAD', 'BISECT_LOG', 'sequencer']
|
|
1015
|
+
const found = []
|
|
1016
|
+
const dir = git(workspace, ['rev-parse', '--path-format=absolute', '--git-dir'])
|
|
1017
|
+
if (dir.ok && existsSync(join(dir.out, 'locked'))) found.push('locked')
|
|
1018
|
+
for (const name of checks) {
|
|
1019
|
+
const r = git(workspace, ['rev-parse', '--path-format=absolute', '--git-path', name])
|
|
1020
|
+
if (r.ok && existsSync(r.out)) found.push(name)
|
|
1021
|
+
}
|
|
1022
|
+
return found
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
function nestedRepositories(workspace, skipPaths) {
|
|
1026
|
+
const root = pathIdentity(workspace)
|
|
1027
|
+
if (!root.resolved) return { found: [], truncated: false }
|
|
1028
|
+
const skip = new Set(skipPaths.map((p) => pathIdentity(p)).filter((i) => i.resolved).map((i) => i.real))
|
|
1029
|
+
const found = []
|
|
1030
|
+
let seen = 0
|
|
1031
|
+
let truncated = false
|
|
1032
|
+
const walk = (dir, depth) => {
|
|
1033
|
+
if (truncated) return
|
|
1034
|
+
if (depth > NESTED_SCAN_MAX_DEPTH) { truncated = true; return }
|
|
1035
|
+
let entries = []
|
|
1036
|
+
try { entries = readdirSync(dir, { withFileTypes: true }) } catch { truncated = true; return }
|
|
1037
|
+
for (const entry of entries) {
|
|
1038
|
+
if (seen++ >= NESTED_SCAN_MAX_ENTRIES) { truncated = true; return }
|
|
1039
|
+
const path = join(dir, entry.name)
|
|
1040
|
+
const ident = pathIdentity(path)
|
|
1041
|
+
if (ident.resolved && skip.has(ident.real)) continue
|
|
1042
|
+
if (entry.name === '.git' && depth === 0) continue
|
|
1043
|
+
if (entry.name === '.git' && depth > 0) { found.push(path); continue }
|
|
1044
|
+
if (entry.isDirectory() && existsSync(join(path, 'HEAD')) && existsSync(join(path, 'objects')) && existsSync(join(path, 'refs'))) { found.push(path); continue }
|
|
1045
|
+
if (!entry.isDirectory()) continue
|
|
1046
|
+
walk(path, depth + 1)
|
|
1047
|
+
}
|
|
1048
|
+
}
|
|
1049
|
+
walk(root.real, 0)
|
|
1050
|
+
return { found, truncated }
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
function evidenceEntries(unit) {
|
|
1054
|
+
const declarations = [unit?.lifecycle?.evidence, unit?.evidence, unit?.lifecycle?.cleanup?.evidence, unit?.requiredEvidence, unit?.lifecycle?.requiredEvidence].filter(v => v !== undefined)
|
|
1055
|
+
const entries = []
|
|
1056
|
+
for (const raw of declarations) {
|
|
1057
|
+
if (!Array.isArray(raw)) return null
|
|
1058
|
+
for (const entry of raw) {
|
|
1059
|
+
if (typeof entry === 'string' && nonEmpty(entry)) entries.push({ path: entry, required: true })
|
|
1060
|
+
else if (isObject(entry) && nonEmpty(entry.path) && (entry.required === undefined || typeof entry.required === 'boolean')) entries.push({ path: entry.path, required: entry.required !== false })
|
|
1061
|
+
else return null
|
|
1062
|
+
}
|
|
1063
|
+
}
|
|
1064
|
+
return entries
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
function evidenceReport(unit, workspace) {
|
|
1068
|
+
const roots = [workspace, ...lifecycleResources(unit).map(r => r.path)].map(pathIdentity)
|
|
1069
|
+
const entries = evidenceEntries(unit)
|
|
1070
|
+
if (entries === null) return [{ path: '(invalid evidence declaration)', required: true, location: 'unresolved', preserved: false, exists: false }]
|
|
1071
|
+
return entries.map((entry) => {
|
|
1072
|
+
const ident = pathIdentity(entry.path)
|
|
1073
|
+
const location = !ident.resolved ? 'unresolved'
|
|
1074
|
+
: roots.some(root => root.resolved && containsPath(root.real, ident.real)) ? 'inside-removal-root' : 'outside'
|
|
1075
|
+
return { ...entry, location, preserved: location === 'outside' && ident.exists, exists: ident.exists }
|
|
1076
|
+
})
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
function targetBranchOf(plan, unit) {
|
|
1080
|
+
const candidates = [plan?.targetBranch, plan?.target]
|
|
1081
|
+
for (const value of candidates) {
|
|
1082
|
+
if (!nonEmpty(value) || isHexSha(value)) continue
|
|
1083
|
+
let name = String(value).trim()
|
|
1084
|
+
name = name.replace(/^refs\/remotes\/origin\//, '').replace(/^refs\/heads\//, '').replace(/^origin\//, '').replace(/^refs\/remotes\//, '')
|
|
1085
|
+
if (name && name !== 'HEAD') return name
|
|
1086
|
+
}
|
|
1087
|
+
return null
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
// Read the actual origin's current heads/tags. Neither local refs/remotes nor the
|
|
1091
|
+
// coordinator's branches are publication authority. Fetch objects only on apply;
|
|
1092
|
+
// do not overwrite refs or their reflogs while deciding what work must survive.
|
|
1093
|
+
function freshTarget({ workspace, target, apply, origin }) {
|
|
1094
|
+
const remote = git(workspace, ['ls-remote', '--heads', '--tags', origin], { timeout: 60000 })
|
|
1095
|
+
if (!remote.ok) return { ok: false, reason: `cannot read fresh origin refs: ${remote.err || remote.out}` }
|
|
1096
|
+
const refs = new Map()
|
|
1097
|
+
for (const line of remote.out.split('\n').filter(Boolean)) {
|
|
1098
|
+
const [sha, ref] = line.split(/\s+/)
|
|
1099
|
+
if (!isHexSha(sha) || !ref?.startsWith('refs/')) return { ok: false, reason: 'unreadable fresh origin refs' }
|
|
1100
|
+
refs.set(ref, sha)
|
|
1101
|
+
}
|
|
1102
|
+
const commit = refs.get(`refs/heads/${target}`)
|
|
1103
|
+
if (!commit) return { ok: false, reason: `remote has no branch ${target}` }
|
|
1104
|
+
const tips = []
|
|
1105
|
+
for (const sha of new Set(refs.values())) {
|
|
1106
|
+
if (!git(workspace, ['cat-file', '-e', sha]).ok && apply) {
|
|
1107
|
+
const fetched = git(workspace, ['fetch', '--quiet', '--no-tags', '--no-write-fetch-head', '--refmap=', origin, sha], { timeout: 120000 })
|
|
1108
|
+
if (!fetched.ok) return { ok: false, reason: `fresh origin object fetch failed: ${fetched.err || fetched.out}` }
|
|
1109
|
+
}
|
|
1110
|
+
const tip = resolveCommit(workspace, sha)
|
|
1111
|
+
if (tip) tips.push(tip)
|
|
1112
|
+
}
|
|
1113
|
+
if (!tips.includes(commit)) return { ok: false, reason: `fresh target ${commit} is unavailable locally; a dry run does not fetch` }
|
|
1114
|
+
return { ok: true, commit, tips: [...new Set(tips)], fresh: true, method: apply ? 'ls-remote-and-object-fetch' : 'ls-remote' }
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
function prNumberOf(unit) {
|
|
1118
|
+
const raw = unit?.pr
|
|
1119
|
+
if (Number.isSafeInteger(raw)) return raw
|
|
1120
|
+
if (isObject(raw)) return Number.isSafeInteger(raw.number) ? raw.number : null
|
|
1121
|
+
if (typeof raw === 'string') {
|
|
1122
|
+
const match = raw.match(/\/pull\/(\d+)(?:\b|$)/) ?? raw.match(/^#?(\d+)$/)
|
|
1123
|
+
if (match) return Number(match[1])
|
|
1124
|
+
}
|
|
1125
|
+
return null
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
function normalizeProof(raw) {
|
|
1129
|
+
if (!isObject(raw)) return null
|
|
1130
|
+
return {
|
|
1131
|
+
repo: raw.repo ?? raw.repository ?? raw.nameWithOwner ?? null,
|
|
1132
|
+
pr: raw.pr ?? raw.number ?? raw.pullRequest ?? null,
|
|
1133
|
+
headRepo: raw.headRepo ?? raw.headRepository?.nameWithOwner ?? raw.headRepository ?? raw.headRepositoryNameWithOwner ?? null,
|
|
1134
|
+
headBranch: raw.headBranch ?? raw.headRefName ?? raw.branch ?? null,
|
|
1135
|
+
headSha: raw.headSha ?? raw.headSHA ?? raw.headRefOid ?? raw.headOid ?? null,
|
|
1136
|
+
dest: raw.dest ?? raw.destination ?? raw.baseRefName ?? raw.base ?? raw.baseBranch ?? null,
|
|
1137
|
+
mergeSha: raw.mergeSha ?? raw.mergeCommit?.oid ?? raw.mergeCommit ?? raw.mergeCommitOid ?? raw.merge_commit_sha ?? null,
|
|
1138
|
+
state: raw.state ?? null,
|
|
1139
|
+
source: raw.__file ?? 'inline',
|
|
1140
|
+
}
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
function ghProof({ unit, repoSlug, runGh }) {
|
|
1144
|
+
const pr = prNumberOf(unit)
|
|
1145
|
+
if (!pr || !repoSlug || typeof runGh !== 'function') return null
|
|
1146
|
+
try {
|
|
1147
|
+
const out = runGh(['pr', 'view', String(pr), '--repo', repoSlug, '--json', 'number,state,headRefOid,headRefName,headRepository,headRepositoryOwner,baseRefName,mergeCommit'])
|
|
1148
|
+
const raw = JSON.parse(out)
|
|
1149
|
+
return normalizeProof({
|
|
1150
|
+
repo: repoSlug,
|
|
1151
|
+
pr: raw.number,
|
|
1152
|
+
headRepo: raw.headRepository?.nameWithOwner ?? (raw.headRepositoryOwner?.login && raw.headRepository?.name ? `${raw.headRepositoryOwner.login}/${raw.headRepository.name}` : null),
|
|
1153
|
+
headBranch: raw.headRefName,
|
|
1154
|
+
headSha: raw.headRefOid,
|
|
1155
|
+
dest: raw.baseRefName,
|
|
1156
|
+
mergeSha: raw.mergeCommit?.oid ?? null,
|
|
1157
|
+
state: raw.state,
|
|
1158
|
+
})
|
|
1159
|
+
} catch {
|
|
1160
|
+
return null
|
|
1161
|
+
}
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
function squashProof({ plan, unit, run, repo, workspace, target, fresh, headSha, apply, runGh }) {
|
|
1165
|
+
const repoSlug = slugFromUrl(remoteUrl(repo, 'origin'))
|
|
1166
|
+
// Saved JSON is evidence, not authority to destroy the PR head. An unavailable or
|
|
1167
|
+
// contradictory provider must retain the tree, even if an inline snapshot says merged.
|
|
1168
|
+
const proof = apply && repoSlug && prNumberOf(unit) && typeof runGh === 'function'
|
|
1169
|
+
? ghProof({ unit, repoSlug, runGh }) : null
|
|
1170
|
+
const snapshots = proof ? [proof] : []
|
|
1171
|
+
const reasons = proof ? [] : ['live authoritative PR proof is unavailable']
|
|
1172
|
+
for (const proof of snapshots) {
|
|
1173
|
+
if (proof.state !== 'MERGED') { reasons.push(`PR state is ${proof.state}`); continue }
|
|
1174
|
+
if (proof.repo !== repoSlug) { reasons.push(`repo ${proof.repo} is not ${repoSlug}`); continue }
|
|
1175
|
+
const pr = prNumberOf(unit)
|
|
1176
|
+
if (proof.pr !== pr) { reasons.push(`PR ${proof.pr} is not ${pr}`); continue }
|
|
1177
|
+
const expectedHeadRepo = unit.headRepo ?? unit.headRepository ?? (isObject(unit.pr) ? (unit.pr.headRepo ?? unit.pr.headRepository) : null) ?? repoSlug
|
|
1178
|
+
if (!/^[\w.-]+\/[\w.-]+$/.test(String(expectedHeadRepo)) || proof.headRepo !== expectedHeadRepo) {
|
|
1179
|
+
reasons.push(`headRepo ${proof.headRepo} is not the recorded ${expectedHeadRepo}`)
|
|
1180
|
+
continue
|
|
1181
|
+
}
|
|
1182
|
+
if (proof.headBranch !== unit.branch) { reasons.push(`headBranch ${proof.headBranch} is not ${unit.branch}`); continue }
|
|
1183
|
+
if (proof.headSha !== headSha) { reasons.push(`headSha ${proof.headSha} is not the recorded ${headSha}`); continue }
|
|
1184
|
+
const dest = String(proof.dest ?? '').replace(/^refs\/heads\//, '').replace(/^origin\//, '')
|
|
1185
|
+
if (dest !== target) { reasons.push(`destination ${proof.dest} is not ${target}`); continue }
|
|
1186
|
+
if (!isHexSha(proof.mergeSha)) { reasons.push('mergeSha is missing or not an exact hex SHA'); continue }
|
|
1187
|
+
if (!git(workspace, ['cat-file', '-e', `${proof.mergeSha}^{commit}`]).ok) { reasons.push(`mergeSha ${proof.mergeSha} is not in the local object store after the fresh target read`); continue }
|
|
1188
|
+
if (!fresh.ok) { reasons.push(fresh.reason); continue }
|
|
1189
|
+
if (!git(workspace, ['merge-base', '--is-ancestor', proof.mergeSha, fresh.commit]).ok) {
|
|
1190
|
+
reasons.push(`mergeSha ${proof.mergeSha} is not reachable from the fresh ${target} ${fresh.commit}`)
|
|
1191
|
+
continue
|
|
1192
|
+
}
|
|
1193
|
+
return { ok: true, proof: { ...proof, source: 'live-github', target: fresh.commit }, reasons }
|
|
1194
|
+
}
|
|
1195
|
+
return { ok: false, reasons }
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1198
|
+
// ---- cleanup planning ----
|
|
1199
|
+
|
|
1200
|
+
function deliveryMerged(unit) {
|
|
1201
|
+
const delivery = unit?.delivery
|
|
1202
|
+
if (delivery === 'merged') return true
|
|
1203
|
+
if (isObject(delivery)) return delivery.outcome === 'merged' || delivery.state === 'merged' || delivery.status === 'merged'
|
|
1204
|
+
return false
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1207
|
+
function unitCompleted(unit) {
|
|
1208
|
+
return unit?.status === 'succeeded' && deliveryMerged(unit)
|
|
1209
|
+
}
|
|
1210
|
+
|
|
1211
|
+
function checkWorkspace({ repo, plan, unit, run, apply, requireApplying, allowGh, runGh }) {
|
|
1212
|
+
const checks = []
|
|
1213
|
+
const add = (name, ok, detail, extra = {}) => { checks.push({ name, ok, detail, ...extra }); return ok }
|
|
1214
|
+
const workspace = normPath(unit?.workspace)
|
|
1215
|
+
const lifecycle = unit?.lifecycle
|
|
1216
|
+
const unitId = String(unit?.id ?? '')
|
|
1217
|
+
|
|
1218
|
+
if (!unitCompleted(unit)) {
|
|
1219
|
+
const delivery = isObject(unit?.delivery) ? (unit.delivery.outcome ?? unit.delivery.state ?? 'unknown') : (unit.delivery ?? 'unknown')
|
|
1220
|
+
return { verdict: 'retain', reason: `unit ${unitId} is not a completed unit (status ${unit.status ?? 'missing'}, delivery ${delivery}); its environment is kept`, checks, workspace }
|
|
1221
|
+
}
|
|
1222
|
+
add('completed', true, `status succeeded, delivery merged`)
|
|
1223
|
+
|
|
1224
|
+
const workers = hasRecordedWorker(plan, unit)
|
|
1225
|
+
if (workers.length) return { verdict: 'retain', reason: `workers are recorded for this unit or overlapping paths (${workers.join(', ')})`, checks, workspace }
|
|
1226
|
+
add('workers', true, 'no worker recorded for this owner or its paths')
|
|
1227
|
+
|
|
1228
|
+
if (!isObject(lifecycle)) return { verdict: 'retain', reason: `unit ${unitId} has no lifecycle record, so the plan does not say what it owns; run inventory/recover`, checks, workspace }
|
|
1229
|
+
add('lifecycle', true, `version ${lifecycle.version}`)
|
|
1230
|
+
if (lifecycle.version !== LIFECYCLE_VERSION) return { verdict: 'retain', reason: `unit ${unitId} lifecycle version ${lifecycle.version} is not ${LIFECYCLE_VERSION}`, checks, workspace }
|
|
1231
|
+
const cleanup = isObject(lifecycle.cleanup) ? lifecycle.cleanup : {}
|
|
1232
|
+
if (requireApplying && cleanup.state !== 'applying' && cleanup.state !== 'complete') {
|
|
1233
|
+
return { verdict: 'refuse', reason: `unit ${unitId} cleanup.state is ${cleanup.state ?? 'missing'}, not applying; the coordinator must bind cleanup.state=applying through update_plan before --apply`, checks, workspace }
|
|
1234
|
+
}
|
|
1235
|
+
add('cleanup-state', true, requireApplying ? String(cleanup.state) : String(cleanup.state ?? 'missing'))
|
|
1236
|
+
|
|
1237
|
+
const wsIdent = pathIdentity(workspace)
|
|
1238
|
+
if (!wsIdent.resolved) return { verdict: 'retain', reason: `workspace ${workspace} cannot be identified: ${wsIdent.reason}`, checks, workspace }
|
|
1239
|
+
if (wsIdent.isSymlink) return { verdict: 'retain', reason: `workspace ${workspace} is a symbolic link`, checks, workspace }
|
|
1240
|
+
if (!wsIdent.exists) {
|
|
1241
|
+
const completion = verifiedReceipt({ repo, run, unit })
|
|
1242
|
+
if (completion.ok && ['applying', 'complete'].includes(cleanup.state)) {
|
|
1243
|
+
return { verdict: 'already-complete', reason: 'the bound cleanup receipt and every removal postcondition agree', checks, workspace, receipt: completion.receipt }
|
|
1244
|
+
}
|
|
1245
|
+
return { verdict: 'retain', reason: `workspace is missing; recovery requires inspection: ${completion.reason}`, checks, workspace }
|
|
1246
|
+
}
|
|
1247
|
+
if (cleanup.state === 'complete') {
|
|
1248
|
+
return { verdict: 'retain', reason: `cleanup.state is complete, but workspace ${workspace} still exists; the plan and the disk disagree, so nothing is removed`, checks, workspace }
|
|
1249
|
+
}
|
|
1250
|
+
|
|
1251
|
+
if (wsIdent.real === pathIdentity(repo).real) return { verdict: 'refuse', reason: `unit ${unitId} workspace is the repository checkout`, checks, workspace }
|
|
1252
|
+
const runIdent = pathIdentity(run)
|
|
1253
|
+
if (!runIdent.resolved || !containsPath(runIdent.real, wsIdent.real)) {
|
|
1254
|
+
return { verdict: 'refuse', reason: `workspace ${workspace} is not inside the canonical run directory ${run}`, checks, workspace }
|
|
1255
|
+
}
|
|
1256
|
+
add('containment', true, workspace)
|
|
1257
|
+
|
|
1258
|
+
const recordedIdentity = lifecycle.workspace?.identity
|
|
1259
|
+
if (!isObject(recordedIdentity)) return { verdict: 'retain', reason: `unit ${unitId} records no workspace identity, so the tree cannot be shown to be the one that was prepared`, checks, workspace }
|
|
1260
|
+
const actualIdentity = workspaceIdentity(workspace)
|
|
1261
|
+
if (!sameIdentity(recordedIdentity, actualIdentity)) {
|
|
1262
|
+
return { verdict: 'retain', reason: `workspace ${workspace} does not match its recorded dev/ino/realPath identity; refusing a replacement tree`, checks, workspace }
|
|
1263
|
+
}
|
|
1264
|
+
add('identity', true, recordedIdentity.realPath)
|
|
1265
|
+
|
|
1266
|
+
const kind = workspaceKindOf(workspace)
|
|
1267
|
+
if (!kind) return { verdict: 'retain', reason: `workspace ${workspace} is not a readable Git worktree or clone`, checks, workspace }
|
|
1268
|
+
if (kind !== lifecycle.workspace.kind) return { verdict: 'retain', reason: `workspace ${workspace} is a ${kind}, not the recorded ${lifecycle.workspace.kind}`, checks, workspace }
|
|
1269
|
+
add('kind', true, kind)
|
|
1270
|
+
|
|
1271
|
+
const head = currentHead(workspace)
|
|
1272
|
+
const recordedHead = cleanup.headSha ?? unit.headSha
|
|
1273
|
+
if (!isHexSha(recordedHead)) return { verdict: 'retain', reason: `unit ${unitId} records no exact headSha for cleanup`, checks, workspace }
|
|
1274
|
+
if (!head || head !== recordedHead) return { verdict: 'retain', reason: `workspace HEAD ${head ?? 'unreadable'} is not the recorded headSha ${recordedHead}`, checks, workspace }
|
|
1275
|
+
if (unit.headSha !== undefined && unit.headSha !== head) return { verdict: 'retain', reason: `unit.headSha ${unit.headSha} is not the workspace HEAD ${head}`, checks, workspace }
|
|
1276
|
+
add('head', true, head)
|
|
1277
|
+
if (currentBranch(workspace) !== unit.branch) return { verdict: 'retain', reason: `workspace branch ${currentBranch(workspace) ?? 'unknown'} is not ${unit.branch}`, checks, workspace }
|
|
1278
|
+
add('branch', true, unit.branch)
|
|
1279
|
+
|
|
1280
|
+
if (kind === 'worktree') {
|
|
1281
|
+
if (!isRegisteredWorktree(repo, workspace)) return { verdict: 'retain', reason: `workspace ${workspace} is a linked worktree but is not registered in ${repo}; only git worktree remove may remove it`, checks, workspace }
|
|
1282
|
+
const common = gitCommonDir(workspace)
|
|
1283
|
+
const repoCommon = gitCommonDir(repo)
|
|
1284
|
+
if (!common || !repoCommon || common !== repoCommon) return { verdict: 'retain', reason: `workspace ${workspace} does not share ${repo}'s Git object store; it is not this repository's linked worktree`, checks, workspace }
|
|
1285
|
+
add('git-ownership', true, common)
|
|
1286
|
+
} else {
|
|
1287
|
+
const list = worktreeList(workspace)
|
|
1288
|
+
if (!list) return { verdict: 'retain', reason: `cannot read the clone's worktree registration`, checks, workspace }
|
|
1289
|
+
if (list.length > 1) return { verdict: 'retain', reason: `clone ${workspace} has ${list.length - 1} linked borrower(s); only independent clones may be removed`, checks, workspace }
|
|
1290
|
+
const common = gitCommonDir(workspace)
|
|
1291
|
+
if (!common || common === gitCommonDir(repo)) return { verdict: 'retain', reason: `clone ${workspace} shares this repository's object store`, checks, workspace }
|
|
1292
|
+
add('git-ownership', true, 'independent clone')
|
|
1293
|
+
}
|
|
1294
|
+
|
|
1295
|
+
const origin = remoteUrl(workspace, 'origin')
|
|
1296
|
+
const repoOrigin = remoteUrl(repo, 'origin')
|
|
1297
|
+
if (!origin) return { verdict: 'retain', reason: `workspace ${workspace} has no origin remote, so a fresh target cannot be read`, checks, workspace }
|
|
1298
|
+
if (!repoOrigin || !sameRepoUrl(origin, repoOrigin)) {
|
|
1299
|
+
return { verdict: 'retain', reason: `workspace origin ${origin} is not the repository origin ${repoOrigin ?? '(missing)'}`, checks, workspace }
|
|
1300
|
+
}
|
|
1301
|
+
add('origin', true, origin)
|
|
1302
|
+
|
|
1303
|
+
const lines = statusLines(workspace)
|
|
1304
|
+
if (lines === null) return { verdict: 'retain', reason: `git status cannot be read in ${workspace}`, checks, workspace }
|
|
1305
|
+
const ignored = lines.filter((l) => l.startsWith('!!')).map((l) => l.slice(3).trim())
|
|
1306
|
+
const declared = lifecycleResources(unit).filter((r) => isObject(r) && nonEmpty(r.path)).map((r) => pathIdentity(r.path)).filter((i) => i.resolved).map((i) => i.real)
|
|
1307
|
+
const underDeclared = (rel) => {
|
|
1308
|
+
const ident = pathIdentity(join(workspace, rel.replace(/^"|"$/g, '')))
|
|
1309
|
+
return ident.resolved && declared.some((prefix) => containsPath(prefix, ident.real))
|
|
1310
|
+
}
|
|
1311
|
+
const statusPath = (line) => line.slice(3).split(' -> ').at(-1).trim().replace(/^"|"$/g, '')
|
|
1312
|
+
const work = lines.filter((l) => !l.startsWith('!!')).filter((l) => !underDeclared(statusPath(l)))
|
|
1313
|
+
if (work.length) return { verdict: 'retain', reason: `workspace ${workspace} holds ${work.length} uncommitted or untracked path(s) outside declared disposable resources; a clean tree is required`, checks, workspace }
|
|
1314
|
+
const unknownIgnored = ignored.filter((rel) => {
|
|
1315
|
+
const abs = join(workspace, rel)
|
|
1316
|
+
const ident = pathIdentity(abs)
|
|
1317
|
+
if (!ident.resolved) return true
|
|
1318
|
+
return !declared.some((prefix) => containsPath(prefix, ident.real))
|
|
1319
|
+
})
|
|
1320
|
+
if (unknownIgnored.length) {
|
|
1321
|
+
return { verdict: 'retain', reason: `workspace ${workspace} holds ${unknownIgnored.length} git-ignored path(s) outside declared disposable resources (${unknownIgnored.slice(0, 3).join(', ')}${unknownIgnored.length > 3 ? ', ...' : ''}); unknown ignored files are never stripped`, checks, workspace }
|
|
1322
|
+
}
|
|
1323
|
+
add('clean', true, 'no uncommitted, untracked, or undeclared ignored paths')
|
|
1324
|
+
|
|
1325
|
+
const interrupted = interruptedGitState(workspace)
|
|
1326
|
+
if (interrupted.length) return { verdict: 'retain', reason: `workspace ${workspace} has an interrupted Git operation (${interrupted.join(', ')})`, checks, workspace }
|
|
1327
|
+
add('git-operation', true, 'none in progress')
|
|
1328
|
+
const stashes = git(workspace, ['stash', 'list'])
|
|
1329
|
+
if (!stashes.ok || stashes.out.split('\n').filter(Boolean).length) {
|
|
1330
|
+
return { verdict: 'retain', reason: `workspace ${workspace} has ${stashes.ok ? stashes.out.split('\n').filter(Boolean).length : 'unreadable'} stash(es)`, checks, workspace }
|
|
1331
|
+
}
|
|
1332
|
+
add('stash', true, 'none')
|
|
1333
|
+
|
|
1334
|
+
const branchTip = git(workspace, ['rev-parse', '--verify', `refs/heads/${unit.branch}`])
|
|
1335
|
+
if (!branchTip.ok || branchTip.out !== head) {
|
|
1336
|
+
return { verdict: 'retain', reason: `branch ${unit.branch} is not at the recorded head ${head}; the workspace has local commits beyond the prepared head`, checks, workspace }
|
|
1337
|
+
}
|
|
1338
|
+
add('branch-tip', true, head)
|
|
1339
|
+
|
|
1340
|
+
// Refs and reflogs are checked below against the fresh origin ref set, after
|
|
1341
|
+
// delivery proof identifies the only head eligible for a squash exemption.
|
|
1342
|
+
|
|
1343
|
+
for (const resource of lifecycleResources(unit)) {
|
|
1344
|
+
if (!existsSync(resource.path)) return { verdict: 'retain', reason: `declared resource ${resource.path} is missing; inspect partial cleanup`, checks, workspace }
|
|
1345
|
+
const actual = resourceIdentity(resource.path)
|
|
1346
|
+
if (!sameIdentity(resource.identity, actual)) return { verdict: 'retain', reason: `declared resource ${resource.path} does not match its recorded identity`, checks, workspace }
|
|
1347
|
+
const actualKind = workspaceKindOf(resource.path) ?? 'directory'
|
|
1348
|
+
if (actualKind !== resource.kind) return { verdict: 'retain', reason: `declared resource ${resource.path} is ${actualKind}, not recorded ${resource.kind}`, checks, workspace }
|
|
1349
|
+
const failure = removalTreeSafety({ repo, path: resource.path, kind: actualKind, identity: resource.identity, declared })
|
|
1350
|
+
if (failure) return { verdict: 'retain', reason: failure, checks, workspace }
|
|
1351
|
+
}
|
|
1352
|
+
const workspaceFailure = removalTreeSafety({ repo, path: workspace, kind, identity: recordedIdentity, declared })
|
|
1353
|
+
if (workspaceFailure) return { verdict: 'retain', reason: workspaceFailure, checks, workspace }
|
|
1354
|
+
add('resources', true, 'every removal path passed Git, identity, nested-repository and lock checks')
|
|
1355
|
+
|
|
1356
|
+
const nested = nestedRepositories(workspace, declared)
|
|
1357
|
+
if (nested.truncated) return { verdict: 'retain', reason: `nested-repository scan of ${workspace} hit its bound before finishing; cannot prove there is no nested repository`, checks, workspace }
|
|
1358
|
+
if (nested.found.length) return { verdict: 'retain', reason: `workspace ${workspace} contains nested Git repository ${nested.found.slice(0, 3).join(', ')}, which this cleanup does not own`, checks, workspace }
|
|
1359
|
+
add('nested-repos', true, 'none')
|
|
1360
|
+
|
|
1361
|
+
const evidence = evidenceReport(unit, workspace)
|
|
1362
|
+
if (symlinkBelow(run, join(run, 'results'))) return { verdict: 'retain', reason: 'receipt directory passes through a symbolic link', checks, workspace, evidence }
|
|
1363
|
+
const receiptPath = join(run, 'results', `${safeName(unit.id)}-cleanup.json`)
|
|
1364
|
+
if (existsSync(receiptPath) && !lstatSync(receiptPath).isFile()) return { verdict: 'retain', reason: 'cleanup receipt destination is not a regular file', checks, workspace, evidence }
|
|
1365
|
+
const blockingEvidence = evidence.filter((e) => e.required && !(e.location === 'outside' && e.exists))
|
|
1366
|
+
if (blockingEvidence.length) {
|
|
1367
|
+
return { verdict: 'retain', reason: `required evidence is not preserved outside the workspace (${blockingEvidence.slice(0, 3).map((e) => e.path).join(', ')}); no transcripts are copied automatically`, checks, workspace, evidence }
|
|
1368
|
+
}
|
|
1369
|
+
add('evidence', true, evidence.length ? `${evidence.length} required path(s) outside the workspace` : 'none recorded')
|
|
1370
|
+
|
|
1371
|
+
// The refusal detail is collected per proof, not in module state: a second cleanup must
|
|
1372
|
+
// not report the first one's read.
|
|
1373
|
+
const uncertain = []
|
|
1374
|
+
const live = liveProcessesUsing([workspace, ...declared], { onUncertain: (detail) => uncertain.push(detail) })
|
|
1375
|
+
if (live === 'unreadable') return { verdict: 'retain', reason: `could not determine whether a process is using ${workspace}${uncertaintyDetail(uncertain)}; uncertainty never authorizes cleanup`, checks, workspace }
|
|
1376
|
+
if (live === 'unavailable') return { verdict: 'retain', reason: `no portable process-use check is available here, so ${workspace} cannot be shown to be idle`, checks, workspace }
|
|
1377
|
+
if (Array.isArray(live) && live.length) return { verdict: 'retain', reason: `${live.length} process(es) are using ${workspace} (${live.slice(0, 3).join(', ')}); a clean tree can still have live work in it`, checks, workspace }
|
|
1378
|
+
add('live-use', true, 'no use found by the bounded OS cwd/open-file scan')
|
|
1379
|
+
|
|
1380
|
+
const target = targetBranchOf(plan, unit)
|
|
1381
|
+
if (!target) return { verdict: 'retain', reason: `unit ${unitId} records no target branch, so merge into the target cannot be proved`, checks, workspace }
|
|
1382
|
+
const fresh = freshTarget({ workspace, target, apply, origin: repoOrigin })
|
|
1383
|
+
if (!fresh.ok) return { verdict: 'retain', reason: fresh.reason, checks, workspace }
|
|
1384
|
+
add('fresh-target', true, `${target} ${fresh.commit}`)
|
|
1385
|
+
|
|
1386
|
+
const direct = git(workspace, ['merge-base', '--is-ancestor', head, fresh.commit]).ok
|
|
1387
|
+
const proof = direct ? null : squashProof({ plan, unit, run, repo, workspace, target, fresh, headSha: head, apply, runGh: allowGh ? runGh : null })
|
|
1388
|
+
if (!direct && !proof.ok) return {
|
|
1389
|
+
verdict: 'retain', reason: `cannot prove ${head} merged into fresh origin/${target}: ${proof.reasons.join('; ')}`,
|
|
1390
|
+
checks, workspace, target, targetCommit: fresh.commit, evidence,
|
|
1391
|
+
}
|
|
1392
|
+
for (const tree of [{ path: workspace, kind }, ...lifecycleResources(unit)]) {
|
|
1393
|
+
if (tree.kind === 'directory') continue
|
|
1394
|
+
const published = tree.path === workspace ? fresh : freshTarget({ workspace: tree.path, target, apply, origin: repoOrigin })
|
|
1395
|
+
if (!published.ok) return { verdict: 'retain', reason: published.reason, checks, workspace, evidence }
|
|
1396
|
+
const failure = unpublishedWork(tree.path, tree.kind, published.tips, head)
|
|
1397
|
+
if (failure) return { verdict: 'retain', reason: failure, checks, workspace, evidence }
|
|
1398
|
+
}
|
|
1399
|
+
add('local-refs', true, 'all removable refs and reflogs are published or reachable from the verified delivered head')
|
|
1400
|
+
add('merged', true, direct ? `${head} is on fresh origin/${target}` : `live PR proof for ${proof.proof.mergeSha}`)
|
|
1401
|
+
return {
|
|
1402
|
+
verdict: 'remove', reason: direct ? `clean and merged: ${head} is on fresh origin/${target}` : `clean and squash-merged: ${head} has live PR proof`,
|
|
1403
|
+
checks, workspace, target, targetCommit: fresh.commit, evidence,
|
|
1404
|
+
...(proof?.ok ? { mergeProof: proof.proof } : {}),
|
|
1405
|
+
}
|
|
1406
|
+
}
|
|
1407
|
+
|
|
1408
|
+
// The liveness refusal names what it could not read, so an operator sees the path, the
|
|
1409
|
+
// operation and the errno instead of only "unreadable". `details` is the caller's own list.
|
|
1410
|
+
function uncertaintyDetail(details) {
|
|
1411
|
+
const detail = details[0]
|
|
1412
|
+
if (!detail) return ''
|
|
1413
|
+
const where = [detail.path, detail.operation].filter(Boolean).join(' ')
|
|
1414
|
+
const limit = detail.limit ? ` (limit ${detail.limit})` : ''
|
|
1415
|
+
const probe = detail.probe
|
|
1416
|
+
? `; ${detail.probe.operation} ${detail.probe.errno ? `failed with ${detail.probe.errno}` : 'showed the pid still alive'}`
|
|
1417
|
+
: ''
|
|
1418
|
+
return ` (${where || 'a /proc read'} failed with ${detail.errno ?? 'an unknown error'}${limit}${probe})`
|
|
1419
|
+
}
|
|
1420
|
+
|
|
1421
|
+
function removalTreeSafety({ repo, path, kind, identity, declared }) {
|
|
1422
|
+
if (!lstatSync(path).isDirectory()) return `${path} is not a real directory`
|
|
1423
|
+
const children = declared.filter(p => p !== pathIdentity(path).real && containsPath(pathIdentity(path).real, p))
|
|
1424
|
+
const nested = nestedRepositories(path, children)
|
|
1425
|
+
if (nested.truncated || nested.found.length) return `${path}: nested repository scan ${nested.truncated ? 'is incomplete' : `found ${nested.found.join(', ')}`}`
|
|
1426
|
+
if (kind === 'directory') {
|
|
1427
|
+
if (existsSync(join(path, '.git')) || (existsSync(join(path, 'HEAD')) && existsSync(join(path, 'objects')) && existsSync(join(path, 'refs')))) return `${path}: directory contains Git metadata`
|
|
1428
|
+
return null
|
|
1429
|
+
}
|
|
1430
|
+
const common = gitCommonDir(path)
|
|
1431
|
+
if (!common || (identity?.gitCommonDir && common !== identity.gitCommonDir)) return `${path}: Git identity changed or is unreadable`
|
|
1432
|
+
if (kind === 'worktree') {
|
|
1433
|
+
if (common !== gitCommonDir(repo) || !isRegisteredWorktree(repo, path)) return `${path}: worktree is not registered in its recorded repository`
|
|
1434
|
+
} else {
|
|
1435
|
+
const list = worktreeList(path)
|
|
1436
|
+
if (!list || list.length !== 1 || !containsPath(pathIdentity(path).real, pathIdentity(common).real)) return `${path}: clone has linked borrowers or an external Git object store`
|
|
1437
|
+
if (existsSync(join(common, 'objects', 'info', 'alternates'))) return `${path}: clone borrows an external Git object store`
|
|
1438
|
+
}
|
|
1439
|
+
const interrupted = interruptedGitState(path)
|
|
1440
|
+
if (interrupted.length) return `${path}: locked or interrupted Git operation (${interrupted.join(', ')})`
|
|
1441
|
+
const lines = statusLines(path)
|
|
1442
|
+
if (!lines) return `${path}: cannot read Git status`
|
|
1443
|
+
// Only untracked/ignored paths may be covered by a declared disposable child.
|
|
1444
|
+
// Tracked changes are always work, including a changed submodule entry.
|
|
1445
|
+
const work = lines.filter(line => {
|
|
1446
|
+
if (!line.startsWith('?? ') && !line.startsWith('!! ')) return true
|
|
1447
|
+
const rel = line.slice(3).replace(/^"|"$/g, '')
|
|
1448
|
+
const ident = pathIdentity(join(path, rel))
|
|
1449
|
+
return !ident.resolved || !children.some(child => containsPath(child, ident.real))
|
|
1450
|
+
})
|
|
1451
|
+
if (work.length) return `${path}: uncommitted, untracked or ignored work must be preserved`
|
|
1452
|
+
const stash = git(path, ['stash', 'list'])
|
|
1453
|
+
if (!stash.ok || stash.out) return `${path}: stash is present or unreadable`
|
|
1454
|
+
const origin = remoteUrl(path)
|
|
1455
|
+
if (!sameRepoUrl(origin, remoteUrl(repo))) return `${path}: origin is not the recorded repository origin`
|
|
1456
|
+
return null
|
|
1457
|
+
}
|
|
1458
|
+
|
|
1459
|
+
function unpublishedWork(path, kind, published, deliveredHead) {
|
|
1460
|
+
const exclusions = [...published]
|
|
1461
|
+
if (resolveCommit(path, deliveredHead)) exclusions.push(deliveredHead)
|
|
1462
|
+
const refs = git(path, ['for-each-ref', '--format=%(refname)'])
|
|
1463
|
+
if (!refs.ok) return `${path}: cannot read local refs`
|
|
1464
|
+
const names = refs.out.split('\n').filter(Boolean)
|
|
1465
|
+
const candidates = [currentHead(path)].filter(Boolean)
|
|
1466
|
+
if (kind === 'clone') {
|
|
1467
|
+
for (const ref of names) {
|
|
1468
|
+
// Replacement refs can change the meaning of every ancestry query. Do not
|
|
1469
|
+
// discard them based on commit reachability, which does not preserve that mapping.
|
|
1470
|
+
if (ref.startsWith('refs/replace/')) return `${path}: local replace ref ${ref} must be preserved`
|
|
1471
|
+
const commit = resolveCommit(path, ref)
|
|
1472
|
+
if (!commit) return `${path}: local ref ${ref} is not a verifiable commit`
|
|
1473
|
+
candidates.push(commit)
|
|
1474
|
+
}
|
|
1475
|
+
} else {
|
|
1476
|
+
// Common refs survive git worktree remove. They can preserve this worktree's
|
|
1477
|
+
// former HEADs without making another unit's private branch a global blocker.
|
|
1478
|
+
for (const ref of names) {
|
|
1479
|
+
const commit = resolveCommit(path, ref)
|
|
1480
|
+
if (commit) exclusions.push(commit)
|
|
1481
|
+
}
|
|
1482
|
+
// Worktree-private refs do NOT survive removal.
|
|
1483
|
+
const privateRefs = names.filter(ref => /^(refs\/(worktree|bisect|rewritten)\/)/.test(ref))
|
|
1484
|
+
if (privateRefs.length) return `${path}: private worktree refs must be preserved`
|
|
1485
|
+
}
|
|
1486
|
+
const outside = git(path, ['rev-list', '--count', ...candidates, '--not', ...exclusions])
|
|
1487
|
+
if (!outside.ok || outside.out !== '0') return `${path}: unpublished local ref or HEAD commit(s) must be preserved`
|
|
1488
|
+
const reflogs = git(path, ['reflog', 'show', ...(kind === 'clone' ? ['--all'] : ['HEAD']), '--format=%H'])
|
|
1489
|
+
if (!reflogs.ok) return `${path}: cannot read reflog`
|
|
1490
|
+
const tips = [...new Set(reflogs.out.split('\n').filter(isHexSha))]
|
|
1491
|
+
if (tips.length) {
|
|
1492
|
+
const result = git(path, ['rev-list', '--count', ...tips, '--not', ...exclusions])
|
|
1493
|
+
if (!result.ok || result.out !== '0') return `${path}: unpublished reflog commit(s) must be preserved`
|
|
1494
|
+
}
|
|
1495
|
+
return null
|
|
1496
|
+
}
|
|
1497
|
+
|
|
1498
|
+
// ---- removal ----
|
|
1499
|
+
|
|
1500
|
+
function removePath(path) {
|
|
1501
|
+
try {
|
|
1502
|
+
rmSync(path, { recursive: true, force: true })
|
|
1503
|
+
return null
|
|
1504
|
+
} catch (error) {
|
|
1505
|
+
try {
|
|
1506
|
+
execFileSync('chmod', ['-R', 'u+w', path], { stdio: 'ignore' })
|
|
1507
|
+
rmSync(path, { recursive: true, force: true })
|
|
1508
|
+
return null
|
|
1509
|
+
} catch {
|
|
1510
|
+
return `${path}: ${error?.code ?? error?.message ?? 'removal failed'}`
|
|
1511
|
+
}
|
|
1512
|
+
}
|
|
1513
|
+
}
|
|
1514
|
+
|
|
1515
|
+
function writeReceipt(run, unitId, payload) {
|
|
1516
|
+
const dir = join(run, 'results')
|
|
1517
|
+
if (symlinkBelow(run, dir)) throw new LifecycleError('symlink', 'receipt directory passes through a symbolic link')
|
|
1518
|
+
mkdirSync(dir, { recursive: true })
|
|
1519
|
+
const path = join(dir, `${safeName(unitId)}-cleanup.json`)
|
|
1520
|
+
const temporary = `${path}.${process.pid}.tmp`
|
|
1521
|
+
try {
|
|
1522
|
+
writeFileSync(temporary, `${JSON.stringify(payload, null, 2)}\n`, { flag: 'wx' })
|
|
1523
|
+
renameSync(temporary, path)
|
|
1524
|
+
} finally {
|
|
1525
|
+
try { unlinkSync(temporary) } catch { /* rename consumed it */ }
|
|
1526
|
+
}
|
|
1527
|
+
return path
|
|
1528
|
+
}
|
|
1529
|
+
|
|
1530
|
+
function removalRecords(unit) {
|
|
1531
|
+
return [{ path: unit.workspace, kind: unit.lifecycle.workspace.kind, identity: unit.lifecycle.workspace.identity }, ...lifecycleResources(unit)]
|
|
1532
|
+
}
|
|
1533
|
+
|
|
1534
|
+
function verifiedReceipt({ repo, run, unit }) {
|
|
1535
|
+
const fail = reason => ({ ok: false, reason })
|
|
1536
|
+
const receipt = join(run, 'results', `${safeName(unit.id)}-cleanup.json`)
|
|
1537
|
+
let saved
|
|
1538
|
+
try {
|
|
1539
|
+
if (symlinkBelow(run, receipt)) return fail('receipt path passes through a symbolic link')
|
|
1540
|
+
saved = JSON.parse(readFileSync(receipt, 'utf8'))
|
|
1541
|
+
} catch { return fail('no readable cleanup receipt') }
|
|
1542
|
+
const ws = unit.lifecycle?.workspace
|
|
1543
|
+
if (saved.version !== RECEIPT_VERSION || saved.kind !== 'workspace-cleanup-postcondition'
|
|
1544
|
+
|| saved.unit !== String(unit.id) || saved.run !== normPath(run) || saved.repo !== normPath(repo)
|
|
1545
|
+
|| !isHexSha(saved.headSha) || saved.headSha !== unit.lifecycle?.cleanup?.headSha
|
|
1546
|
+
|| (unit.headSha !== undefined && saved.headSha !== unit.headSha)
|
|
1547
|
+
|| saved.workspace?.path !== normPath(unit.workspace) || saved.workspace?.kind !== ws?.kind
|
|
1548
|
+
|| saved.workspace?.removed !== true || !sameIdentity(ws?.identity, saved.workspace?.identity)
|
|
1549
|
+
|| JSON.stringify(ws?.identity) !== JSON.stringify(saved.workspace?.identity)) return fail('receipt does not bind this run, unit, head and workspace identity')
|
|
1550
|
+
const records = removalRecords(unit)
|
|
1551
|
+
if (!Array.isArray(saved.resources) || saved.resources.length !== records.length - 1
|
|
1552
|
+
|| !Array.isArray(saved.removedPaths) || saved.removedPaths.length !== records.length
|
|
1553
|
+
|| new Set(saved.removedPaths).size !== records.length) return fail('receipt does not cover every removal path')
|
|
1554
|
+
const list = worktreeList(repo)
|
|
1555
|
+
if (!list) return fail('worktree registrations cannot be read')
|
|
1556
|
+
for (const record of records) {
|
|
1557
|
+
if (!saved.removedPaths.includes(normPath(record.path))) return fail(`receipt omits ${record.path}`)
|
|
1558
|
+
if (record !== records[0]) {
|
|
1559
|
+
const item = saved.resources.find(r => r.path === record.path)
|
|
1560
|
+
if (!item || item.removed !== true || item.kind !== record.kind || JSON.stringify(item.identity) !== JSON.stringify(record.identity)) return fail(`receipt does not bind resource ${record.path}`)
|
|
1561
|
+
}
|
|
1562
|
+
const ident = pathIdentity(record.path)
|
|
1563
|
+
if (!ident.resolved || ident.exists || symlinkBelow(run, record.path)) return fail(`removal path still exists or cannot be identified: ${record.path}`)
|
|
1564
|
+
if (list.some(p => pathIdentity(p).real === ident.real)) return fail(`worktree remains registered: ${record.path}`)
|
|
1565
|
+
}
|
|
1566
|
+
if (evidenceReport(unit, unit.workspace).some(e => e.required && !e.preserved)) return fail('required evidence is not preserved')
|
|
1567
|
+
return { ok: true, receipt }
|
|
1568
|
+
}
|
|
1569
|
+
|
|
1570
|
+
function removeOwnedTree({ repo, path, kind }) {
|
|
1571
|
+
if (kind === 'worktree') {
|
|
1572
|
+
if (!isRegisteredWorktree(repo, path)) return `${path}: not a registered worktree; refusing a raw removal`
|
|
1573
|
+
const result = git(repo, ['worktree', 'remove', path], { timeout: 120000 })
|
|
1574
|
+
if (!result.ok) return `${path}: git worktree remove failed: ${result.err || result.out}`
|
|
1575
|
+
if (existsSync(path)) return `${path}: still exists after git worktree remove`
|
|
1576
|
+
return null
|
|
1577
|
+
}
|
|
1578
|
+
return removePath(path)
|
|
1579
|
+
}
|
|
1580
|
+
|
|
1581
|
+
function applyRemoval({ repo, run, plan, unit, planResult }) {
|
|
1582
|
+
const unitId = String(unit.id)
|
|
1583
|
+
const workspace = normPath(unit.workspace)
|
|
1584
|
+
const lifecycle = unit.lifecycle
|
|
1585
|
+
const failures = []
|
|
1586
|
+
const removed = []
|
|
1587
|
+
const resources = lifecycleResources(unit)
|
|
1588
|
+
// Recheck the plan after remote/provider calls and before the first deletion.
|
|
1589
|
+
const latest = readPlan(run)
|
|
1590
|
+
requireRevision(latest.plan, plan.planRevision)
|
|
1591
|
+
if (JSON.stringify(latest.plan) !== JSON.stringify(plan)) throw new LifecycleError('revision-mismatch', 'plan changed during cleanup preflight')
|
|
1592
|
+
const records = removalRecords(unit).sort((a, b) => b.path.split(sep).length - a.path.split(sep).length)
|
|
1593
|
+
const declared = resources.map(r => pathIdentity(r.path).real)
|
|
1594
|
+
for (const record of records) {
|
|
1595
|
+
if (symlinkBelow(run, record.path) || !sameIdentity(record.identity, resourceIdentity(record.path))
|
|
1596
|
+
|| (workspaceKindOf(record.path) ?? 'directory') !== record.kind) return { failures: [`${record.path}: identity or kind changed before deletion`], removed }
|
|
1597
|
+
const failure = removalTreeSafety({ repo, ...record, declared })
|
|
1598
|
+
if (failure) return { failures: [failure], removed }
|
|
1599
|
+
}
|
|
1600
|
+
if (currentHead(workspace) !== planResult.cleanup.headSha) return { failures: ['workspace HEAD changed during preflight'], removed }
|
|
1601
|
+
// Children precede their containers. Git worktrees always go through Git, never rm.
|
|
1602
|
+
for (const record of records) {
|
|
1603
|
+
try {
|
|
1604
|
+
if (symlinkBelow(run, record.path) || !sameIdentity(record.identity, resourceIdentity(record.path))) {
|
|
1605
|
+
failures.push(`${record.path}: identity changed during removal`)
|
|
1606
|
+
break
|
|
1607
|
+
}
|
|
1608
|
+
const failure = removeOwnedTree({ repo, path: record.path, kind: record.kind })
|
|
1609
|
+
if (failure) { failures.push(failure); break }
|
|
1610
|
+
removed.push(normPath(record.path))
|
|
1611
|
+
} catch (error) {
|
|
1612
|
+
failures.push(`${record.path}: ${error.message}`)
|
|
1613
|
+
break
|
|
1614
|
+
}
|
|
1615
|
+
}
|
|
1616
|
+
const registrations = worktreeList(repo)
|
|
1617
|
+
if (!registrations) failures.push('cannot verify final worktree registrations')
|
|
1618
|
+
for (const record of records) {
|
|
1619
|
+
const postcondition = pathIdentity(record.path)
|
|
1620
|
+
if (!postcondition.resolved || postcondition.exists || symlinkBelow(run, record.path)) failures.push(`${record.path}: removal postcondition is not established`)
|
|
1621
|
+
if (registrations?.some(p => pathIdentity(p).real === pathIdentity(record.path).real)) failures.push(`${record.path}: still registered after removal`)
|
|
1622
|
+
}
|
|
1623
|
+
if (failures.length) return { failures, removed }
|
|
1624
|
+
|
|
1625
|
+
const receiptPayload = {
|
|
1626
|
+
version: RECEIPT_VERSION,
|
|
1627
|
+
kind: 'workspace-cleanup-postcondition',
|
|
1628
|
+
unit: unitId,
|
|
1629
|
+
run: normPath(run),
|
|
1630
|
+
repo: normPath(repo),
|
|
1631
|
+
at: new Date().toISOString(),
|
|
1632
|
+
planRevision: plan.planRevision,
|
|
1633
|
+
headSha: planResult.cleanup.headSha,
|
|
1634
|
+
workspace: {
|
|
1635
|
+
path: workspace,
|
|
1636
|
+
kind: lifecycle.workspace.kind,
|
|
1637
|
+
identity: lifecycle.workspace.identity,
|
|
1638
|
+
removed: true,
|
|
1639
|
+
},
|
|
1640
|
+
resources: resources.map((r) => ({ path: r.path, role: r.role, kind: r.kind, identity: r.identity, removed: !existsSync(r.path) })),
|
|
1641
|
+
removedPaths: removed,
|
|
1642
|
+
target: planResult.target ?? null,
|
|
1643
|
+
targetCommit: planResult.targetCommit ?? null,
|
|
1644
|
+
mergeProof: planResult.mergeProof ?? null,
|
|
1645
|
+
evidence: planResult.evidence ?? [],
|
|
1646
|
+
}
|
|
1647
|
+
let receipt = null
|
|
1648
|
+
try { receipt = writeReceipt(run, unitId, receiptPayload) } catch (error) { failures.push(`could not write cleanup receipt: ${error.message}`) }
|
|
1649
|
+
return { failures, removed, receipt }
|
|
1650
|
+
}
|
|
1651
|
+
|
|
1652
|
+
export function cleanup({
|
|
1653
|
+
run, unit: unitId, revision, apply = false, requireApplying = true, allowGh = true, runGh = null,
|
|
1654
|
+
}) {
|
|
1655
|
+
run = resolve(run)
|
|
1656
|
+
const first = readPlan(run)
|
|
1657
|
+
const repo = repositoryOf(first.plan, run)
|
|
1658
|
+
requireRevision(first.plan, revision)
|
|
1659
|
+
const unit = unitOf(first.plan, unitId)
|
|
1660
|
+
assertValid(validateUnit({ repo, run, plan: first.plan, unit }))
|
|
1661
|
+
const runDir = canonicalRunDir(repo, run)
|
|
1662
|
+
|
|
1663
|
+
if (!apply) {
|
|
1664
|
+
// A dry run previews the same checks, but it does not demand the coordinator's applying
|
|
1665
|
+
// binding yet: that binding is what --apply checks, and the plan must be able to see what
|
|
1666
|
+
// it would remove before making the transition.
|
|
1667
|
+
const check = checkWorkspace({ repo, plan: first.plan, unit, run: runDir, apply: false, requireApplying: false, allowGh, runGh })
|
|
1668
|
+
const lifecycle = isObject(unit.lifecycle) ? unit.lifecycle : null
|
|
1669
|
+
const needsBinding = requireApplying && lifecycle?.cleanup?.state !== 'applying' && lifecycle?.cleanup?.state !== 'complete'
|
|
1670
|
+
const wouldRemove = check.verdict === 'remove'
|
|
1671
|
+
return {
|
|
1672
|
+
version: 1,
|
|
1673
|
+
command: 'cleanup',
|
|
1674
|
+
ok: check.verdict === 'remove' || check.verdict === 'already-complete',
|
|
1675
|
+
applied: false,
|
|
1676
|
+
action: check.verdict,
|
|
1677
|
+
wouldRemove,
|
|
1678
|
+
authorizationRequired: needsBinding && check.verdict === 'remove' ? 'applying' : null,
|
|
1679
|
+
repo,
|
|
1680
|
+
run: runDir,
|
|
1681
|
+
unit: String(unitId),
|
|
1682
|
+
revision: planRevisionOf(first.plan),
|
|
1683
|
+
workspace: { path: normPath(unit.workspace), exists: existsSync(unit.workspace), kind: lifecycle?.workspace?.kind ?? null },
|
|
1684
|
+
lifecycle: null,
|
|
1685
|
+
nextLifecycle: lifecycle,
|
|
1686
|
+
requiredTransition: needsBinding && check.verdict === 'remove'
|
|
1687
|
+
? { cleanup: { state: 'applying', headSha: lifecycle?.cleanup?.headSha ?? unit.headSha ?? check.checks.find((c) => c.name === 'head')?.detail } }
|
|
1688
|
+
: null,
|
|
1689
|
+
checks: check.checks,
|
|
1690
|
+
paths: check.verdict === 'remove' ? [unit.workspace, ...lifecycleResources(unit).map((r) => r.path)] : [],
|
|
1691
|
+
reason: needsBinding && check.verdict === 'remove'
|
|
1692
|
+
? `${check.reason}; bind cleanup.state=applying through update_plan before --apply`
|
|
1693
|
+
: check.reason,
|
|
1694
|
+
receipt: check.receipt ?? null,
|
|
1695
|
+
evidence: check.evidence ?? [],
|
|
1696
|
+
errors: [],
|
|
1697
|
+
}
|
|
1698
|
+
}
|
|
1699
|
+
|
|
1700
|
+
const lock = acquireLock(runDir, unitId)
|
|
1701
|
+
try {
|
|
1702
|
+
// Revision and identity are rechecked under the lock, immediately before mutation.
|
|
1703
|
+
const current = readPlan(run)
|
|
1704
|
+
requireRevision(current.plan, revision)
|
|
1705
|
+
const freshUnit = unitOf(current.plan, unitId)
|
|
1706
|
+
assertValid(validateUnit({ repo, run, plan: current.plan, unit: freshUnit }))
|
|
1707
|
+
const check = checkWorkspace({ repo, plan: current.plan, unit: freshUnit, run: runDir, apply: true, requireApplying, allowGh, runGh })
|
|
1708
|
+
|
|
1709
|
+
if (check.verdict === 'remove') {
|
|
1710
|
+
const removal = applyRemoval({
|
|
1711
|
+
repo, run: runDir, plan: current.plan, unit: freshUnit,
|
|
1712
|
+
planResult: { cleanup: { headSha: check.checks.find((c) => c.name === 'head')?.detail ?? freshUnit.lifecycle?.cleanup?.headSha }, target: check.target, targetCommit: check.targetCommit, mergeProof: check.mergeProof, evidence: check.evidence },
|
|
1713
|
+
})
|
|
1714
|
+
if (removal.failures.length) {
|
|
1715
|
+
const lifecycle = {
|
|
1716
|
+
...freshUnit.lifecycle,
|
|
1717
|
+
cleanup: { ...(freshUnit.lifecycle.cleanup ?? {}), state: 'applying', headSha: freshUnit.lifecycle.cleanup?.headSha ?? currentHead(freshUnit.workspace), reason: removal.failures.join('; '), removedPaths: removal.removed, failureCount: removal.failures.length },
|
|
1718
|
+
}
|
|
1719
|
+
return {
|
|
1720
|
+
version: 1, command: 'cleanup', ok: false, applied: true, action: 'retain', code: 'retained',
|
|
1721
|
+
repo, run: runDir, unit: String(unitId), revision: planRevisionOf(current.plan),
|
|
1722
|
+
workspace: { path: normPath(freshUnit.workspace), exists: existsSync(freshUnit.workspace), kind: freshUnit.lifecycle.workspace.kind },
|
|
1723
|
+
lifecycle, nextLifecycle: lifecycle, checks: check.checks,
|
|
1724
|
+
paths: removal.removed, reason: removal.failures.join('; '), receipt: null,
|
|
1725
|
+
evidence: check.evidence ?? [], errors: removal.failures,
|
|
1726
|
+
}
|
|
1727
|
+
}
|
|
1728
|
+
const lifecycle = {
|
|
1729
|
+
...freshUnit.lifecycle,
|
|
1730
|
+
cleanup: {
|
|
1731
|
+
...(freshUnit.lifecycle.cleanup ?? {}),
|
|
1732
|
+
state: 'complete',
|
|
1733
|
+
headSha: freshUnit.lifecycle.cleanup?.headSha ?? check.checks.find((c) => c.name === 'head')?.detail,
|
|
1734
|
+
completedAt: new Date().toISOString(),
|
|
1735
|
+
...(removal.receipt ? { receipt: removal.receipt } : {}),
|
|
1736
|
+
},
|
|
1737
|
+
}
|
|
1738
|
+
return {
|
|
1739
|
+
version: 1, command: 'cleanup', ok: true, applied: true, action: 'remove', code: 'removed',
|
|
1740
|
+
repo, run: runDir, unit: String(unitId), revision: planRevisionOf(current.plan),
|
|
1741
|
+
workspace: { path: normPath(freshUnit.workspace), exists: false, kind: freshUnit.lifecycle.workspace.kind },
|
|
1742
|
+
lifecycle, nextLifecycle: lifecycle, checks: check.checks,
|
|
1743
|
+
paths: removal.removed, reason: check.reason, receipt: removal.receipt,
|
|
1744
|
+
evidence: check.evidence ?? [], errors: [],
|
|
1745
|
+
}
|
|
1746
|
+
}
|
|
1747
|
+
|
|
1748
|
+
if (check.verdict === 'already-complete') {
|
|
1749
|
+
const receipt = check.receipt
|
|
1750
|
+
const lifecycle = {
|
|
1751
|
+
...freshUnit.lifecycle,
|
|
1752
|
+
cleanup: { ...(freshUnit.lifecycle.cleanup ?? {}), state: 'complete', ...(receipt ? { receipt } : {}) },
|
|
1753
|
+
}
|
|
1754
|
+
return {
|
|
1755
|
+
version: 1, command: 'cleanup', ok: true, applied: false, action: 'already-complete', code: 'complete',
|
|
1756
|
+
repo, run: runDir, unit: String(unitId), revision: planRevisionOf(current.plan),
|
|
1757
|
+
workspace: { path: normPath(freshUnit.workspace), exists: existsSync(freshUnit.workspace), kind: freshUnit.lifecycle.workspace.kind },
|
|
1758
|
+
lifecycle, nextLifecycle: lifecycle, checks: check.checks, paths: [],
|
|
1759
|
+
reason: check.reason, receipt, evidence: check.evidence ?? [], errors: [],
|
|
1760
|
+
}
|
|
1761
|
+
}
|
|
1762
|
+
|
|
1763
|
+
const baseLifecycle = isObject(freshUnit.lifecycle) ? freshUnit.lifecycle : null
|
|
1764
|
+
const lifecycle = baseLifecycle
|
|
1765
|
+
? {
|
|
1766
|
+
...baseLifecycle,
|
|
1767
|
+
cleanup: { ...(baseLifecycle.cleanup ?? {}), state: !existsSync(freshUnit.workspace) && baseLifecycle.cleanup?.state === 'applying' ? 'applying' : 'retained', headSha: currentHead(freshUnit.workspace) ?? baseLifecycle.cleanup?.headSha ?? null, reason: check.reason },
|
|
1768
|
+
}
|
|
1769
|
+
: null
|
|
1770
|
+
return {
|
|
1771
|
+
version: 1, command: 'cleanup', ok: false, applied: false, action: check.verdict, code: check.verdict === 'refuse' ? 'refused' : 'retained',
|
|
1772
|
+
repo, run: runDir, unit: String(unitId), revision: planRevisionOf(current.plan),
|
|
1773
|
+
workspace: { path: normPath(freshUnit.workspace), exists: existsSync(freshUnit.workspace), kind: baseLifecycle?.workspace?.kind ?? null },
|
|
1774
|
+
lifecycle, nextLifecycle: lifecycle, checks: check.checks, paths: [],
|
|
1775
|
+
reason: check.reason, receipt: null, evidence: check.evidence ?? [], errors: [],
|
|
1776
|
+
}
|
|
1777
|
+
} finally {
|
|
1778
|
+
lock.release()
|
|
1779
|
+
}
|
|
1780
|
+
}
|
|
1781
|
+
|
|
1782
|
+
// ---- legacy --merged compatibility ----
|
|
1783
|
+
|
|
1784
|
+
// The old flow (`cleanup-runs.mjs --merged <workspace> --apply`) had no --run or --unit.
|
|
1785
|
+
// Resolve the saved run and unit that own the path, then use the same checks. An unowned
|
|
1786
|
+
// path is retained: a path that no plan claims is not cleaned by a glob.
|
|
1787
|
+
export function resolveMerged(repo, targetPath, { discoveryRoot = null } = {}) {
|
|
1788
|
+
const path = pathIdentity(resolve(targetPath))
|
|
1789
|
+
if (!path.resolved) return { found: false, reason: `cannot resolve ${targetPath}: ${path.reason}` }
|
|
1790
|
+
const roots = []
|
|
1791
|
+
if (discoveryRoot) roots.push(discoveryRoot)
|
|
1792
|
+
else {
|
|
1793
|
+
const marker = `${sep}.resolve-issues${sep}rush${sep}`
|
|
1794
|
+
const text = `${path.real}${sep}`
|
|
1795
|
+
const at = text.indexOf(marker)
|
|
1796
|
+
if (at >= 0) roots.push(text.slice(0, at + marker.length - 1))
|
|
1797
|
+
}
|
|
1798
|
+
if (!roots.length && repo) roots.push(join(repo, '.resolve-issues', 'rush'))
|
|
1799
|
+
const runsRoot = roots[0]
|
|
1800
|
+
if (!runsRoot) return { found: false, reason: `${targetPath} is not under a .resolve-issues/rush directory` }
|
|
1801
|
+
let names = []
|
|
1802
|
+
try { names = readdirSync(runsRoot) } catch { return { found: false, reason: `cannot read ${runsRoot}` } }
|
|
1803
|
+
for (const name of names) {
|
|
1804
|
+
const run = join(runsRoot, name)
|
|
1805
|
+
let plan
|
|
1806
|
+
try { plan = readPlan(run).plan } catch { continue }
|
|
1807
|
+
const units = Array.isArray(plan.units) ? plan.units : (Array.isArray(plan.issues) ? plan.issues : [])
|
|
1808
|
+
for (const unit of units) {
|
|
1809
|
+
if (!isObject(unit)) continue
|
|
1810
|
+
if (nonEmpty(unit.workspace)) {
|
|
1811
|
+
const ws = pathIdentity(unit.workspace)
|
|
1812
|
+
if (ws.resolved && ws.real === path.real) return { found: true, run, repo: repositoryOf(plan, run), plan, unit, role: 'workspace' }
|
|
1813
|
+
}
|
|
1814
|
+
for (const resource of lifecycleResources(unit)) {
|
|
1815
|
+
if (!isObject(resource) || !nonEmpty(resource.path)) continue
|
|
1816
|
+
const ident = pathIdentity(resource.path)
|
|
1817
|
+
if (ident.resolved && ident.real === path.real) return { found: true, run, repo: repositoryOf(plan, run), plan, unit, role: 'resource', resource }
|
|
1818
|
+
}
|
|
1819
|
+
}
|
|
1820
|
+
}
|
|
1821
|
+
return { found: false, reason: `no saved run or unit claims ${targetPath}; an unowned path is retained` }
|
|
1822
|
+
}
|
|
1823
|
+
|
|
1824
|
+
export function cleanupMerged({ repo, path, apply = false, requireApplying = false, runGh = null }) {
|
|
1825
|
+
const resolved = resolveMerged(repo, path)
|
|
1826
|
+
if (!resolved.found) {
|
|
1827
|
+
return {
|
|
1828
|
+
version: 1, command: 'merged', ok: false, applied: false, action: 'refuse',
|
|
1829
|
+
repo, run: null, unit: null, revision: null, workspace: { path: normPath(path), exists: existsSync(path) },
|
|
1830
|
+
lifecycle: null, nextLifecycle: null, checks: [], paths: [], reason: resolved.reason, receipt: null, evidence: [], errors: [],
|
|
1831
|
+
}
|
|
1832
|
+
}
|
|
1833
|
+
// Compatibility resolves ownership only; both workspace and resource requests enter
|
|
1834
|
+
// the same whole-environment checks, lock, revision binding and receipt path.
|
|
1835
|
+
const result = cleanup({ run: resolved.run, unit: resolved.unit.id, revision: resolved.plan.planRevision, apply, requireApplying, runGh })
|
|
1836
|
+
return { ...result, command: 'merged' }
|
|
1837
|
+
}
|
|
1838
|
+
|
|
1839
|
+
// ---- safe --repo sweep ----
|
|
1840
|
+
|
|
1841
|
+
export function sweep({ repo, apply = false, runGh = null }) {
|
|
1842
|
+
repo = resolve(repo)
|
|
1843
|
+
const runsRoot = join(repo, '.resolve-issues', 'rush')
|
|
1844
|
+
const result = {
|
|
1845
|
+
version: 1,
|
|
1846
|
+
command: 'sweep',
|
|
1847
|
+
ok: true,
|
|
1848
|
+
applied: Boolean(apply),
|
|
1849
|
+
repo: normPath(repo),
|
|
1850
|
+
runsRoot,
|
|
1851
|
+
runs: [],
|
|
1852
|
+
unknownEntries: [],
|
|
1853
|
+
summary: { runs: 0, completed: 0, removed: 0, retained: 0, refused: 0, unknown: 0 },
|
|
1854
|
+
errors: [],
|
|
1855
|
+
}
|
|
1856
|
+
if (!existsSync(runsRoot)) return result
|
|
1857
|
+
let names = []
|
|
1858
|
+
try { names = readdirSync(runsRoot, { withFileTypes: true }) } catch (error) {
|
|
1859
|
+
return { ...result, ok: false, errors: [`cannot read ${runsRoot}: ${error.message}`] }
|
|
1860
|
+
}
|
|
1861
|
+
for (const entry of names) {
|
|
1862
|
+
if (!entry.isDirectory()) { result.unknownEntries.push(entry.name); continue }
|
|
1863
|
+
const run = join(runsRoot, entry.name)
|
|
1864
|
+
if (!existsSync(join(run, 'task-plan.json'))) { result.unknownEntries.push(entry.name); continue }
|
|
1865
|
+
result.summary.runs++
|
|
1866
|
+
let planRead
|
|
1867
|
+
try { planRead = readPlan(run) } catch (error) {
|
|
1868
|
+
result.runs.push({ run: normPath(run), action: 'inspect', reason: `${error.code}: ${error.message}` })
|
|
1869
|
+
result.summary.retained++
|
|
1870
|
+
continue
|
|
1871
|
+
}
|
|
1872
|
+
const { plan, units } = planRead
|
|
1873
|
+
const runRow = { run: normPath(run), revision: plan.planRevision ?? null, units: [] }
|
|
1874
|
+
for (const unit of units) {
|
|
1875
|
+
if (!isObject(unit)) continue
|
|
1876
|
+
const unitId = String(unit.id ?? '')
|
|
1877
|
+
if (!unitCompleted(unit)) {
|
|
1878
|
+
runRow.units.push({ unit: unitId, action: 'retain', reason: 'not a completed unit' })
|
|
1879
|
+
result.summary.retained++
|
|
1880
|
+
continue
|
|
1881
|
+
}
|
|
1882
|
+
result.summary.completed++
|
|
1883
|
+
const lifecycle = unit.lifecycle
|
|
1884
|
+
if (!isObject(lifecycle)) {
|
|
1885
|
+
runRow.units.push({ unit: unitId, action: 'retain', reason: 'no lifecycle record; the sweep does not adopt an unrecorded tree' })
|
|
1886
|
+
result.summary.retained++
|
|
1887
|
+
continue
|
|
1888
|
+
}
|
|
1889
|
+
try {
|
|
1890
|
+
const observed = cleanup({ run, unit: unitId, revision: plan.planRevision, apply, requireApplying: false, runGh })
|
|
1891
|
+
runRow.units.push(observed)
|
|
1892
|
+
if (observed.action === 'remove' && observed.applied) result.summary.removed++
|
|
1893
|
+
else if (observed.action === 'refuse') result.summary.refused++
|
|
1894
|
+
else if (!observed.ok) result.summary.retained++
|
|
1895
|
+
} catch (error) {
|
|
1896
|
+
runRow.units.push({ unit: unitId, action: 'inspect', reason: `${error.code ?? 'error'}: ${error.message}` })
|
|
1897
|
+
result.summary.retained++
|
|
1898
|
+
}
|
|
1899
|
+
}
|
|
1900
|
+
result.runs.push(runRow)
|
|
1901
|
+
}
|
|
1902
|
+
result.summary.unknown = result.unknownEntries.length
|
|
1903
|
+
return result
|
|
1904
|
+
}
|
|
1905
|
+
|
|
1906
|
+
// ---- recover (read-only) ----
|
|
1907
|
+
|
|
1908
|
+
export function recover({ repo, runGh = null }) {
|
|
1909
|
+
repo = resolve(repo)
|
|
1910
|
+
const runsRoot = join(repo, '.resolve-issues', 'rush')
|
|
1911
|
+
const out = {
|
|
1912
|
+
version: 1,
|
|
1913
|
+
command: 'recover',
|
|
1914
|
+
ok: true,
|
|
1915
|
+
readonly: true,
|
|
1916
|
+
repo: normPath(repo),
|
|
1917
|
+
runsRoot,
|
|
1918
|
+
runs: [],
|
|
1919
|
+
worktrees: [],
|
|
1920
|
+
locks: [],
|
|
1921
|
+
due: [],
|
|
1922
|
+
unknownEntries: [],
|
|
1923
|
+
}
|
|
1924
|
+
const owned = new Map()
|
|
1925
|
+
let names = []
|
|
1926
|
+
try { names = readdirSync(runsRoot, { withFileTypes: true }) } catch (error) {
|
|
1927
|
+
return { ...out, ok: false, errors: [`cannot read ${runsRoot}: ${error.message}`] }
|
|
1928
|
+
}
|
|
1929
|
+
for (const entry of names) {
|
|
1930
|
+
if (!entry.isDirectory()) { out.unknownEntries.push(entry.name); continue }
|
|
1931
|
+
const run = join(runsRoot, entry.name)
|
|
1932
|
+
if (!existsSync(join(run, 'task-plan.json'))) { out.unknownEntries.push(entry.name); continue }
|
|
1933
|
+
let planRead
|
|
1934
|
+
try { planRead = readPlan(run) } catch (error) {
|
|
1935
|
+
out.runs.push({ run: normPath(run), action: 'inspect', reason: `${error.code}: ${error.message}`, units: [] })
|
|
1936
|
+
continue
|
|
1937
|
+
}
|
|
1938
|
+
const { plan, units } = planRead
|
|
1939
|
+
const runRow = { run: normPath(run), revision: plan.planRevision ?? null, units: [] }
|
|
1940
|
+
for (const unit of units) {
|
|
1941
|
+
if (!isObject(unit)) continue
|
|
1942
|
+
const unitId = String(unit.id ?? '')
|
|
1943
|
+
if (nonEmpty(unit.workspace)) {
|
|
1944
|
+
const ident = pathIdentity(unit.workspace)
|
|
1945
|
+
if (ident.resolved) owned.set(ident.real, `${unitId}:workspace`)
|
|
1946
|
+
}
|
|
1947
|
+
for (const resource of lifecycleResources(unit)) {
|
|
1948
|
+
if (!isObject(resource) || !nonEmpty(resource.path)) continue
|
|
1949
|
+
const ident = pathIdentity(resource.path)
|
|
1950
|
+
if (ident.resolved) owned.set(ident.real, `${unitId}:resource:${resource.role ?? 'unknown'}`)
|
|
1951
|
+
}
|
|
1952
|
+
const lifecycle = isObject(unit.lifecycle) ? unit.lifecycle : null
|
|
1953
|
+
const row = {
|
|
1954
|
+
unit: unitId,
|
|
1955
|
+
status: unit.status ?? null,
|
|
1956
|
+
delivery: isObject(unit.delivery) ? (unit.delivery.outcome ?? unit.delivery.state ?? null) : (unit.delivery ?? null),
|
|
1957
|
+
workspace: { path: unit.workspace ?? null, exists: nonEmpty(unit.workspace) ? existsSync(unit.workspace) : false, kind: lifecycle?.workspace?.kind ?? null },
|
|
1958
|
+
cleanup: lifecycle?.cleanup ?? null,
|
|
1959
|
+
resources: lifecycleResources(unit).map((r) => ({ path: r.path, role: r.role, exists: existsSync(r.path), state: r.state })),
|
|
1960
|
+
action: 'none',
|
|
1961
|
+
reason: null,
|
|
1962
|
+
}
|
|
1963
|
+
const cleanup = lifecycle?.cleanup ?? null
|
|
1964
|
+
if (lifecycle?.workspace?.state === 'preparing' && cleanup?.state !== 'complete' && cleanup?.state !== 'retained') {
|
|
1965
|
+
row.action = 'prepare'
|
|
1966
|
+
row.reason = `lifecycle.workspace.state is preparing; prepare the recorded ${lifecycle.workspace.kind} and persist its ready identity`
|
|
1967
|
+
out.due.push({ run: normPath(run), unit: unitId, operation: 'prepare', workspace: unit.workspace ?? null, reason: row.reason })
|
|
1968
|
+
} else if (isObject(cleanup) && cleanup.state === 'complete') {
|
|
1969
|
+
const completion = verifiedReceipt({ repo: repositoryOf(plan, run), run: canonicalRunDir(repo, run), unit })
|
|
1970
|
+
if (!completion.ok) {
|
|
1971
|
+
row.action = 'inspect'
|
|
1972
|
+
row.reason = `completion cannot be verified: ${completion.reason}`
|
|
1973
|
+
out.due.push({ run: normPath(run), unit: unitId, operation: 'inspect', workspace: unit.workspace, reason: row.reason })
|
|
1974
|
+
}
|
|
1975
|
+
} else if (isObject(cleanup) && cleanup.state === 'applying') {
|
|
1976
|
+
if (!row.workspace.exists) {
|
|
1977
|
+
const completion = verifiedReceipt({ repo: repositoryOf(plan, run), run: canonicalRunDir(repo, run), unit })
|
|
1978
|
+
row.action = completion.ok ? 'postcondition' : 'inspect'
|
|
1979
|
+
row.reason = completion.ok ? `verified cleanup receipt ${completion.receipt}; persist complete` : `ambiguous partial cleanup: ${completion.reason}`
|
|
1980
|
+
out.due.push({ run: normPath(run), unit: unitId, operation: completion.ok ? 'recover-postcondition' : 'inspect', workspace: unit.workspace ?? null, receipt: completion.receipt ?? null, reason: row.reason })
|
|
1981
|
+
} else {
|
|
1982
|
+
row.action = 'cleanup-due'
|
|
1983
|
+
row.reason = 'cleanup.state is applying and the workspace still exists; rerun cleanup --apply'
|
|
1984
|
+
out.due.push({ run: normPath(run), unit: unitId, operation: 'cleanup', workspace: unit.workspace ?? null, reason: row.reason })
|
|
1985
|
+
}
|
|
1986
|
+
} else if (unitCompleted(unit) && lifecycle && cleanup?.state !== 'complete' && row.workspace.exists) {
|
|
1987
|
+
row.action = 'cleanup-due'
|
|
1988
|
+
row.reason = `unit is completed and cleanup.state is ${cleanup?.state ?? 'missing'}; verify and bind applying before apply`
|
|
1989
|
+
out.due.push({ run: normPath(run), unit: unitId, operation: 'cleanup', workspace: unit.workspace ?? null, reason: row.reason })
|
|
1990
|
+
} else if (!row.workspace.exists && lifecycle?.workspace?.state === 'ready') {
|
|
1991
|
+
row.action = 'inspect'
|
|
1992
|
+
row.reason = 'lifecycle records a ready workspace that is missing'
|
|
1993
|
+
out.due.push({ run: normPath(run), unit: unitId, operation: 'inspect', workspace: unit.workspace ?? null, reason: row.reason })
|
|
1994
|
+
}
|
|
1995
|
+
runRow.units.push(row)
|
|
1996
|
+
}
|
|
1997
|
+
out.runs.push(runRow)
|
|
1998
|
+
try {
|
|
1999
|
+
const locks = readdirSync(join(run, '.workspace-locks'), { withFileTypes: true }).filter((e) => e.isDirectory()).map((e) => e.name.replace(/\.lock$/, ''))
|
|
2000
|
+
for (const lock of locks) {
|
|
2001
|
+
const path = join(run, '.workspace-locks', `${lock}.lock`)
|
|
2002
|
+
out.locks.push({ run: normPath(run), unit: lock, path })
|
|
2003
|
+
out.due.push({ run: normPath(run), unit: lock, operation: 'lock', path, reason: 'a workspace lock is held; the lock is never reclaimed by age, so inspect the owner and remove it only after confirming no prepare or cleanup is running' })
|
|
2004
|
+
}
|
|
2005
|
+
} catch { /* no locks directory */ }
|
|
2006
|
+
}
|
|
2007
|
+
const listed = worktreeList(repo)
|
|
2008
|
+
if (listed) {
|
|
2009
|
+
const runsReal = pathIdentity(runsRoot).real
|
|
2010
|
+
for (const path of listed) {
|
|
2011
|
+
const ident = pathIdentity(path)
|
|
2012
|
+
if (!ident.resolved) {
|
|
2013
|
+
out.worktrees.push({ path, resolved: false, ownedBy: owned.get(ident.recorded) ?? null, insideRoot: false })
|
|
2014
|
+
continue
|
|
2015
|
+
}
|
|
2016
|
+
const isRepository = ident.real === pathIdentity(repo).real
|
|
2017
|
+
out.worktrees.push({
|
|
2018
|
+
path: ident.real,
|
|
2019
|
+
ownedBy: owned.get(ident.real) ?? null,
|
|
2020
|
+
insideRoot: runsReal ? containsPath(runsReal, ident.real) : false,
|
|
2021
|
+
isRepository,
|
|
2022
|
+
})
|
|
2023
|
+
if (!isRepository && !owned.get(ident.real)) {
|
|
2024
|
+
out.due.push({ operation: 'orphan-worktree', path: ident.real, reason: 'a registered worktree is not owned by any unit in a saved plan; inventory only, never removed by pattern or age' })
|
|
2025
|
+
}
|
|
2026
|
+
}
|
|
2027
|
+
}
|
|
2028
|
+
out.ownedPaths = [...owned.entries()].map(([path, owner]) => ({ path, owner }))
|
|
2029
|
+
return out
|
|
2030
|
+
}
|
|
2031
|
+
|
|
2032
|
+
// ---- inventory (read-only, bounded, no symlink traversal) ----
|
|
2033
|
+
|
|
2034
|
+
export function inventory({ repo, root = null, maxEntries = INVENTORY_MAX_ENTRIES, maxDepth = INVENTORY_MAX_DEPTH }) {
|
|
2035
|
+
repo = resolve(repo)
|
|
2036
|
+
if (!Number.isSafeInteger(maxEntries) || maxEntries <= 0) throw new LifecycleError('inventory-bound', '--max-entries must be a positive integer')
|
|
2037
|
+
if (!Number.isSafeInteger(maxDepth) || maxDepth < 0) throw new LifecycleError('inventory-bound', '--max-depth must be a nonnegative integer')
|
|
2038
|
+
const defaultRoot = join(repo, '.resolve-issues', 'rush')
|
|
2039
|
+
const inventoryRoot = root ? resolve(root) : defaultRoot
|
|
2040
|
+
const out = {
|
|
2041
|
+
version: 1,
|
|
2042
|
+
command: 'inventory',
|
|
2043
|
+
ok: true,
|
|
2044
|
+
readonly: true,
|
|
2045
|
+
repo: normPath(repo),
|
|
2046
|
+
root: normPath(inventoryRoot),
|
|
2047
|
+
maxEntries,
|
|
2048
|
+
maxDepth,
|
|
2049
|
+
entries: [],
|
|
2050
|
+
runs: [],
|
|
2051
|
+
registeredWorktrees: [],
|
|
2052
|
+
truncated: false,
|
|
2053
|
+
unknownEntries: [],
|
|
2054
|
+
}
|
|
2055
|
+
const owned = new Map()
|
|
2056
|
+
const registered = new Set()
|
|
2057
|
+
const runs = []
|
|
2058
|
+
if (existsSync(defaultRoot)) {
|
|
2059
|
+
let names = []
|
|
2060
|
+
try { names = readdirSync(defaultRoot, { withFileTypes: true }) } catch { names = [] }
|
|
2061
|
+
for (const entry of names) {
|
|
2062
|
+
if (!entry.isDirectory()) { out.unknownEntries.push(entry.name); continue }
|
|
2063
|
+
const run = join(defaultRoot, entry.name)
|
|
2064
|
+
if (!existsSync(join(run, 'task-plan.json'))) { out.unknownEntries.push(entry.name); continue }
|
|
2065
|
+
try {
|
|
2066
|
+
const { plan, units } = readPlan(run)
|
|
2067
|
+
const runRow = { run: normPath(run), revision: plan.planRevision ?? null, units: [] }
|
|
2068
|
+
for (const unit of units) {
|
|
2069
|
+
if (!isObject(unit)) continue
|
|
2070
|
+
const unitId = String(unit.id ?? '')
|
|
2071
|
+
const lifecycle = isObject(unit.lifecycle) ? unit.lifecycle : null
|
|
2072
|
+
runRow.units.push({
|
|
2073
|
+
unit: unitId,
|
|
2074
|
+
workspace: unit.workspace ?? null,
|
|
2075
|
+
branch: unit.branch ?? null,
|
|
2076
|
+
lifecycle: lifecycle ? { state: lifecycle.workspace?.state, kind: lifecycle.workspace?.kind, cleanup: lifecycle.cleanup ?? null } : null,
|
|
2077
|
+
resources: lifecycleResources(unit).map((r) => ({ path: r.path, role: r.role, state: r.state })),
|
|
2078
|
+
})
|
|
2079
|
+
if (nonEmpty(unit.workspace)) {
|
|
2080
|
+
const ident = pathIdentity(unit.workspace)
|
|
2081
|
+
if (ident.resolved) owned.set(ident.real, `${unitId}:workspace`)
|
|
2082
|
+
}
|
|
2083
|
+
for (const resource of lifecycleResources(unit)) {
|
|
2084
|
+
if (!isObject(resource) || !nonEmpty(resource.path)) continue
|
|
2085
|
+
const ident = pathIdentity(resource.path)
|
|
2086
|
+
if (ident.resolved) owned.set(ident.real, `${unitId}:resource:${resource.role ?? 'unknown'}`)
|
|
2087
|
+
}
|
|
2088
|
+
}
|
|
2089
|
+
runs.push(runRow)
|
|
2090
|
+
} catch (error) {
|
|
2091
|
+
out.unknownEntries.push(entry.name)
|
|
2092
|
+
runs.push({ run: normPath(run), action: 'inspect', reason: `${error.code}: ${error.message}` })
|
|
2093
|
+
}
|
|
2094
|
+
}
|
|
2095
|
+
}
|
|
2096
|
+
out.runs = runs
|
|
2097
|
+
|
|
2098
|
+
// Walk the requested root without following symlinks. A symlink is reported as an entry
|
|
2099
|
+
// and never traversed; the bound is on entries, not on the size of the filesystem.
|
|
2100
|
+
const rootIdent = pathIdentity(inventoryRoot)
|
|
2101
|
+
let rootIsSymlink = false
|
|
2102
|
+
try { rootIsSymlink = lstatSync(inventoryRoot).isSymbolicLink() } catch { /* missing is handled below */ }
|
|
2103
|
+
if (!rootIdent.resolved || !rootIdent.exists) {
|
|
2104
|
+
out.reason = rootIdent.resolved ? `${inventoryRoot} does not exist` : rootIdent.reason
|
|
2105
|
+
} else if (rootIsSymlink) {
|
|
2106
|
+
out.reason = `${inventoryRoot} is a symbolic link; inventory does not follow it`
|
|
2107
|
+
} else {
|
|
2108
|
+
const queue = [{ path: rootIdent.real, depth: 0 }]
|
|
2109
|
+
while (queue.length) {
|
|
2110
|
+
if (out.entries.length >= maxEntries) { out.truncated = true; break }
|
|
2111
|
+
const { path, depth } = queue.shift()
|
|
2112
|
+
let entries = []
|
|
2113
|
+
try { entries = readdirSync(path, { withFileTypes: true }) } catch { continue }
|
|
2114
|
+
for (const entry of entries) {
|
|
2115
|
+
if (out.entries.length >= maxEntries) { out.truncated = true; break }
|
|
2116
|
+
const child = join(path, entry.name)
|
|
2117
|
+
let kind = 'other'
|
|
2118
|
+
if (entry.isSymbolicLink()) kind = 'symlink'
|
|
2119
|
+
else if (entry.isDirectory()) kind = 'directory'
|
|
2120
|
+
else if (entry.isFile()) kind = 'file'
|
|
2121
|
+
const ident = pathIdentity(child)
|
|
2122
|
+
const ownedBy = ident.resolved ? owned.get(ident.real) ?? null : null
|
|
2123
|
+
const row = {
|
|
2124
|
+
path: child,
|
|
2125
|
+
depth,
|
|
2126
|
+
kind,
|
|
2127
|
+
ownedBy,
|
|
2128
|
+
registeredWorktree: false,
|
|
2129
|
+
}
|
|
2130
|
+
try { row.size = entry.isFile() ? statSync(child).size : null } catch { row.size = null }
|
|
2131
|
+
out.entries.push(row)
|
|
2132
|
+
if (kind === 'directory' && depth + 1 <= maxDepth) queue.push({ path: child, depth: depth + 1 })
|
|
2133
|
+
else if (kind === 'directory') out.truncated = true
|
|
2134
|
+
}
|
|
2135
|
+
}
|
|
2136
|
+
}
|
|
2137
|
+
|
|
2138
|
+
const listed = worktreeList(repo)
|
|
2139
|
+
if (listed) {
|
|
2140
|
+
const rootReal = pathIdentity(inventoryRoot).real
|
|
2141
|
+
out.registeredWorktrees = listed.map((path) => {
|
|
2142
|
+
const ident = pathIdentity(path)
|
|
2143
|
+
if (ident.resolved) registered.add(ident.real)
|
|
2144
|
+
return {
|
|
2145
|
+
path: ident.resolved ? ident.real : path,
|
|
2146
|
+
ownedBy: ident.resolved ? owned.get(ident.real) ?? null : null,
|
|
2147
|
+
insideRoot: ident.resolved && rootReal ? containsPath(rootReal, ident.real) : false,
|
|
2148
|
+
exists: ident.resolved ? ident.exists : false,
|
|
2149
|
+
}
|
|
2150
|
+
})
|
|
2151
|
+
for (const row of out.entries) row.registeredWorktree = registered.has(row.path)
|
|
2152
|
+
}
|
|
2153
|
+
return out
|
|
2154
|
+
}
|