pi-codex-marketplace 0.1.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.
Files changed (58) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +134 -0
  3. package/extensions/pi/git-registration.ts +138 -0
  4. package/extensions/pi/index.ts +293 -0
  5. package/extensions/pi/installation.ts +90 -0
  6. package/extensions/pi/journal.ts +80 -0
  7. package/extensions/pi/lifecycle.ts +285 -0
  8. package/extensions/pi/registration.ts +143 -0
  9. package/extensions/pi/scope-overrides.ts +170 -0
  10. package/package.json +60 -0
  11. package/src/barrier/global-barrier.ts +105 -0
  12. package/src/bridge-state/atomic.ts +237 -0
  13. package/src/bridge-state/index.ts +5 -0
  14. package/src/bridge-state/migrate.ts +261 -0
  15. package/src/bridge-state/paths.ts +75 -0
  16. package/src/bridge-state/repair.ts +185 -0
  17. package/src/bridge-state/schema.ts +70 -0
  18. package/src/bridge-state/store.ts +489 -0
  19. package/src/bridge-state/types.ts +170 -0
  20. package/src/cache/index.ts +2 -0
  21. package/src/cache/paths.ts +42 -0
  22. package/src/cache/source-cache.ts +365 -0
  23. package/src/compatibility/index.ts +1 -0
  24. package/src/compatibility/profile.ts +328 -0
  25. package/src/installation/flow.ts +443 -0
  26. package/src/installation/index.ts +1 -0
  27. package/src/installation/inspection.ts +129 -0
  28. package/src/journal/active-chains.ts +99 -0
  29. package/src/journal/index.ts +3 -0
  30. package/src/journal/journal.ts +215 -0
  31. package/src/journal/types.ts +49 -0
  32. package/src/lifecycle/index.ts +5 -0
  33. package/src/lifecycle/rebind.ts +290 -0
  34. package/src/lifecycle/refresh.ts +407 -0
  35. package/src/lifecycle/removal.ts +457 -0
  36. package/src/lifecycle/update-plan.ts +222 -0
  37. package/src/lifecycle/update.ts +303 -0
  38. package/src/projection/collision.ts +120 -0
  39. package/src/projection/effective-state.ts +182 -0
  40. package/src/projection/index.ts +4 -0
  41. package/src/projection/overrides.ts +230 -0
  42. package/src/projection/project.ts +359 -0
  43. package/src/reconciliation/startup.ts +144 -0
  44. package/src/registration/budget.ts +28 -0
  45. package/src/registration/catalog.ts +224 -0
  46. package/src/registration/contained.ts +140 -0
  47. package/src/registration/fence.ts +86 -0
  48. package/src/registration/findings.ts +188 -0
  49. package/src/registration/flow.ts +619 -0
  50. package/src/registration/git-acquisition.ts +481 -0
  51. package/src/registration/git-flow.ts +654 -0
  52. package/src/registration/git-locator.ts +380 -0
  53. package/src/registration/git-selector.ts +279 -0
  54. package/src/registration/index.ts +16 -0
  55. package/src/registration/receipt.ts +305 -0
  56. package/src/registration/registration.ts +102 -0
  57. package/src/registration/snapshot.ts +382 -0
  58. package/src/registration/source-key.ts +111 -0
@@ -0,0 +1,170 @@
1
+ /**
2
+ * TUI flow for Scope Override management and the Effective State / projection view.
3
+ *
4
+ * Scope Overrides suppress inherited Global Scope records without modifying them: a
5
+ * Registration override suppresses its marketplace subtree, an Installation override
6
+ * suppresses only that single Plugin, and removing either reveals the inherited record again.
7
+ * The Effective State view marks every record's participating source and suppression reason,
8
+ * and lists Projected Skills with their collision outcome and Skill Availability.
9
+ */
10
+
11
+ import type { ExtensionCommandContext, ExtensionUIContext } from '@earendil-works/pi-coding-agent';
12
+
13
+ import { readBridgeState } from '../../src/bridge-state/store.js';
14
+ import type { BridgeState } from '../../src/bridge-state/types.js';
15
+ import { computeEffectiveState, type EffectiveState } from '../../src/projection/effective-state.js';
16
+ import { createScopeOverride, removeScopeOverride, type OverrideKind, type OverrideOutcome } from '../../src/projection/overrides.js';
17
+ import { projectEffectiveState } from '../../src/projection/project.js';
18
+ import { reportOutcome } from './registration.js';
19
+
20
+ function quote(value: string): string {
21
+ return JSON.stringify(value);
22
+ }
23
+
24
+ /** One row of the inherited-Global listing shown before creating an override. */
25
+ export interface InheritedRecordRow {
26
+ label: string;
27
+ kind: OverrideKind;
28
+ targetId: string;
29
+ /** true when a Project Scope Override currently suppresses this record. */
30
+ suppressedByOverride: boolean;
31
+ /** true when an enabled project Installation supersedes this record by precedence. */
32
+ supersededByProject: boolean;
33
+ selectable: boolean;
34
+ }
35
+
36
+ /**
37
+ * Build the inherited Global Scope listing: every global Registration and enabled global
38
+ * Installation annotated with why it is not participating in Effective State.
39
+ */
40
+ export function inheritedRecordRows(
41
+ global: BridgeState,
42
+ project: BridgeState,
43
+ projectTrusted: boolean,
44
+ ): InheritedRecordRow[] {
45
+ const effective = computeEffectiveState(global, project, { projectTrusted });
46
+ const suppressedKinds = new Map(effective.suppressed.map((item) => [`${item.kind}/${item.targetId}`, item.reason]));
47
+ const rows: InheritedRecordRow[] = [];
48
+ for (const registration of global.registrations) {
49
+ const reason = suppressedKinds.get(`registration/${registration.id}`);
50
+ rows.push({
51
+ kind: 'registration',
52
+ targetId: registration.id,
53
+ label: `${quote(registration.alias ?? registration.marketplaceName ?? registration.id)} · Registration ${registration.id.slice(0, 8)}…${reason === 'scope-override-registration' ? ' — 已抑制(子樹)' : ''}`,
54
+ suppressedByOverride: reason === 'scope-override-registration',
55
+ supersededByProject: false,
56
+ selectable: true,
57
+ });
58
+ }
59
+ for (const installation of global.installations) {
60
+ if (installation.installationState !== 'enabled') continue; // disabled never participates
61
+ const reason = suppressedKinds.get(`installation/${installation.id}`);
62
+ rows.push({
63
+ kind: 'installation',
64
+ targetId: installation.id,
65
+ label: `${quote(installation.manifestName ?? installation.pluginId)} · Installation ${installation.id.slice(0, 8)}…${
66
+ reason === 'scope-override-installation' ? ' — 已抑制(單一 Plugin)'
67
+ : reason === 'scope-override-registration' ? ' — 已抑制(隸屬被覆蓋的 Registration 子樹)'
68
+ : reason === 'project-precedence' ? ' — 由 Project 同名 Plugin 優先取代' : ''
69
+ }`,
70
+ suppressedByOverride: reason === 'scope-override-installation' || reason === 'scope-override-registration',
71
+ supersededByProject: reason === 'project-precedence',
72
+ selectable: true,
73
+ });
74
+ }
75
+ return rows;
76
+ }
77
+
78
+ /** Compact multi-line summary of one projection result for disclosure / notification. */
79
+ export function formatProjectionSummary(state: EffectiveState, plugins: ReturnType<typeof projectEffectiveState>['plugins'], findings: ReturnType<typeof projectEffectiveState>['findings']): string {
80
+ const lines = [
81
+ `Effective State: ${state.registrations.length} registrations · ${state.installations.length} installations 參與`,
82
+ ...state.suppressed.map((item) => `⊘ suppressed ${item.kind} ${item.targetId.slice(0, 8)}… (${item.reason})`),
83
+ ];
84
+ if (plugins.length === 0) lines.push('Projected Plugins: none');
85
+ for (const plugin of plugins) {
86
+ lines.push(`▸ ${quote(plugin.pluginId)} · ${plugin.sourceScope}`);
87
+ for (const skill of plugin.skills) {
88
+ const status = skill.status === 'projected' ? 'projected' : 'unavailable(碰撞)';
89
+ lines.push(` ${quote(skill.name)} · ${status} · availability: ${skill.availability}`);
90
+ }
91
+ }
92
+ if (findings.length > 0) {
93
+ lines.push(`Findings: ${findings.map((f) => `${f.classification} ${f.code}`).join(' | ')}`);
94
+ }
95
+ return lines.join('\n');
96
+ }
97
+
98
+ async function readBoth(ctx: { cwd?: string; agentDir?: string }): Promise<{ ok: boolean; global?: BridgeState; project?: BridgeState; error?: string }> {
99
+ const opts = { cwd: ctx.cwd, agentDir: ctx.agentDir };
100
+ const [globalRead, projectRead] = await Promise.all([readBridgeState('global', opts), readBridgeState('project', opts)]);
101
+ const bad = [globalRead, projectRead].find((read) => read.status !== 'ok' && read.status !== 'missing');
102
+ if (bad) return { ok: false, error: bad.error ?? 'Bridge State is not readable (Persistence Indeterminate)' };
103
+ return { ok: true, global: globalRead.state!, project: projectRead.state! };
104
+ }
105
+
106
+ /** Create fine-grained Scope Overrides against inherited Global registrations / installations. */
107
+ export async function runScopeOverrideFlow(ctx: ExtensionCommandContext): Promise<void> {
108
+ const ui: ExtensionUIContext = ctx.ui;
109
+ const opts = { cwd: ctx.cwd, agentDir: undefined, projectTrusted: ctx.isProjectTrusted() };
110
+ const docs = await readBoth({ cwd: ctx.cwd });
111
+ if (!docs.ok) return void ui.notify(`Bridge State 不可讀:${docs.error}`, 'error');
112
+
113
+ if (!ctx.isProjectTrusted()) {
114
+ return void ui.notify('Project Trust 未由 Pi host 授予——無法執行任何 Project Scope Lifecycle Operation(紀錄仍保存但不參與 Effective State)。', 'warning');
115
+ }
116
+
117
+ const rows = inheritedRecordRows(docs.global!, docs.project!, ctx.isProjectTrusted());
118
+ const chosen = await ui.select('建立 Scope Override — 選擇要抑制的繼承全域紀錄', rows.map((row) => row.label));
119
+ if (!chosen) return;
120
+ const row = rows.find((item) => item.label === chosen)!;
121
+ if (row.suppressedByOverride) return void ui.notify('此紀錄已被現有 Scope Override 抑制。', 'warning');
122
+
123
+ const cascade = row.kind === 'registration'
124
+ ? '\n\nRegistration Override 會抑制整顆 Marketplace 子樹(該 Registration 及其所有 Installations)。'
125
+ : '\n\nInstallation Override 僅抑制此單一 Plugin。';
126
+ const confirmed = await ui.confirm(
127
+ 'Scope Override Disclosure — 預設 No',
128
+ `將於 Project Scope 建立 ${row.kind} Scope Override,抑制繼承的全域紀錄:\n${row.targetId}${cascade}\n\n不會修改全域文件;移除 Override 即可還原繼承。`,
129
+ );
130
+ if (!confirmed) return void ui.notify('Attempt Summary: Declined — state unchanged.', 'info');
131
+
132
+ const outcome = await createScopeOverride(row.kind, row.targetId, opts);
133
+ reportOutcome(ctx, outcome);
134
+ }
135
+
136
+ /** Remove existing Scope Overrides; inheritance restores immediately via recomputation. */
137
+ export async function runRemoveScopeOverrideFlow(ctx: ExtensionCommandContext): Promise<void> {
138
+ const ui: ExtensionUIContext = ctx.ui;
139
+ const opts = { cwd: ctx.cwd, agentDir: undefined, projectTrusted: ctx.isProjectTrusted() };
140
+ const docs = await readBoth({ cwd: ctx.cwd });
141
+ if (!docs.ok) return void ui.notify(`Bridge State 不可讀:${docs.error}`, 'error');
142
+ const overrides = docs.project!.scopeOverrides;
143
+ if (overrides.length === 0) return void ui.notify('Project Scope 目前沒有任何 Scope Override。', 'info');
144
+
145
+ const labels = overrides.map((item) => `${item.kind} Override → ${item.targetId.slice(0, 8)}…`);
146
+ const chosen = await ui.select('移除 Scope Override — 移除後立即還原繼承(不改寫全域文件)', labels);
147
+ if (!chosen) return;
148
+ const target = overrides[labels.indexOf(chosen)]!;
149
+
150
+ const confirmed = await ui.confirm('Override Removal — 預設 No', `移除 ${target.kind} Scope Override?\n被抑制的繼承全域紀錄將立即在 Effective State 中恢復。`);
151
+ if (!confirmed) return void ui.notify('Attempt Summary: Declined — state unchanged.', 'info');
152
+
153
+ const outcome = await removeScopeOverride(target.kind, target.targetId, opts);
154
+ reportOutcome(ctx, outcome);
155
+ }
156
+
157
+ /** Read-only Effective State + Projected Skills / collision diagnostics view. */
158
+ export async function runEffectiveStateView(ctx: ExtensionCommandContext): Promise<void> {
159
+ const ui: ExtensionUIContext = ctx.ui;
160
+ const trusted = ctx.isProjectTrusted();
161
+ const docs = await readBoth({ cwd: ctx.cwd });
162
+ if (!docs.ok) return void ui.notify(`Bridge State 不可讀:${docs.error}`, 'error');
163
+ const effective = computeEffectiveState(docs.global!, docs.project!, { projectTrusted: trusted });
164
+ const projection = projectEffectiveState(docs.global!, docs.project!, { projectTrusted: trusted });
165
+ const trustNote = trusted ? '' : '\n\n⚠ Project Trust 未授予——Project Scope 紀錄仍保存但不參與 Effective State。';
166
+ ui.notify(
167
+ `${formatProjectionSummary(effective, projection.plugins, projection.findings)}${trustNote}\n\nAvailable 僅由宿主獨立證據確立;碰撞僅影響技能粒度,不改變 Plugin 分類。`,
168
+ projection.findings.length > 0 ? 'warning' : 'info',
169
+ );
170
+ }
package/package.json ADDED
@@ -0,0 +1,60 @@
1
+ {
2
+ "name": "pi-codex-marketplace",
3
+ "version": "0.1.0",
4
+ "description": "Bridge Package for Codex Marketplace compatibility in Pi — Global/Project Bridge State, atomic persistence, and /codex-marketplace TUI",
5
+ "type": "module",
6
+ "keywords": [
7
+ "pi-package",
8
+ "pi",
9
+ "pi-coding-agent",
10
+ "codex",
11
+ "marketplace"
12
+ ],
13
+ "license": "MIT",
14
+ "author": "Sam Wang",
15
+ "repository": {
16
+ "type": "git",
17
+ "url": "git+https://github.com/SamWang32191/pi-codex-marketplace.git"
18
+ },
19
+ "engines": {
20
+ "node": ">=22.19.0"
21
+ },
22
+ "pi": {
23
+ "extensions": [
24
+ "./extensions/pi/index.ts"
25
+ ]
26
+ },
27
+ "peerDependencies": {
28
+ "@earendil-works/pi-ai": "*",
29
+ "@earendil-works/pi-coding-agent": "0.84.2",
30
+ "@earendil-works/pi-tui": "*"
31
+ },
32
+ "peerDependenciesMeta": {
33
+ "@earendil-works/pi-coding-agent": {
34
+ "optional": false
35
+ }
36
+ },
37
+ "overrides": {},
38
+ "devDependencies": {
39
+ "@earendil-works/pi-coding-agent": "^0.84.2",
40
+ "@earendil-works/pi-tui": "^0.84.2",
41
+ "@types/node": "^26.2.0",
42
+ "typescript": "^5.7.0",
43
+ "vitest": "^3.0.0"
44
+ },
45
+ "scripts": {
46
+ "typecheck": "tsc --noEmit",
47
+ "test": "vitest run --coverage=false",
48
+ "test:watch": "vitest",
49
+ "test:acceptance": "vitest run tests/acceptance --coverage=false",
50
+ "build": "echo 'no build step — extensions loaded via jiti'",
51
+ "check": "npm run typecheck && npm test",
52
+ "prepublishOnly": "npm run typecheck && npm test"
53
+ },
54
+ "files": [
55
+ "extensions",
56
+ "src",
57
+ "README.md",
58
+ "LICENSE"
59
+ ]
60
+ }
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Global Pending Barrier — Compatibility Profile v1 policy.
3
+ * See CONTEXT.md: Global Pending Barrier.
4
+ *
5
+ * Active when:
6
+ * - Global Scope Attempt Fence is currently held, OR
7
+ * - Global Scope has Pending Application, Persistence Indeterminate, or Receipt Journal degradation.
8
+ *
9
+ * Blocks all Project Scope mutations and Runtime Applications (Lifecycle Operations,
10
+ * Repair State, project startup reconciliation). Inspection and Marketplace Refresh remain available.
11
+ */
12
+
13
+ import { existsSync, openSync, closeSync, unlinkSync } from 'node:fs';
14
+
15
+ import { readBridgeStateSync } from '../bridge-state/store.js';
16
+ import { getFencePath, getGlobalStatePath, getStatePath } from '../bridge-state/paths.js';
17
+ import { readReceiptJournal } from '../journal/journal.js';
18
+ import { blocking, CODE, RULE, type ValidationFinding } from '../registration/findings.js';
19
+
20
+ export interface GlobalBarrierStatus {
21
+ active: boolean;
22
+ reason?: string;
23
+ finding?: ValidationFinding;
24
+ }
25
+
26
+ export function globalBarrierFinding(reason: string): ValidationFinding {
27
+ return blocking({
28
+ code: CODE.GLOBAL_PENDING_BARRIER,
29
+ phase: 'admission',
30
+ target: 'attempt',
31
+ scope: 'project',
32
+ pointer: '',
33
+ rule: RULE.GLOBAL_PENDING_BARRIER,
34
+ outcome: `Global Pending Barrier is active: ${reason}; project state mutations and runtime applications are blocked until global recovery completes (Inspection and Marketplace Refresh remain available)`,
35
+ });
36
+ }
37
+
38
+ /** Check if the Global Attempt Fence lock is currently held. */
39
+ function isGlobalFenceHeld(opts: { agentDir?: string; cwd?: string }): boolean {
40
+ const globalStatePath = getGlobalStatePath(opts.agentDir);
41
+ const fencePath = getFencePath(globalStatePath);
42
+ if (!existsSync(fencePath)) return false;
43
+
44
+ // Try opening with 'wx' flag to see if file exists / is held
45
+ // In our fence implementation, the lock file exists while the fence is held and is unlinked on release.
46
+ // If fencePath exists, check if we can acquire it or if it exists.
47
+ return existsSync(fencePath);
48
+ }
49
+
50
+ /** Check if the Global Pending Barrier is active. */
51
+ export async function checkGlobalPendingBarrier(
52
+ opts: { agentDir?: string; cwd?: string } = {},
53
+ ): Promise<GlobalBarrierStatus> {
54
+ // 1. Check if Global Attempt Fence is held
55
+ if (isGlobalFenceHeld(opts)) {
56
+ const reason = 'Global Attempt Fence is held by an in-flight operation';
57
+ return {
58
+ active: true,
59
+ reason,
60
+ finding: globalBarrierFinding(reason),
61
+ };
62
+ }
63
+
64
+ // 2. Check Global State readability / integrity
65
+ const globalState = readBridgeStateSync('global', opts);
66
+ if (globalState.status === 'corrupted' || globalState.status === 'incompatible') {
67
+ const reason = `Global Bridge State is ${globalState.status} (${globalState.error ?? 'Persistence Indeterminate'})`;
68
+ return {
69
+ active: true,
70
+ reason,
71
+ finding: globalBarrierFinding(reason),
72
+ };
73
+ }
74
+
75
+ // 3. Check Global Receipt Journal for active recovery chains or degradation
76
+ const journal = await readReceiptJournal('global', opts);
77
+ if (journal.activeChains.length > 0) {
78
+ const root = journal.activeChains[0];
79
+ const conditionLabel =
80
+ root.condition === 'pending-application'
81
+ ? 'Pending Application'
82
+ : root.condition === 'persistence-indeterminate'
83
+ ? 'Persistence Indeterminate'
84
+ : root.condition === 'persistence-failed'
85
+ ? 'Persistence Failed'
86
+ : 'Receipt Journal degradation';
87
+ const reason = `Global Scope has active recovery condition '${conditionLabel}' (root receipt: ${root.rootReceiptId})`;
88
+ return {
89
+ active: true,
90
+ reason,
91
+ finding: globalBarrierFinding(reason),
92
+ };
93
+ }
94
+
95
+ if (journal.isDegraded) {
96
+ const reason = `Global Receipt Journal is degraded (${journal.corruptedLineCount} corrupted lines)`;
97
+ return {
98
+ active: true,
99
+ reason,
100
+ finding: globalBarrierFinding(reason),
101
+ };
102
+ }
103
+
104
+ return { active: false };
105
+ }
@@ -0,0 +1,237 @@
1
+ /**
2
+ * Atomic file utilities: WAL + write-to-temp → fsync → rename + file lock + read-after-verify
3
+ *
4
+ * Guarantees:
5
+ * - Cross-process concurrent writers do not corrupt the file (lock + atomic rename)
6
+ * - Readers never observe a torn write (rename is atomic on POSIX)
7
+ * - Durability: fsync temp file and parent dir before/after rename
8
+ * - Verification: read back persisted content and compare
9
+ *
10
+ * Fail-closed contract:
11
+ * - If neither previous nor target State Revision can be verified, caller should treat as Persistence Indeterminate
12
+ * - If previous revision still verified, it's Persistence Failed
13
+ *
14
+ * Lock strategy: advisory lock file via O_CREAT|O_EXCL (".lock" sibling). Stale locks are not auto-removed
15
+ * except on timeout; callers hold lock for the shortest interval (write+verify).
16
+ */
17
+
18
+ import { closeSync, fsyncSync, openSync, readFileSync, renameSync, unlinkSync, writeSync } from 'node:fs';
19
+ import { mkdirSync, existsSync, statSync } from 'node:fs';
20
+ import { randomBytes } from 'node:crypto';
21
+ import { dirname, join } from 'node:path';
22
+
23
+ export interface AtomicWriteResult {
24
+ success: boolean;
25
+ error?: string;
26
+ /** Whether target was verified by read-after */
27
+ verified?: boolean;
28
+ }
29
+
30
+ /**
31
+ * Acquire an advisory lock file. Creates lockPath with O_EXCL and writes pid.
32
+ * Retries until timeoutMs (default 5000). Caller must release via releaseLock or the returned fd.
33
+ * Returns fd of lock file (already open). Caller should close+unlink on success/failure.
34
+ */
35
+ export async function acquireLock(lockPath: string, timeoutMs = 5000): Promise<number> {
36
+ const start = Date.now();
37
+ const dir = dirname(lockPath);
38
+ mkdirSync(dir, { recursive: true });
39
+
40
+ while (true) {
41
+ try {
42
+ const fd = openSync(lockPath, 'wx', 0o600);
43
+ try {
44
+ writeSync(fd, String(process.pid));
45
+ fsyncSync(fd);
46
+ } catch {
47
+ // ignore write failure but keep lock
48
+ }
49
+ return fd;
50
+ } catch (e: unknown) {
51
+ const err = e as NodeJS.ErrnoException;
52
+ if (err.code !== 'EEXIST') throw e;
53
+ if (Date.now() - start > timeoutMs) {
54
+ throw new Error(`Failed to acquire lock ${lockPath} after ${timeoutMs}ms`);
55
+ }
56
+ await new Promise<void>((resolve) => setTimeout(resolve, 20));
57
+ }
58
+ }
59
+ }
60
+
61
+ export function acquireLockSync(lockPath: string, timeoutMs = 5000): number {
62
+ const start = Date.now();
63
+ const dir = dirname(lockPath);
64
+ mkdirSync(dir, { recursive: true });
65
+ while (true) {
66
+ try {
67
+ const fd = openSync(lockPath, 'wx', 0o600);
68
+ try {
69
+ writeSync(fd, String(process.pid));
70
+ fsyncSync(fd);
71
+ } catch {}
72
+ return fd;
73
+ } catch (e: unknown) {
74
+ const err = e as NodeJS.ErrnoException;
75
+ if (err.code !== 'EEXIST') throw e;
76
+ if (Date.now() - start > timeoutMs) {
77
+ throw new Error(`Failed to acquire lock ${lockPath} after ${timeoutMs}ms`);
78
+ }
79
+ // brief spin for sync path (used rarely)
80
+ const end = Date.now() + 10;
81
+ while (Date.now() < end) {}
82
+ }
83
+ }
84
+ }
85
+
86
+ export function releaseLock(fd: number, lockPath: string): void {
87
+ try {
88
+ closeSync(fd);
89
+ } catch {}
90
+ try {
91
+ unlinkSync(lockPath);
92
+ } catch {}
93
+ }
94
+
95
+ /** Run fn while holding the lock file. */
96
+ export async function withFileLock<T>(
97
+ lockPath: string,
98
+ fn: () => Promise<T> | T,
99
+ timeoutMs = 5000,
100
+ ): Promise<T> {
101
+ const fd = await acquireLock(lockPath, timeoutMs);
102
+ let released = false;
103
+ const doRelease = () => {
104
+ if (!released) {
105
+ released = true;
106
+ releaseLock(fd, lockPath);
107
+ }
108
+ };
109
+ try {
110
+ const result = await fn();
111
+ doRelease();
112
+ return result;
113
+ } catch (e) {
114
+ doRelease();
115
+ throw e;
116
+ }
117
+ }
118
+
119
+ /**
120
+ * Atomic write: write data to temp file, fsync, rename to target, fsync dir, verify.
121
+ * Must be called while holding the sibling .lock if protecting against RMW races;
122
+ * but even without lock, this prevents torn reads.
123
+ */
124
+ export function atomicWriteFile(targetPath: string, data: string): AtomicWriteResult {
125
+ const dir = dirname(targetPath);
126
+ mkdirSync(dir, { recursive: true });
127
+
128
+ const tmpName = `.${Date.now()}.${process.pid}.${randomBytes(4).toString('hex')}.tmp`;
129
+ const tmpPath = join(dir, tmpName);
130
+
131
+ let tmpFd: number | undefined;
132
+ try {
133
+ tmpFd = openSync(tmpPath, 'wx', 0o600);
134
+ writeSync(tmpFd, data, null, 'utf-8');
135
+ fsyncSync(tmpFd);
136
+ closeSync(tmpFd);
137
+ tmpFd = undefined;
138
+
139
+ // Atomic rename
140
+ renameSync(tmpPath, targetPath);
141
+
142
+ // Fsync parent dir for durability (best-effort)
143
+ try {
144
+ const dirFd = openSync(dir, 'r');
145
+ try {
146
+ fsyncSync(dirFd);
147
+ } finally {
148
+ closeSync(dirFd);
149
+ }
150
+ } catch {
151
+ // fsync dir may fail on some filesystems; not fatal
152
+ }
153
+
154
+ // Read-after-verify
155
+ try {
156
+ const persisted = readFileSync(targetPath, 'utf-8');
157
+ if (persisted !== data) {
158
+ return {
159
+ success: false,
160
+ verified: false,
161
+ error: 'Read-after-verify mismatch: persisted content differs from written',
162
+ };
163
+ }
164
+ } catch (e) {
165
+ const msg = e instanceof Error ? e.message : String(e);
166
+ return { success: false, verified: false, error: `Read-after-verify failed: ${msg}` };
167
+ }
168
+
169
+ return { success: true, verified: true };
170
+ } catch (e) {
171
+ const msg = e instanceof Error ? e.message : String(e);
172
+ return { success: false, verified: false, error: msg };
173
+ } finally {
174
+ if (tmpFd !== undefined) {
175
+ try {
176
+ closeSync(tmpFd);
177
+ } catch {}
178
+ }
179
+ // cleanup tmp if still exists
180
+ if (existsSync(tmpPath)) {
181
+ try {
182
+ unlinkSync(tmpPath);
183
+ } catch {}
184
+ }
185
+ }
186
+ }
187
+
188
+ /**
189
+ * Higher-level atomic commit that combines lock + write + verify,
190
+ * and classifies outcome as success / Persistence Failed / Persistence Indeterminate
191
+ * by checking whether previous revision is still readable.
192
+ * For scaffold, we defer Indeterminate classification to store.ts (needs read context).
193
+ */
194
+ export async function atomicWriteWithLock(
195
+ targetPath: string,
196
+ data: string,
197
+ lockPath: string,
198
+ timeoutMs = 5000,
199
+ ): Promise<AtomicWriteResult & { lockHeld: boolean }> {
200
+ const fd = await acquireLock(lockPath, timeoutMs);
201
+ try {
202
+ const result = atomicWriteFile(targetPath, data);
203
+ releaseLock(fd, lockPath);
204
+ return { ...result, lockHeld: true };
205
+ } catch (e) {
206
+ try {
207
+ releaseLock(fd, lockPath);
208
+ } catch {}
209
+ const msg = e instanceof Error ? e.message : String(e);
210
+ return { success: false, verified: false, error: msg, lockHeld: true };
211
+ }
212
+ }
213
+
214
+ export function atomicWriteWithLockSync(
215
+ targetPath: string,
216
+ data: string,
217
+ lockPath: string,
218
+ timeoutMs = 5000,
219
+ ): AtomicWriteResult & { lockHeld: boolean } {
220
+ const fd = acquireLockSync(lockPath, timeoutMs);
221
+ try {
222
+ const result = atomicWriteFile(targetPath, data);
223
+ releaseLock(fd, lockPath);
224
+ return { ...result, lockHeld: true };
225
+ } catch (e) {
226
+ try {
227
+ releaseLock(fd, lockPath);
228
+ } catch {}
229
+ const msg = e instanceof Error ? e.message : String(e);
230
+ return { success: false, verified: false, error: msg, lockHeld: true };
231
+ }
232
+ }
233
+
234
+ // For testability: export fsync helper
235
+ export function fsyncFileSync(fd: number): void {
236
+ fsyncSync(fd);
237
+ }
@@ -0,0 +1,5 @@
1
+ export * from './types.js';
2
+ export * from './paths.js';
3
+ export * from './schema.js';
4
+ export * from './atomic.js';
5
+ export * from './store.js';