@lumenflow/core 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +190 -0
- package/README.md +119 -0
- package/dist/active-wu-detector.d.ts +33 -0
- package/dist/active-wu-detector.js +106 -0
- package/dist/adapters/filesystem-metrics.adapter.d.ts +108 -0
- package/dist/adapters/filesystem-metrics.adapter.js +519 -0
- package/dist/adapters/terminal-renderer.adapter.d.ts +106 -0
- package/dist/adapters/terminal-renderer.adapter.js +337 -0
- package/dist/arg-parser.d.ts +63 -0
- package/dist/arg-parser.js +560 -0
- package/dist/backlog-editor.d.ts +98 -0
- package/dist/backlog-editor.js +179 -0
- package/dist/backlog-generator.d.ts +111 -0
- package/dist/backlog-generator.js +381 -0
- package/dist/backlog-parser.d.ts +45 -0
- package/dist/backlog-parser.js +102 -0
- package/dist/backlog-sync-validator.d.ts +78 -0
- package/dist/backlog-sync-validator.js +294 -0
- package/dist/branch-drift.d.ts +34 -0
- package/dist/branch-drift.js +51 -0
- package/dist/cleanup-install-config.d.ts +33 -0
- package/dist/cleanup-install-config.js +37 -0
- package/dist/cleanup-lock.d.ts +139 -0
- package/dist/cleanup-lock.js +313 -0
- package/dist/code-path-validator.d.ts +146 -0
- package/dist/code-path-validator.js +537 -0
- package/dist/code-paths-overlap.d.ts +55 -0
- package/dist/code-paths-overlap.js +245 -0
- package/dist/commands-logger.d.ts +77 -0
- package/dist/commands-logger.js +254 -0
- package/dist/commit-message-utils.d.ts +25 -0
- package/dist/commit-message-utils.js +41 -0
- package/dist/compliance-parser.d.ts +150 -0
- package/dist/compliance-parser.js +507 -0
- package/dist/constants/backlog-patterns.d.ts +20 -0
- package/dist/constants/backlog-patterns.js +23 -0
- package/dist/constants/dora-constants.d.ts +49 -0
- package/dist/constants/dora-constants.js +53 -0
- package/dist/constants/gate-constants.d.ts +15 -0
- package/dist/constants/gate-constants.js +15 -0
- package/dist/constants/linter-constants.d.ts +16 -0
- package/dist/constants/linter-constants.js +16 -0
- package/dist/constants/tokenizer-constants.d.ts +15 -0
- package/dist/constants/tokenizer-constants.js +15 -0
- package/dist/core/scope-checker.d.ts +97 -0
- package/dist/core/scope-checker.js +163 -0
- package/dist/core/tool-runner.d.ts +161 -0
- package/dist/core/tool-runner.js +393 -0
- package/dist/core/tool.constants.d.ts +105 -0
- package/dist/core/tool.constants.js +101 -0
- package/dist/core/tool.schemas.d.ts +226 -0
- package/dist/core/tool.schemas.js +226 -0
- package/dist/core/worktree-guard.d.ts +130 -0
- package/dist/core/worktree-guard.js +242 -0
- package/dist/coverage-gate.d.ts +108 -0
- package/dist/coverage-gate.js +196 -0
- package/dist/date-utils.d.ts +75 -0
- package/dist/date-utils.js +140 -0
- package/dist/dependency-graph.d.ts +142 -0
- package/dist/dependency-graph.js +550 -0
- package/dist/dependency-guard.d.ts +54 -0
- package/dist/dependency-guard.js +142 -0
- package/dist/dependency-validator.d.ts +105 -0
- package/dist/dependency-validator.js +154 -0
- package/dist/docs-path-validator.d.ts +36 -0
- package/dist/docs-path-validator.js +95 -0
- package/dist/domain/orchestration.constants.d.ts +99 -0
- package/dist/domain/orchestration.constants.js +97 -0
- package/dist/domain/orchestration.schemas.d.ts +280 -0
- package/dist/domain/orchestration.schemas.js +211 -0
- package/dist/domain/orchestration.types.d.ts +133 -0
- package/dist/domain/orchestration.types.js +12 -0
- package/dist/error-handler.d.ts +116 -0
- package/dist/error-handler.js +136 -0
- package/dist/file-classifiers.d.ts +62 -0
- package/dist/file-classifiers.js +108 -0
- package/dist/gates-agent-mode.d.ts +81 -0
- package/dist/gates-agent-mode.js +94 -0
- package/dist/generate-traceability.d.ts +107 -0
- package/dist/generate-traceability.js +411 -0
- package/dist/git-adapter.d.ts +395 -0
- package/dist/git-adapter.js +649 -0
- package/dist/git-staged-validator.d.ts +32 -0
- package/dist/git-staged-validator.js +48 -0
- package/dist/hardcoded-strings.d.ts +61 -0
- package/dist/hardcoded-strings.js +270 -0
- package/dist/incremental-lint.d.ts +78 -0
- package/dist/incremental-lint.js +129 -0
- package/dist/incremental-test.d.ts +39 -0
- package/dist/incremental-test.js +61 -0
- package/dist/index.d.ts +42 -0
- package/dist/index.js +61 -0
- package/dist/invariants/check-automated-tests.d.ts +50 -0
- package/dist/invariants/check-automated-tests.js +166 -0
- package/dist/invariants-runner.d.ts +103 -0
- package/dist/invariants-runner.js +527 -0
- package/dist/lane-checker.d.ts +50 -0
- package/dist/lane-checker.js +319 -0
- package/dist/lane-inference.d.ts +39 -0
- package/dist/lane-inference.js +195 -0
- package/dist/lane-lock.d.ts +211 -0
- package/dist/lane-lock.js +474 -0
- package/dist/lane-validator.d.ts +48 -0
- package/dist/lane-validator.js +114 -0
- package/dist/logs-lib.d.ts +104 -0
- package/dist/logs-lib.js +207 -0
- package/dist/lumenflow-config-schema.d.ts +272 -0
- package/dist/lumenflow-config-schema.js +207 -0
- package/dist/lumenflow-config.d.ts +95 -0
- package/dist/lumenflow-config.js +236 -0
- package/dist/manual-test-validator.d.ts +80 -0
- package/dist/manual-test-validator.js +200 -0
- package/dist/merge-lock.d.ts +115 -0
- package/dist/merge-lock.js +251 -0
- package/dist/micro-worktree.d.ts +159 -0
- package/dist/micro-worktree.js +427 -0
- package/dist/migration-deployer.d.ts +69 -0
- package/dist/migration-deployer.js +151 -0
- package/dist/orchestration-advisory-loader.d.ts +28 -0
- package/dist/orchestration-advisory-loader.js +87 -0
- package/dist/orchestration-advisory.d.ts +58 -0
- package/dist/orchestration-advisory.js +94 -0
- package/dist/orchestration-di.d.ts +48 -0
- package/dist/orchestration-di.js +57 -0
- package/dist/orchestration-rules.d.ts +57 -0
- package/dist/orchestration-rules.js +201 -0
- package/dist/orphan-detector.d.ts +131 -0
- package/dist/orphan-detector.js +226 -0
- package/dist/path-classifiers.d.ts +57 -0
- package/dist/path-classifiers.js +93 -0
- package/dist/piped-command-detector.d.ts +34 -0
- package/dist/piped-command-detector.js +64 -0
- package/dist/ports/dashboard-renderer.port.d.ts +112 -0
- package/dist/ports/dashboard-renderer.port.js +25 -0
- package/dist/ports/metrics-collector.port.d.ts +132 -0
- package/dist/ports/metrics-collector.port.js +26 -0
- package/dist/process-detector.d.ts +84 -0
- package/dist/process-detector.js +172 -0
- package/dist/prompt-linter.d.ts +72 -0
- package/dist/prompt-linter.js +312 -0
- package/dist/prompt-monitor.d.ts +15 -0
- package/dist/prompt-monitor.js +205 -0
- package/dist/rebase-artifact-cleanup.d.ts +145 -0
- package/dist/rebase-artifact-cleanup.js +433 -0
- package/dist/retry-strategy.d.ts +189 -0
- package/dist/retry-strategy.js +283 -0
- package/dist/risk-detector.d.ts +108 -0
- package/dist/risk-detector.js +252 -0
- package/dist/rollback-utils.d.ts +76 -0
- package/dist/rollback-utils.js +104 -0
- package/dist/section-headings.d.ts +43 -0
- package/dist/section-headings.js +49 -0
- package/dist/spawn-escalation.d.ts +90 -0
- package/dist/spawn-escalation.js +253 -0
- package/dist/spawn-monitor.d.ts +229 -0
- package/dist/spawn-monitor.js +672 -0
- package/dist/spawn-recovery.d.ts +82 -0
- package/dist/spawn-recovery.js +298 -0
- package/dist/spawn-registry-schema.d.ts +98 -0
- package/dist/spawn-registry-schema.js +108 -0
- package/dist/spawn-registry-store.d.ts +146 -0
- package/dist/spawn-registry-store.js +273 -0
- package/dist/spawn-tree.d.ts +121 -0
- package/dist/spawn-tree.js +285 -0
- package/dist/stamp-status-validator.d.ts +84 -0
- package/dist/stamp-status-validator.js +134 -0
- package/dist/stamp-utils.d.ts +100 -0
- package/dist/stamp-utils.js +229 -0
- package/dist/state-machine.d.ts +26 -0
- package/dist/state-machine.js +83 -0
- package/dist/system-map-validator.d.ts +80 -0
- package/dist/system-map-validator.js +272 -0
- package/dist/telemetry.d.ts +80 -0
- package/dist/telemetry.js +213 -0
- package/dist/token-counter.d.ts +51 -0
- package/dist/token-counter.js +145 -0
- package/dist/usecases/get-dashboard-data.usecase.d.ts +52 -0
- package/dist/usecases/get-dashboard-data.usecase.js +61 -0
- package/dist/usecases/get-suggestions.usecase.d.ts +100 -0
- package/dist/usecases/get-suggestions.usecase.js +153 -0
- package/dist/user-normalizer.d.ts +41 -0
- package/dist/user-normalizer.js +141 -0
- package/dist/validators/phi-constants.d.ts +97 -0
- package/dist/validators/phi-constants.js +152 -0
- package/dist/validators/phi-scanner.d.ts +58 -0
- package/dist/validators/phi-scanner.js +215 -0
- package/dist/worktree-ownership.d.ts +50 -0
- package/dist/worktree-ownership.js +74 -0
- package/dist/worktree-scanner.d.ts +103 -0
- package/dist/worktree-scanner.js +168 -0
- package/dist/worktree-symlink.d.ts +99 -0
- package/dist/worktree-symlink.js +359 -0
- package/dist/wu-backlog-updater.d.ts +17 -0
- package/dist/wu-backlog-updater.js +37 -0
- package/dist/wu-checkpoint.d.ts +124 -0
- package/dist/wu-checkpoint.js +233 -0
- package/dist/wu-claim-helpers.d.ts +26 -0
- package/dist/wu-claim-helpers.js +63 -0
- package/dist/wu-claim-resume.d.ts +106 -0
- package/dist/wu-claim-resume.js +276 -0
- package/dist/wu-consistency-checker.d.ts +95 -0
- package/dist/wu-consistency-checker.js +567 -0
- package/dist/wu-constants.d.ts +1275 -0
- package/dist/wu-constants.js +1382 -0
- package/dist/wu-create-validators.d.ts +42 -0
- package/dist/wu-create-validators.js +93 -0
- package/dist/wu-done-branch-only.d.ts +63 -0
- package/dist/wu-done-branch-only.js +191 -0
- package/dist/wu-done-messages.d.ts +119 -0
- package/dist/wu-done-messages.js +185 -0
- package/dist/wu-done-pr.d.ts +72 -0
- package/dist/wu-done-pr.js +174 -0
- package/dist/wu-done-retry-helpers.d.ts +85 -0
- package/dist/wu-done-retry-helpers.js +172 -0
- package/dist/wu-done-ui.d.ts +37 -0
- package/dist/wu-done-ui.js +69 -0
- package/dist/wu-done-validators.d.ts +411 -0
- package/dist/wu-done-validators.js +1229 -0
- package/dist/wu-done-worktree.d.ts +182 -0
- package/dist/wu-done-worktree.js +1097 -0
- package/dist/wu-helpers.d.ts +128 -0
- package/dist/wu-helpers.js +248 -0
- package/dist/wu-lint.d.ts +70 -0
- package/dist/wu-lint.js +234 -0
- package/dist/wu-paths.d.ts +171 -0
- package/dist/wu-paths.js +178 -0
- package/dist/wu-preflight-validators.d.ts +86 -0
- package/dist/wu-preflight-validators.js +251 -0
- package/dist/wu-recovery.d.ts +138 -0
- package/dist/wu-recovery.js +341 -0
- package/dist/wu-repair-core.d.ts +131 -0
- package/dist/wu-repair-core.js +669 -0
- package/dist/wu-schema-normalization.d.ts +17 -0
- package/dist/wu-schema-normalization.js +82 -0
- package/dist/wu-schema.d.ts +793 -0
- package/dist/wu-schema.js +881 -0
- package/dist/wu-spawn-helpers.d.ts +121 -0
- package/dist/wu-spawn-helpers.js +271 -0
- package/dist/wu-spawn.d.ts +158 -0
- package/dist/wu-spawn.js +1306 -0
- package/dist/wu-state-schema.d.ts +213 -0
- package/dist/wu-state-schema.js +156 -0
- package/dist/wu-state-store.d.ts +264 -0
- package/dist/wu-state-store.js +691 -0
- package/dist/wu-status-transition.d.ts +63 -0
- package/dist/wu-status-transition.js +382 -0
- package/dist/wu-status-updater.d.ts +25 -0
- package/dist/wu-status-updater.js +116 -0
- package/dist/wu-transaction-collectors.d.ts +116 -0
- package/dist/wu-transaction-collectors.js +272 -0
- package/dist/wu-transaction.d.ts +170 -0
- package/dist/wu-transaction.js +273 -0
- package/dist/wu-validation-constants.d.ts +60 -0
- package/dist/wu-validation-constants.js +66 -0
- package/dist/wu-validation.d.ts +118 -0
- package/dist/wu-validation.js +243 -0
- package/dist/wu-validator.d.ts +62 -0
- package/dist/wu-validator.js +325 -0
- package/dist/wu-yaml-fixer.d.ts +97 -0
- package/dist/wu-yaml-fixer.js +264 -0
- package/dist/wu-yaml.d.ts +86 -0
- package/dist/wu-yaml.js +222 -0
- package/package.json +114 -0
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Lane Lock Module
|
|
4
|
+
*
|
|
5
|
+
* Provides atomic file-based locking to prevent TOCTOU (Time Of Check To Time Of Use)
|
|
6
|
+
* race conditions when multiple agents try to claim WUs in the same lane simultaneously.
|
|
7
|
+
*
|
|
8
|
+
* The lock file is created atomically using the 'wx' flag (exclusive create),
|
|
9
|
+
* which fails if the file already exists. This prevents the race condition where
|
|
10
|
+
* parallel agents could both read status.md before either updates it.
|
|
11
|
+
*
|
|
12
|
+
* Lock file location: .beacon/locks/<lane-kebab>.lock
|
|
13
|
+
* Lock file format: JSON with wuId, timestamp, agentSession, pid
|
|
14
|
+
*
|
|
15
|
+
* @see WU-1603 - Race condition fix for wu:claim
|
|
16
|
+
*/
|
|
17
|
+
export interface LockMetadata {
|
|
18
|
+
wuId: string;
|
|
19
|
+
timestamp: string;
|
|
20
|
+
agentSession: string | null;
|
|
21
|
+
pid: number;
|
|
22
|
+
lane: string;
|
|
23
|
+
}
|
|
24
|
+
interface LockResult {
|
|
25
|
+
acquired: boolean;
|
|
26
|
+
error: string | null;
|
|
27
|
+
existingLock: LockMetadata | null;
|
|
28
|
+
isStale: boolean;
|
|
29
|
+
}
|
|
30
|
+
interface UnlockResult {
|
|
31
|
+
released: boolean;
|
|
32
|
+
error: string | null;
|
|
33
|
+
notFound: boolean;
|
|
34
|
+
}
|
|
35
|
+
interface AuditedUnlockResult extends UnlockResult {
|
|
36
|
+
reason?: string;
|
|
37
|
+
forced?: boolean;
|
|
38
|
+
previousLock?: LockMetadata | null;
|
|
39
|
+
}
|
|
40
|
+
interface AcquireLockOptions {
|
|
41
|
+
agentSession?: string | null;
|
|
42
|
+
baseDir?: string | null;
|
|
43
|
+
}
|
|
44
|
+
interface ReleaseLockOptions {
|
|
45
|
+
wuId?: string | null;
|
|
46
|
+
baseDir?: string | null;
|
|
47
|
+
force?: boolean;
|
|
48
|
+
}
|
|
49
|
+
interface CheckLockOptions {
|
|
50
|
+
baseDir?: string | null;
|
|
51
|
+
}
|
|
52
|
+
interface AuditedUnlockOptions {
|
|
53
|
+
reason: string;
|
|
54
|
+
baseDir?: string | null;
|
|
55
|
+
force?: boolean;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Get the stale lock threshold in milliseconds.
|
|
59
|
+
*
|
|
60
|
+
* WU-1949: Default is 2 hours. Can be overridden via STALE_LOCK_THRESHOLD_HOURS env var.
|
|
61
|
+
*
|
|
62
|
+
* @returns {number} Threshold in milliseconds
|
|
63
|
+
*/
|
|
64
|
+
export declare function getStaleThresholdMs(): number;
|
|
65
|
+
/**
|
|
66
|
+
* @typedef {Object} LockMetadata
|
|
67
|
+
* @property {string} wuId - WU ID that holds the lock (e.g., "WU-123")
|
|
68
|
+
* @property {string} timestamp - ISO timestamp when lock was acquired
|
|
69
|
+
* @property {string|null} agentSession - Agent session ID if available
|
|
70
|
+
* @property {number} pid - Process ID that acquired the lock
|
|
71
|
+
* @property {string} lane - Original lane name
|
|
72
|
+
*/
|
|
73
|
+
/**
|
|
74
|
+
* @typedef {Object} LockResult
|
|
75
|
+
* @property {boolean} acquired - Whether lock was successfully acquired
|
|
76
|
+
* @property {string|null} error - Error message if acquisition failed
|
|
77
|
+
* @property {LockMetadata|null} existingLock - Existing lock metadata if lock already exists
|
|
78
|
+
* @property {boolean} isStale - Whether the existing lock is stale (>2h old by default)
|
|
79
|
+
*/
|
|
80
|
+
/**
|
|
81
|
+
* @typedef {Object} UnlockResult
|
|
82
|
+
* @property {boolean} released - Whether lock was successfully released
|
|
83
|
+
* @property {string|null} error - Error message if release failed
|
|
84
|
+
* @property {boolean} notFound - Whether lock file was not found
|
|
85
|
+
*/
|
|
86
|
+
/**
|
|
87
|
+
* Get the path to the locks directory
|
|
88
|
+
* @param {string} [baseDir] - Optional base directory (defaults to project root)
|
|
89
|
+
* @returns {string} Absolute path to locks directory
|
|
90
|
+
*/
|
|
91
|
+
export declare function getLocksDir(baseDir?: string | null): string;
|
|
92
|
+
/**
|
|
93
|
+
* Get the path to a lock file for a specific lane
|
|
94
|
+
* @param {string} lane - Lane name (e.g., "Operations: Tooling")
|
|
95
|
+
* @param {string} [baseDir] - Optional base directory
|
|
96
|
+
* @returns {string} Absolute path to lock file
|
|
97
|
+
*/
|
|
98
|
+
export declare function getLockFilePath(lane: string, baseDir?: string | null): string;
|
|
99
|
+
/**
|
|
100
|
+
* Check if a lock is stale (>2 hours old by default, configurable via env var)
|
|
101
|
+
*
|
|
102
|
+
* WU-1949: Reduced threshold from 24h to 2h. Zombie detection (PID check)
|
|
103
|
+
* remains the fast-path for immediate recovery when process has exited.
|
|
104
|
+
*
|
|
105
|
+
* @param {LockMetadata} metadata - Lock metadata
|
|
106
|
+
* @returns {boolean} True if lock is stale
|
|
107
|
+
*/
|
|
108
|
+
export declare function isLockStale(metadata: LockMetadata | null): boolean;
|
|
109
|
+
/**
|
|
110
|
+
* WU-1808: Check if a lock is a "zombie" (PID no longer running)
|
|
111
|
+
*
|
|
112
|
+
* A zombie lock occurs when the process that acquired the lock has crashed
|
|
113
|
+
* or exited without releasing it. This function checks if the process
|
|
114
|
+
* identified by the lock's PID is still running.
|
|
115
|
+
*
|
|
116
|
+
* @param {LockMetadata} metadata - Lock metadata
|
|
117
|
+
* @returns {boolean} True if lock is a zombie (PID not running)
|
|
118
|
+
*/
|
|
119
|
+
export declare function isZombieLock(metadata: LockMetadata | null): boolean;
|
|
120
|
+
/**
|
|
121
|
+
* Read lock metadata from a lock file
|
|
122
|
+
* @param {string} lockPath - Path to lock file
|
|
123
|
+
* @returns {LockMetadata|null} Lock metadata or null if file doesn't exist/is invalid
|
|
124
|
+
*/
|
|
125
|
+
export declare function readLockMetadata(lockPath: string): LockMetadata | null;
|
|
126
|
+
/**
|
|
127
|
+
* Acquire a lane lock atomically
|
|
128
|
+
*
|
|
129
|
+
* Uses the 'wx' flag for atomic file creation - fails if file already exists.
|
|
130
|
+
* This prevents TOCTOU race conditions where multiple agents could both
|
|
131
|
+
* read an empty lock state before either writes.
|
|
132
|
+
*
|
|
133
|
+
* @param {string} lane - Lane name (e.g., "Operations: Tooling")
|
|
134
|
+
* @param {string} wuId - WU ID being claimed (e.g., "WU-123")
|
|
135
|
+
* @param {Object} [options] - Optional parameters
|
|
136
|
+
* @param {string} [options.agentSession] - Agent session ID
|
|
137
|
+
* @param {string} [options.baseDir] - Base directory for lock files
|
|
138
|
+
* @returns {LockResult} Result of lock acquisition attempt
|
|
139
|
+
*/
|
|
140
|
+
export declare function acquireLaneLock(lane: string, wuId: string, options?: AcquireLockOptions): LockResult;
|
|
141
|
+
/**
|
|
142
|
+
* Release a lane lock
|
|
143
|
+
*
|
|
144
|
+
* @param {string} lane - Lane name (e.g., "Operations: Tooling")
|
|
145
|
+
* @param {Object} [options] - Optional parameters
|
|
146
|
+
* @param {string} [options.wuId] - WU ID to validate ownership (optional)
|
|
147
|
+
* @param {string} [options.baseDir] - Base directory for lock files
|
|
148
|
+
* @param {boolean} [options.force] - Force release even if not owner
|
|
149
|
+
* @returns {UnlockResult} Result of lock release attempt
|
|
150
|
+
*/
|
|
151
|
+
export declare function releaseLaneLock(lane: string, options?: ReleaseLockOptions): UnlockResult;
|
|
152
|
+
/**
|
|
153
|
+
* Check if a lane is currently locked
|
|
154
|
+
*
|
|
155
|
+
* @param {string} lane - Lane name (e.g., "Operations: Tooling")
|
|
156
|
+
* @param {Object} [options] - Optional parameters
|
|
157
|
+
* @param {string} [options.baseDir] - Base directory for lock files
|
|
158
|
+
* @returns {{ locked: boolean, metadata: LockMetadata|null, isStale: boolean }}
|
|
159
|
+
*/
|
|
160
|
+
export declare function checkLaneLock(lane: string, options?: CheckLockOptions): {
|
|
161
|
+
locked: boolean;
|
|
162
|
+
metadata: LockMetadata | null;
|
|
163
|
+
isStale: boolean;
|
|
164
|
+
};
|
|
165
|
+
/**
|
|
166
|
+
* Force-remove a stale lock with warning
|
|
167
|
+
*
|
|
168
|
+
* @param {string} lane - Lane name (e.g., "Operations: Tooling")
|
|
169
|
+
* @param {Object} [options] - Optional parameters
|
|
170
|
+
* @param {string} [options.baseDir] - Base directory for lock files
|
|
171
|
+
* @returns {UnlockResult} Result of forced removal
|
|
172
|
+
*/
|
|
173
|
+
export declare function forceRemoveStaleLock(lane: string, options?: CheckLockOptions): UnlockResult;
|
|
174
|
+
/**
|
|
175
|
+
* Get all current lane locks
|
|
176
|
+
*
|
|
177
|
+
* @param {Object} [options] - Optional parameters
|
|
178
|
+
* @param {string} [options.baseDir] - Base directory for lock files
|
|
179
|
+
* @returns {Map<string, LockMetadata>} Map of lane name to lock metadata
|
|
180
|
+
*/
|
|
181
|
+
export declare function getAllLaneLocks(options?: CheckLockOptions): Map<string, LockMetadata>;
|
|
182
|
+
/**
|
|
183
|
+
* @typedef {Object} AuditedUnlockResult
|
|
184
|
+
* @property {boolean} released - Whether lock was successfully released
|
|
185
|
+
* @property {string|null} error - Error message if release failed
|
|
186
|
+
* @property {boolean} notFound - Whether lock file was not found
|
|
187
|
+
* @property {string} [reason] - The provided reason for unlocking
|
|
188
|
+
* @property {boolean} [forced] - Whether --force was used
|
|
189
|
+
* @property {LockMetadata|null} [previousLock] - Metadata of the removed lock
|
|
190
|
+
*/
|
|
191
|
+
/**
|
|
192
|
+
* WU-1808: Audited unlock command for operators to safely clear lane locks
|
|
193
|
+
*
|
|
194
|
+
* This function provides a dedicated command for operators to clear locks with
|
|
195
|
+
* proper audit logging. It follows a safety-first approach:
|
|
196
|
+
*
|
|
197
|
+
* - Zombie locks (PID not running): Can be unlocked without --force
|
|
198
|
+
* - Stale locks (>2h old by default): Can be unlocked without --force
|
|
199
|
+
* - Active locks (recent, PID running): Require --force to unlock
|
|
200
|
+
*
|
|
201
|
+
* All unlocks require a reason parameter for audit purposes.
|
|
202
|
+
*
|
|
203
|
+
* @param {string} lane - Lane name (e.g., "Operations: Tooling")
|
|
204
|
+
* @param {Object} options - Required options
|
|
205
|
+
* @param {string} options.reason - Reason for unlocking (required for audit)
|
|
206
|
+
* @param {string} [options.baseDir] - Base directory for lock files
|
|
207
|
+
* @param {boolean} [options.force] - Force unlock even if lock is active
|
|
208
|
+
* @returns {AuditedUnlockResult} Result of audited unlock attempt
|
|
209
|
+
*/
|
|
210
|
+
export declare function auditedUnlock(lane: string, options: AuditedUnlockOptions): AuditedUnlockResult;
|
|
211
|
+
export {};
|
|
@@ -0,0 +1,474 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Lane Lock Module
|
|
4
|
+
*
|
|
5
|
+
* Provides atomic file-based locking to prevent TOCTOU (Time Of Check To Time Of Use)
|
|
6
|
+
* race conditions when multiple agents try to claim WUs in the same lane simultaneously.
|
|
7
|
+
*
|
|
8
|
+
* The lock file is created atomically using the 'wx' flag (exclusive create),
|
|
9
|
+
* which fails if the file already exists. This prevents the race condition where
|
|
10
|
+
* parallel agents could both read status.md before either updates it.
|
|
11
|
+
*
|
|
12
|
+
* Lock file location: .beacon/locks/<lane-kebab>.lock
|
|
13
|
+
* Lock file format: JSON with wuId, timestamp, agentSession, pid
|
|
14
|
+
*
|
|
15
|
+
* @see WU-1603 - Race condition fix for wu:claim
|
|
16
|
+
*/
|
|
17
|
+
import { openSync, closeSync, writeFileSync, readFileSync, unlinkSync, existsSync, mkdirSync, } from 'node:fs';
|
|
18
|
+
import path from 'node:path';
|
|
19
|
+
import { toKebab } from './wu-constants.js';
|
|
20
|
+
import { getProjectRoot } from './wu-constants.js';
|
|
21
|
+
/** Log prefix for lane-lock messages */
|
|
22
|
+
const LOG_PREFIX = '[lane-lock]';
|
|
23
|
+
/** Directory for lock files relative to project root */
|
|
24
|
+
const LOCKS_DIR = '.beacon/locks';
|
|
25
|
+
/** Default stale lock threshold in hours (WU-1949: reduced from 24h to 2h) */
|
|
26
|
+
const DEFAULT_STALE_LOCK_THRESHOLD_HOURS = 2;
|
|
27
|
+
/**
|
|
28
|
+
* Get the stale lock threshold in milliseconds.
|
|
29
|
+
*
|
|
30
|
+
* WU-1949: Default is 2 hours. Can be overridden via STALE_LOCK_THRESHOLD_HOURS env var.
|
|
31
|
+
*
|
|
32
|
+
* @returns {number} Threshold in milliseconds
|
|
33
|
+
*/
|
|
34
|
+
export function getStaleThresholdMs() {
|
|
35
|
+
const envValue = process.env.STALE_LOCK_THRESHOLD_HOURS;
|
|
36
|
+
if (envValue) {
|
|
37
|
+
const hours = parseFloat(envValue);
|
|
38
|
+
if (!Number.isNaN(hours) && hours > 0) {
|
|
39
|
+
return hours * 60 * 60 * 1000;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return DEFAULT_STALE_LOCK_THRESHOLD_HOURS * 60 * 60 * 1000;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* @typedef {Object} LockMetadata
|
|
46
|
+
* @property {string} wuId - WU ID that holds the lock (e.g., "WU-123")
|
|
47
|
+
* @property {string} timestamp - ISO timestamp when lock was acquired
|
|
48
|
+
* @property {string|null} agentSession - Agent session ID if available
|
|
49
|
+
* @property {number} pid - Process ID that acquired the lock
|
|
50
|
+
* @property {string} lane - Original lane name
|
|
51
|
+
*/
|
|
52
|
+
/**
|
|
53
|
+
* @typedef {Object} LockResult
|
|
54
|
+
* @property {boolean} acquired - Whether lock was successfully acquired
|
|
55
|
+
* @property {string|null} error - Error message if acquisition failed
|
|
56
|
+
* @property {LockMetadata|null} existingLock - Existing lock metadata if lock already exists
|
|
57
|
+
* @property {boolean} isStale - Whether the existing lock is stale (>2h old by default)
|
|
58
|
+
*/
|
|
59
|
+
/**
|
|
60
|
+
* @typedef {Object} UnlockResult
|
|
61
|
+
* @property {boolean} released - Whether lock was successfully released
|
|
62
|
+
* @property {string|null} error - Error message if release failed
|
|
63
|
+
* @property {boolean} notFound - Whether lock file was not found
|
|
64
|
+
*/
|
|
65
|
+
/**
|
|
66
|
+
* Get the path to the locks directory
|
|
67
|
+
* @param {string} [baseDir] - Optional base directory (defaults to project root)
|
|
68
|
+
* @returns {string} Absolute path to locks directory
|
|
69
|
+
*/
|
|
70
|
+
export function getLocksDir(baseDir = null) {
|
|
71
|
+
const projectRoot = baseDir || getProjectRoot(import.meta.url);
|
|
72
|
+
return path.join(projectRoot, LOCKS_DIR);
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Get the path to a lock file for a specific lane
|
|
76
|
+
* @param {string} lane - Lane name (e.g., "Operations: Tooling")
|
|
77
|
+
* @param {string} [baseDir] - Optional base directory
|
|
78
|
+
* @returns {string} Absolute path to lock file
|
|
79
|
+
*/
|
|
80
|
+
export function getLockFilePath(lane, baseDir = null) {
|
|
81
|
+
const laneKebab = toKebab(lane);
|
|
82
|
+
const locksDir = getLocksDir(baseDir);
|
|
83
|
+
return path.join(locksDir, `${laneKebab}.lock`);
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Ensure the locks directory exists
|
|
87
|
+
* @param {string} [baseDir] - Optional base directory
|
|
88
|
+
*/
|
|
89
|
+
function ensureLocksDir(baseDir = null) {
|
|
90
|
+
const locksDir = getLocksDir(baseDir);
|
|
91
|
+
if (!existsSync(locksDir)) {
|
|
92
|
+
mkdirSync(locksDir, { recursive: true });
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Check if a lock is stale (>2 hours old by default, configurable via env var)
|
|
97
|
+
*
|
|
98
|
+
* WU-1949: Reduced threshold from 24h to 2h. Zombie detection (PID check)
|
|
99
|
+
* remains the fast-path for immediate recovery when process has exited.
|
|
100
|
+
*
|
|
101
|
+
* @param {LockMetadata} metadata - Lock metadata
|
|
102
|
+
* @returns {boolean} True if lock is stale
|
|
103
|
+
*/
|
|
104
|
+
export function isLockStale(metadata) {
|
|
105
|
+
if (!metadata || !metadata.timestamp) {
|
|
106
|
+
return true; // Invalid metadata is considered stale
|
|
107
|
+
}
|
|
108
|
+
const lockTime = new Date(metadata.timestamp).getTime();
|
|
109
|
+
const now = Date.now();
|
|
110
|
+
return now - lockTime > getStaleThresholdMs();
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* WU-1808: Check if a lock is a "zombie" (PID no longer running)
|
|
114
|
+
*
|
|
115
|
+
* A zombie lock occurs when the process that acquired the lock has crashed
|
|
116
|
+
* or exited without releasing it. This function checks if the process
|
|
117
|
+
* identified by the lock's PID is still running.
|
|
118
|
+
*
|
|
119
|
+
* @param {LockMetadata} metadata - Lock metadata
|
|
120
|
+
* @returns {boolean} True if lock is a zombie (PID not running)
|
|
121
|
+
*/
|
|
122
|
+
export function isZombieLock(metadata) {
|
|
123
|
+
if (!metadata || typeof metadata.pid !== 'number') {
|
|
124
|
+
return true; // Invalid metadata is considered zombie
|
|
125
|
+
}
|
|
126
|
+
// Check if process is running by sending signal 0
|
|
127
|
+
// This doesn't actually send a signal, but checks if the process exists
|
|
128
|
+
try {
|
|
129
|
+
process.kill(metadata.pid, 0);
|
|
130
|
+
return false; // Process exists
|
|
131
|
+
}
|
|
132
|
+
catch {
|
|
133
|
+
// ESRCH = no such process (zombie)
|
|
134
|
+
// EPERM = process exists but we don't have permission (not zombie)
|
|
135
|
+
return true;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Read lock metadata from a lock file
|
|
140
|
+
* @param {string} lockPath - Path to lock file
|
|
141
|
+
* @returns {LockMetadata|null} Lock metadata or null if file doesn't exist/is invalid
|
|
142
|
+
*/
|
|
143
|
+
export function readLockMetadata(lockPath) {
|
|
144
|
+
try {
|
|
145
|
+
if (!existsSync(lockPath)) {
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
const content = readFileSync(lockPath, { encoding: 'utf-8' });
|
|
149
|
+
return JSON.parse(content);
|
|
150
|
+
}
|
|
151
|
+
catch {
|
|
152
|
+
// Invalid JSON or read error - treat as no lock
|
|
153
|
+
return null;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* Acquire a lane lock atomically
|
|
158
|
+
*
|
|
159
|
+
* Uses the 'wx' flag for atomic file creation - fails if file already exists.
|
|
160
|
+
* This prevents TOCTOU race conditions where multiple agents could both
|
|
161
|
+
* read an empty lock state before either writes.
|
|
162
|
+
*
|
|
163
|
+
* @param {string} lane - Lane name (e.g., "Operations: Tooling")
|
|
164
|
+
* @param {string} wuId - WU ID being claimed (e.g., "WU-123")
|
|
165
|
+
* @param {Object} [options] - Optional parameters
|
|
166
|
+
* @param {string} [options.agentSession] - Agent session ID
|
|
167
|
+
* @param {string} [options.baseDir] - Base directory for lock files
|
|
168
|
+
* @returns {LockResult} Result of lock acquisition attempt
|
|
169
|
+
*/
|
|
170
|
+
// eslint-disable-next-line sonarjs/cognitive-complexity -- WU-1808: Added zombie lock detection increases complexity but all paths are necessary
|
|
171
|
+
export function acquireLaneLock(lane, wuId, options = {}) {
|
|
172
|
+
const { agentSession = null, baseDir = null } = options;
|
|
173
|
+
try {
|
|
174
|
+
ensureLocksDir(baseDir);
|
|
175
|
+
const lockPath = getLockFilePath(lane, baseDir);
|
|
176
|
+
// Build lock metadata
|
|
177
|
+
const metadata = {
|
|
178
|
+
wuId,
|
|
179
|
+
timestamp: new Date().toISOString(),
|
|
180
|
+
agentSession,
|
|
181
|
+
pid: process.pid,
|
|
182
|
+
lane,
|
|
183
|
+
};
|
|
184
|
+
try {
|
|
185
|
+
// Attempt atomic file creation with 'wx' flag
|
|
186
|
+
// 'wx' = write exclusive - fails if file exists
|
|
187
|
+
const fd = openSync(lockPath, 'wx');
|
|
188
|
+
// Write metadata and close
|
|
189
|
+
writeFileSync(lockPath, JSON.stringify(metadata, null, 2), { encoding: 'utf-8' });
|
|
190
|
+
closeSync(fd);
|
|
191
|
+
console.log(`${LOG_PREFIX} Acquired lane lock for "${lane}" (${wuId})`);
|
|
192
|
+
return {
|
|
193
|
+
acquired: true,
|
|
194
|
+
error: null,
|
|
195
|
+
existingLock: null,
|
|
196
|
+
isStale: false,
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
catch (err) {
|
|
200
|
+
// File already exists - check if it's our lock or another agent's
|
|
201
|
+
if (err.code === 'EEXIST') {
|
|
202
|
+
const existingLock = readLockMetadata(lockPath);
|
|
203
|
+
const stale = existingLock ? isLockStale(existingLock) : true;
|
|
204
|
+
const zombie = existingLock ? isZombieLock(existingLock) : true;
|
|
205
|
+
// Check if it's the same WU (re-claim attempt)
|
|
206
|
+
if (existingLock && existingLock.wuId === wuId) {
|
|
207
|
+
console.log(`${LOG_PREFIX} Lock already held by same WU (${wuId})`);
|
|
208
|
+
return {
|
|
209
|
+
acquired: true, // Allow re-claim of same WU
|
|
210
|
+
error: null,
|
|
211
|
+
existingLock,
|
|
212
|
+
isStale: stale,
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
// WU-1808: Auto-clear zombie locks (PID no longer running)
|
|
216
|
+
// This allows recovery from crashed wu:claim processes
|
|
217
|
+
if (zombie) {
|
|
218
|
+
console.warn(`${LOG_PREFIX} Detected zombie lock for "${lane}" (PID ${existingLock?.pid} not running)`);
|
|
219
|
+
console.warn(`${LOG_PREFIX} Previous owner: ${existingLock?.wuId}`);
|
|
220
|
+
console.warn(`${LOG_PREFIX} Lock timestamp: ${existingLock?.timestamp}`);
|
|
221
|
+
console.warn(`${LOG_PREFIX} Auto-clearing zombie lock...`);
|
|
222
|
+
// Remove the zombie lock
|
|
223
|
+
try {
|
|
224
|
+
unlinkSync(lockPath);
|
|
225
|
+
}
|
|
226
|
+
catch {
|
|
227
|
+
// Ignore errors - file might have been removed by another process
|
|
228
|
+
}
|
|
229
|
+
// Retry acquisition
|
|
230
|
+
return acquireLaneLock(lane, wuId, options);
|
|
231
|
+
}
|
|
232
|
+
return {
|
|
233
|
+
acquired: false,
|
|
234
|
+
error: existingLock
|
|
235
|
+
? `Lane "${lane}" is locked by ${existingLock.wuId} (since ${existingLock.timestamp})`
|
|
236
|
+
: `Lane "${lane}" has an invalid lock file`,
|
|
237
|
+
existingLock,
|
|
238
|
+
isStale: stale,
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
// Other error (permissions, etc.)
|
|
242
|
+
throw err;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
catch (err) {
|
|
246
|
+
const errMessage = err instanceof Error ? err.message : String(err);
|
|
247
|
+
return {
|
|
248
|
+
acquired: false,
|
|
249
|
+
error: `Failed to acquire lane lock: ${errMessage}`,
|
|
250
|
+
existingLock: null,
|
|
251
|
+
isStale: false,
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
/**
|
|
256
|
+
* Release a lane lock
|
|
257
|
+
*
|
|
258
|
+
* @param {string} lane - Lane name (e.g., "Operations: Tooling")
|
|
259
|
+
* @param {Object} [options] - Optional parameters
|
|
260
|
+
* @param {string} [options.wuId] - WU ID to validate ownership (optional)
|
|
261
|
+
* @param {string} [options.baseDir] - Base directory for lock files
|
|
262
|
+
* @param {boolean} [options.force] - Force release even if not owner
|
|
263
|
+
* @returns {UnlockResult} Result of lock release attempt
|
|
264
|
+
*/
|
|
265
|
+
export function releaseLaneLock(lane, options = {}) {
|
|
266
|
+
const { wuId = null, baseDir = null, force = false } = options;
|
|
267
|
+
try {
|
|
268
|
+
const lockPath = getLockFilePath(lane, baseDir);
|
|
269
|
+
if (!existsSync(lockPath)) {
|
|
270
|
+
// Lock doesn't exist - not an error, just nothing to release
|
|
271
|
+
return {
|
|
272
|
+
released: true,
|
|
273
|
+
error: null,
|
|
274
|
+
notFound: true,
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
// Validate ownership if wuId provided
|
|
278
|
+
if (wuId && !force) {
|
|
279
|
+
const existingLock = readLockMetadata(lockPath);
|
|
280
|
+
if (existingLock && existingLock.wuId !== wuId) {
|
|
281
|
+
return {
|
|
282
|
+
released: false,
|
|
283
|
+
error: `Cannot release lock: owned by ${existingLock.wuId}, not ${wuId}`,
|
|
284
|
+
notFound: false,
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
// Remove the lock file
|
|
289
|
+
unlinkSync(lockPath);
|
|
290
|
+
console.log(`${LOG_PREFIX} Released lane lock for "${lane}"`);
|
|
291
|
+
return {
|
|
292
|
+
released: true,
|
|
293
|
+
error: null,
|
|
294
|
+
notFound: false,
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
catch (err) {
|
|
298
|
+
const errMessage = err instanceof Error ? err.message : String(err);
|
|
299
|
+
return {
|
|
300
|
+
released: false,
|
|
301
|
+
error: `Failed to release lane lock: ${errMessage}`,
|
|
302
|
+
notFound: false,
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
/**
|
|
307
|
+
* Check if a lane is currently locked
|
|
308
|
+
*
|
|
309
|
+
* @param {string} lane - Lane name (e.g., "Operations: Tooling")
|
|
310
|
+
* @param {Object} [options] - Optional parameters
|
|
311
|
+
* @param {string} [options.baseDir] - Base directory for lock files
|
|
312
|
+
* @returns {{ locked: boolean, metadata: LockMetadata|null, isStale: boolean }}
|
|
313
|
+
*/
|
|
314
|
+
export function checkLaneLock(lane, options = {}) {
|
|
315
|
+
const { baseDir = null } = options;
|
|
316
|
+
const lockPath = getLockFilePath(lane, baseDir);
|
|
317
|
+
const metadata = readLockMetadata(lockPath);
|
|
318
|
+
if (!metadata) {
|
|
319
|
+
return {
|
|
320
|
+
locked: false,
|
|
321
|
+
metadata: null,
|
|
322
|
+
isStale: false,
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
return {
|
|
326
|
+
locked: true,
|
|
327
|
+
metadata,
|
|
328
|
+
isStale: isLockStale(metadata),
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
/**
|
|
332
|
+
* Force-remove a stale lock with warning
|
|
333
|
+
*
|
|
334
|
+
* @param {string} lane - Lane name (e.g., "Operations: Tooling")
|
|
335
|
+
* @param {Object} [options] - Optional parameters
|
|
336
|
+
* @param {string} [options.baseDir] - Base directory for lock files
|
|
337
|
+
* @returns {UnlockResult} Result of forced removal
|
|
338
|
+
*/
|
|
339
|
+
export function forceRemoveStaleLock(lane, options = {}) {
|
|
340
|
+
const { baseDir = null } = options;
|
|
341
|
+
const lockPath = getLockFilePath(lane, baseDir);
|
|
342
|
+
const existingLock = readLockMetadata(lockPath);
|
|
343
|
+
if (!existingLock) {
|
|
344
|
+
return {
|
|
345
|
+
released: true,
|
|
346
|
+
error: null,
|
|
347
|
+
notFound: true,
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
if (!isLockStale(existingLock)) {
|
|
351
|
+
return {
|
|
352
|
+
released: false,
|
|
353
|
+
error: `Cannot force-remove: lock is not stale (${existingLock.wuId} since ${existingLock.timestamp})`,
|
|
354
|
+
notFound: false,
|
|
355
|
+
};
|
|
356
|
+
}
|
|
357
|
+
console.warn(`${LOG_PREFIX} ⚠️ Force-removing stale lock for "${lane}"`);
|
|
358
|
+
console.warn(`${LOG_PREFIX} Previous owner: ${existingLock.wuId}`);
|
|
359
|
+
console.warn(`${LOG_PREFIX} Lock timestamp: ${existingLock.timestamp}`);
|
|
360
|
+
return releaseLaneLock(lane, { baseDir, force: true });
|
|
361
|
+
}
|
|
362
|
+
/**
|
|
363
|
+
* Get all current lane locks
|
|
364
|
+
*
|
|
365
|
+
* @param {Object} [options] - Optional parameters
|
|
366
|
+
* @param {string} [options.baseDir] - Base directory for lock files
|
|
367
|
+
* @returns {Map<string, LockMetadata>} Map of lane name to lock metadata
|
|
368
|
+
*/
|
|
369
|
+
export function getAllLaneLocks(options = {}) {
|
|
370
|
+
const { baseDir = null } = options;
|
|
371
|
+
const locksDir = getLocksDir(baseDir);
|
|
372
|
+
const locks = new Map();
|
|
373
|
+
if (!existsSync(locksDir)) {
|
|
374
|
+
return locks;
|
|
375
|
+
}
|
|
376
|
+
try {
|
|
377
|
+
const files = require('node:fs').readdirSync(locksDir);
|
|
378
|
+
for (const file of files) {
|
|
379
|
+
if (!file.endsWith('.lock'))
|
|
380
|
+
continue;
|
|
381
|
+
const lockPath = path.join(locksDir, file);
|
|
382
|
+
const metadata = readLockMetadata(lockPath);
|
|
383
|
+
if (metadata && metadata.lane) {
|
|
384
|
+
locks.set(metadata.lane, metadata);
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
catch {
|
|
389
|
+
// Ignore errors reading directory
|
|
390
|
+
}
|
|
391
|
+
return locks;
|
|
392
|
+
}
|
|
393
|
+
/**
|
|
394
|
+
* @typedef {Object} AuditedUnlockResult
|
|
395
|
+
* @property {boolean} released - Whether lock was successfully released
|
|
396
|
+
* @property {string|null} error - Error message if release failed
|
|
397
|
+
* @property {boolean} notFound - Whether lock file was not found
|
|
398
|
+
* @property {string} [reason] - The provided reason for unlocking
|
|
399
|
+
* @property {boolean} [forced] - Whether --force was used
|
|
400
|
+
* @property {LockMetadata|null} [previousLock] - Metadata of the removed lock
|
|
401
|
+
*/
|
|
402
|
+
/**
|
|
403
|
+
* WU-1808: Audited unlock command for operators to safely clear lane locks
|
|
404
|
+
*
|
|
405
|
+
* This function provides a dedicated command for operators to clear locks with
|
|
406
|
+
* proper audit logging. It follows a safety-first approach:
|
|
407
|
+
*
|
|
408
|
+
* - Zombie locks (PID not running): Can be unlocked without --force
|
|
409
|
+
* - Stale locks (>2h old by default): Can be unlocked without --force
|
|
410
|
+
* - Active locks (recent, PID running): Require --force to unlock
|
|
411
|
+
*
|
|
412
|
+
* All unlocks require a reason parameter for audit purposes.
|
|
413
|
+
*
|
|
414
|
+
* @param {string} lane - Lane name (e.g., "Operations: Tooling")
|
|
415
|
+
* @param {Object} options - Required options
|
|
416
|
+
* @param {string} options.reason - Reason for unlocking (required for audit)
|
|
417
|
+
* @param {string} [options.baseDir] - Base directory for lock files
|
|
418
|
+
* @param {boolean} [options.force] - Force unlock even if lock is active
|
|
419
|
+
* @returns {AuditedUnlockResult} Result of audited unlock attempt
|
|
420
|
+
*/
|
|
421
|
+
export function auditedUnlock(lane, options) {
|
|
422
|
+
const { reason, baseDir = null, force = false } = options;
|
|
423
|
+
// Require reason for audit trail
|
|
424
|
+
if (!reason) {
|
|
425
|
+
return {
|
|
426
|
+
released: false,
|
|
427
|
+
error: 'Reason is required for audited unlock. Use --reason "<text>"',
|
|
428
|
+
notFound: false,
|
|
429
|
+
};
|
|
430
|
+
}
|
|
431
|
+
const lockPath = getLockFilePath(lane, baseDir);
|
|
432
|
+
const existingLock = readLockMetadata(lockPath);
|
|
433
|
+
// Handle non-existent lock
|
|
434
|
+
if (!existingLock) {
|
|
435
|
+
return {
|
|
436
|
+
released: true,
|
|
437
|
+
error: null,
|
|
438
|
+
notFound: true,
|
|
439
|
+
reason,
|
|
440
|
+
};
|
|
441
|
+
}
|
|
442
|
+
const stale = isLockStale(existingLock);
|
|
443
|
+
const zombie = isZombieLock(existingLock);
|
|
444
|
+
const safeToRemove = stale || zombie;
|
|
445
|
+
// If lock is active (not stale, not zombie), require --force
|
|
446
|
+
if (!safeToRemove && !force) {
|
|
447
|
+
return {
|
|
448
|
+
released: false,
|
|
449
|
+
error: `Cannot unlock active lock for "${lane}" (${existingLock.wuId}).\n` +
|
|
450
|
+
`Lock is recent (${existingLock.timestamp}) and PID ${existingLock.pid} is running.\n` +
|
|
451
|
+
`Use --force to override (emergency only).`,
|
|
452
|
+
notFound: false,
|
|
453
|
+
previousLock: existingLock,
|
|
454
|
+
};
|
|
455
|
+
}
|
|
456
|
+
// Log the unlock for audit purposes
|
|
457
|
+
const unlockType = force ? 'FORCED' : zombie ? 'ZOMBIE' : 'STALE';
|
|
458
|
+
console.log(`${LOG_PREFIX} Audited unlock (${unlockType}) for "${lane}"`);
|
|
459
|
+
console.log(`${LOG_PREFIX} Previous owner: ${existingLock.wuId}`);
|
|
460
|
+
console.log(`${LOG_PREFIX} Lock timestamp: ${existingLock.timestamp}`);
|
|
461
|
+
console.log(`${LOG_PREFIX} Lock PID: ${existingLock.pid}`);
|
|
462
|
+
console.log(`${LOG_PREFIX} Reason: ${reason}`);
|
|
463
|
+
if (force && !safeToRemove) {
|
|
464
|
+
console.warn(`${LOG_PREFIX} ⚠️ WARNING: Forced unlock of active lock!`);
|
|
465
|
+
}
|
|
466
|
+
// Release the lock
|
|
467
|
+
const releaseResult = releaseLaneLock(lane, { baseDir, force: true });
|
|
468
|
+
return {
|
|
469
|
+
...releaseResult,
|
|
470
|
+
reason,
|
|
471
|
+
forced: force && !safeToRemove,
|
|
472
|
+
previousLock: existingLock,
|
|
473
|
+
};
|
|
474
|
+
}
|