agent-plugkit 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.
- package/LICENSE +21 -0
- package/README.md +295 -0
- package/dist/adapters/agent-plugins.d.ts +35 -0
- package/dist/adapters/agent-plugins.js +57 -0
- package/dist/adapters/agent-plugins.js.map +1 -0
- package/dist/adapters/claude.d.ts +64 -0
- package/dist/adapters/claude.js +123 -0
- package/dist/adapters/claude.js.map +1 -0
- package/dist/adapters/cli-validation.d.ts +12 -0
- package/dist/adapters/cli-validation.js +31 -0
- package/dist/adapters/cli-validation.js.map +1 -0
- package/dist/adapters/codex.d.ts +62 -0
- package/dist/adapters/codex.js +91 -0
- package/dist/adapters/codex.js.map +1 -0
- package/dist/adapters/marketplace-registration.d.ts +17 -0
- package/dist/adapters/marketplace-registration.js +369 -0
- package/dist/adapters/marketplace-registration.js.map +1 -0
- package/dist/adapters/marketplace.d.ts +11 -0
- package/dist/adapters/marketplace.js +96 -0
- package/dist/adapters/marketplace.js.map +1 -0
- package/dist/adapters/mcp.d.ts +17 -0
- package/dist/adapters/mcp.js +28 -0
- package/dist/adapters/mcp.js.map +1 -0
- package/dist/application/artifact-generation.d.ts +58 -0
- package/dist/application/artifact-generation.js +344 -0
- package/dist/application/artifact-generation.js.map +1 -0
- package/dist/application/local-release.d.ts +122 -0
- package/dist/application/local-release.js +940 -0
- package/dist/application/local-release.js.map +1 -0
- package/dist/application/marketplace-document-contract.d.ts +81 -0
- package/dist/application/marketplace-document-contract.js +2 -0
- package/dist/application/marketplace-document-contract.js.map +1 -0
- package/dist/application/marketplace-document.d.ts +45 -0
- package/dist/application/marketplace-document.js +557 -0
- package/dist/application/marketplace-document.js.map +1 -0
- package/dist/application/marketplace-registration-contract.d.ts +51 -0
- package/dist/application/marketplace-registration-contract.js +11 -0
- package/dist/application/marketplace-registration-contract.js.map +1 -0
- package/dist/application/marketplace-registration.d.ts +20 -0
- package/dist/application/marketplace-registration.js +304 -0
- package/dist/application/marketplace-registration.js.map +1 -0
- package/dist/application/marketplace.d.ts +9 -0
- package/dist/application/marketplace.js +32 -0
- package/dist/application/marketplace.js.map +1 -0
- package/dist/application/plugin-components-contract.d.ts +135 -0
- package/dist/application/plugin-components-contract.js +2 -0
- package/dist/application/plugin-components-contract.js.map +1 -0
- package/dist/application/plugin-components.d.ts +17 -0
- package/dist/application/plugin-components.js +1000 -0
- package/dist/application/plugin-components.js.map +1 -0
- package/dist/application/plugin-document-contract.d.ts +115 -0
- package/dist/application/plugin-document-contract.js +2 -0
- package/dist/application/plugin-document-contract.js.map +1 -0
- package/dist/application/plugin-document.d.ts +69 -0
- package/dist/application/plugin-document.js +1037 -0
- package/dist/application/plugin-document.js.map +1 -0
- package/dist/application/plugin-lifecycle-contract.d.ts +230 -0
- package/dist/application/plugin-lifecycle-contract.js +3 -0
- package/dist/application/plugin-lifecycle-contract.js.map +1 -0
- package/dist/application/plugin-lifecycle.d.ts +8 -0
- package/dist/application/plugin-lifecycle.js +1375 -0
- package/dist/application/plugin-lifecycle.js.map +1 -0
- package/dist/application/plugin-scaffold.d.ts +18 -0
- package/dist/application/plugin-scaffold.js +188 -0
- package/dist/application/plugin-scaffold.js.map +1 -0
- package/dist/application/plugin-source.d.ts +39 -0
- package/dist/application/plugin-source.js +59 -0
- package/dist/application/plugin-source.js.map +1 -0
- package/dist/application/skill-document-contract.d.ts +92 -0
- package/dist/application/skill-document-contract.js +8 -0
- package/dist/application/skill-document-contract.js.map +1 -0
- package/dist/application/skill-document-guided.d.ts +3 -0
- package/dist/application/skill-document-guided.js +53 -0
- package/dist/application/skill-document-guided.js.map +1 -0
- package/dist/application/skill-document.d.ts +19 -0
- package/dist/application/skill-document.js +368 -0
- package/dist/application/skill-document.js.map +1 -0
- package/dist/application/workspace-create-contract.d.ts +50 -0
- package/dist/application/workspace-create-contract.js +16 -0
- package/dist/application/workspace-create-contract.js.map +1 -0
- package/dist/application/workspace-create.d.ts +8 -0
- package/dist/application/workspace-create.js +656 -0
- package/dist/application/workspace-create.js.map +1 -0
- package/dist/application/workspace-health-contract.d.ts +133 -0
- package/dist/application/workspace-health-contract.js +272 -0
- package/dist/application/workspace-health-contract.js.map +1 -0
- package/dist/application/workspace-health.d.ts +10 -0
- package/dist/application/workspace-health.js +1114 -0
- package/dist/application/workspace-health.js.map +1 -0
- package/dist/application/workspace-operation-contract.d.ts +99 -0
- package/dist/application/workspace-operation-contract.js +450 -0
- package/dist/application/workspace-operation-contract.js.map +1 -0
- package/dist/application/workspace-operation.d.ts +42 -0
- package/dist/application/workspace-operation.js +517 -0
- package/dist/application/workspace-operation.js.map +1 -0
- package/dist/cli.d.ts +16 -0
- package/dist/cli.js +136 -0
- package/dist/cli.js.map +1 -0
- package/dist/commands/add.d.ts +8 -0
- package/dist/commands/add.js +77 -0
- package/dist/commands/add.js.map +1 -0
- package/dist/commands/build.d.ts +7 -0
- package/dist/commands/build.js +29 -0
- package/dist/commands/build.js.map +1 -0
- package/dist/commands/component-scaffold.d.ts +4 -0
- package/dist/commands/component-scaffold.js +14 -0
- package/dist/commands/component-scaffold.js.map +1 -0
- package/dist/commands/import-skill.d.ts +15 -0
- package/dist/commands/import-skill.js +41 -0
- package/dist/commands/import-skill.js.map +1 -0
- package/dist/commands/index-gen.d.ts +12 -0
- package/dist/commands/index-gen.js +33 -0
- package/dist/commands/index-gen.js.map +1 -0
- package/dist/commands/init-repo.d.ts +8 -0
- package/dist/commands/init-repo.js +53 -0
- package/dist/commands/init-repo.js.map +1 -0
- package/dist/commands/init.d.ts +9 -0
- package/dist/commands/init.js +33 -0
- package/dist/commands/init.js.map +1 -0
- package/dist/commands/install-repo.d.ts +20 -0
- package/dist/commands/install-repo.js +200 -0
- package/dist/commands/install-repo.js.map +1 -0
- package/dist/commands/release-local.d.ts +9 -0
- package/dist/commands/release-local.js +27 -0
- package/dist/commands/release-local.js.map +1 -0
- package/dist/commands/validate.d.ts +8 -0
- package/dist/commands/validate.js +45 -0
- package/dist/commands/validate.js.map +1 -0
- package/dist/core/errors.d.ts +5 -0
- package/dist/core/errors.js +12 -0
- package/dist/core/errors.js.map +1 -0
- package/dist/generated/skill-content.d.ts +3 -0
- package/dist/generated/skill-content.js +9 -0
- package/dist/generated/skill-content.js.map +1 -0
- package/dist/infrastructure/authorized-path.d.ts +20 -0
- package/dist/infrastructure/authorized-path.js +85 -0
- package/dist/infrastructure/authorized-path.js.map +1 -0
- package/dist/infrastructure/document-commit.d.ts +165 -0
- package/dist/infrastructure/document-commit.js +1352 -0
- package/dist/infrastructure/document-commit.js.map +1 -0
- package/dist/infrastructure/process-runner.d.ts +29 -0
- package/dist/infrastructure/process-runner.js +130 -0
- package/dist/infrastructure/process-runner.js.map +1 -0
- package/dist/infrastructure/vscode-user-settings.d.ts +42 -0
- package/dist/infrastructure/vscode-user-settings.js +509 -0
- package/dist/infrastructure/vscode-user-settings.js.map +1 -0
- package/dist/schema/plugin-yaml.d.ts +89 -0
- package/dist/schema/plugin-yaml.js +241 -0
- package/dist/schema/plugin-yaml.js.map +1 -0
- package/dist/utils/frontmatter.d.ts +30 -0
- package/dist/utils/frontmatter.js +60 -0
- package/dist/utils/frontmatter.js.map +1 -0
- package/dist/utils/helpers.d.ts +44 -0
- package/dist/utils/helpers.js +94 -0
- package/dist/utils/helpers.js.map +1 -0
- package/package.json +56 -0
|
@@ -0,0 +1,1352 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
+
import { chmodSync, closeSync, constants as fsConstants, copyFileSync, existsSync, fsyncSync, linkSync, lstatSync, mkdirSync, openSync, readFileSync, readdirSync, renameSync, rmdirSync, rmSync, statSync, unlinkSync, writeFileSync, } from 'node:fs';
|
|
3
|
+
import { dirname, isAbsolute, join, relative, sep } from 'node:path';
|
|
4
|
+
import { AuthorizedPathError, authorizeExistingDirectory, resolveAuthorizedPath, } from './authorized-path.js';
|
|
5
|
+
/**
|
|
6
|
+
* These names are reserved transaction traces inside an authorized workspace.
|
|
7
|
+
* They are not product state. A valid lock records the only staging token that
|
|
8
|
+
* may be reclaimed after its owner has exited.
|
|
9
|
+
*/
|
|
10
|
+
export const WORKSPACE_WRITE_LOCK = '.agent-plugkit-write.lock';
|
|
11
|
+
export const DOCUMENT_STAGING_PREFIX = '.agent-plugkit-document-';
|
|
12
|
+
const LOCK_RECLAIM_PREFIX = '.agent-plugkit-lock-reclaim-';
|
|
13
|
+
const LOCK_PREPARE_PREFIX = '.agent-plugkit-lock-prepare-';
|
|
14
|
+
const activeLockTokens = new Set();
|
|
15
|
+
const activePrepareTokens = new Set();
|
|
16
|
+
export function documentRevision(bytes) {
|
|
17
|
+
return createHash('sha256').update(bytes).digest('hex');
|
|
18
|
+
}
|
|
19
|
+
function failureMessage(error) {
|
|
20
|
+
if (error instanceof AuthorizedPathError)
|
|
21
|
+
return error.message;
|
|
22
|
+
return error instanceof Error ? error.message : String(error);
|
|
23
|
+
}
|
|
24
|
+
function readRegularFile(path) {
|
|
25
|
+
const stat = lstatSync(path);
|
|
26
|
+
if (!stat.isFile())
|
|
27
|
+
throw new Error('提交目标不是普通文件');
|
|
28
|
+
return readFileSync(path);
|
|
29
|
+
}
|
|
30
|
+
function defaultProcessAlive(processId) {
|
|
31
|
+
try {
|
|
32
|
+
process.kill(processId, 0);
|
|
33
|
+
return true;
|
|
34
|
+
}
|
|
35
|
+
catch (error) {
|
|
36
|
+
return !(error !== null &&
|
|
37
|
+
typeof error === 'object' &&
|
|
38
|
+
'code' in error &&
|
|
39
|
+
error.code === 'ESRCH');
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
function parseLockRecord(raw) {
|
|
43
|
+
try {
|
|
44
|
+
const value = JSON.parse(raw);
|
|
45
|
+
if (value === null ||
|
|
46
|
+
typeof value !== 'object' ||
|
|
47
|
+
Array.isArray(value)) {
|
|
48
|
+
return undefined;
|
|
49
|
+
}
|
|
50
|
+
const record = value;
|
|
51
|
+
if (Object.keys(record).length !== 4 ||
|
|
52
|
+
record.version !== 1 ||
|
|
53
|
+
typeof record.token !== 'string' ||
|
|
54
|
+
!/^[0-9a-f-]{16,80}$/i.test(record.token) ||
|
|
55
|
+
!Number.isInteger(record.processId) ||
|
|
56
|
+
record.processId <= 0 ||
|
|
57
|
+
typeof record.startedAt !== 'string' ||
|
|
58
|
+
Number.isNaN(Date.parse(record.startedAt))) {
|
|
59
|
+
return undefined;
|
|
60
|
+
}
|
|
61
|
+
return {
|
|
62
|
+
version: 1,
|
|
63
|
+
token: record.token,
|
|
64
|
+
processId: record.processId,
|
|
65
|
+
startedAt: record.startedAt,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
return undefined;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
function recordCanBeReclaimed(record, isProcessAlive) {
|
|
73
|
+
if (activeLockTokens.has(record.token) ||
|
|
74
|
+
activePrepareTokens.has(record.token)) {
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
// A live PID always keeps ownership. Elapsed time cannot distinguish a
|
|
78
|
+
// genuinely long transaction from PID reuse, so it must never weaken the
|
|
79
|
+
// exclusive-write guarantee.
|
|
80
|
+
return !isProcessAlive(record.processId);
|
|
81
|
+
}
|
|
82
|
+
function shouldReclaimLock(lockPath, isProcessAlive) {
|
|
83
|
+
const record = parseLockRecord(readFileSync(lockPath, 'utf8'));
|
|
84
|
+
if (record === undefined) {
|
|
85
|
+
// An unrecognized reserved-path occupant is not proven to be ours.
|
|
86
|
+
// Fail closed rather than deleting a possibly user-owned file.
|
|
87
|
+
return { reclaim: false };
|
|
88
|
+
}
|
|
89
|
+
return {
|
|
90
|
+
reclaim: recordCanBeReclaimed(record, isProcessAlive),
|
|
91
|
+
token: record.token,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
function reclaimAbandonedLock(directory, lockPath, lockToken, reclaimToken, afterReclaimLink) {
|
|
95
|
+
const residualPaths = [];
|
|
96
|
+
let staleStagingRelative;
|
|
97
|
+
let staleStagingPath;
|
|
98
|
+
let stalePrepareRelative;
|
|
99
|
+
let stalePreparePath;
|
|
100
|
+
const quarantineRelative = `${LOCK_RECLAIM_PREFIX}${reclaimToken}`;
|
|
101
|
+
const quarantinePath = resolveAuthorizedPath(directory, quarantineRelative);
|
|
102
|
+
let quarantineLinked = false;
|
|
103
|
+
let canonicalLockRemoved = false;
|
|
104
|
+
let diskChanged = false;
|
|
105
|
+
try {
|
|
106
|
+
// link(2) gives us a no-clobber quarantine claim. Unlike rename(2), it
|
|
107
|
+
// cannot overwrite an unknown same-name occupant.
|
|
108
|
+
linkSync(lockPath, quarantinePath);
|
|
109
|
+
quarantineLinked = true;
|
|
110
|
+
diskChanged = true;
|
|
111
|
+
afterReclaimLink?.(lockPath, quarantinePath);
|
|
112
|
+
const quarantinedRecord = parseLockRecord(readFileSync(quarantinePath, 'utf8'));
|
|
113
|
+
const currentRecord = parseLockRecord(readFileSync(lockPath, 'utf8'));
|
|
114
|
+
const lockStat = statSync(lockPath);
|
|
115
|
+
const quarantineStat = statSync(quarantinePath);
|
|
116
|
+
if (lockToken === undefined ||
|
|
117
|
+
quarantinedRecord?.token !== lockToken ||
|
|
118
|
+
currentRecord?.token !== lockToken ||
|
|
119
|
+
lockStat.dev !== quarantineStat.dev ||
|
|
120
|
+
lockStat.ino !== quarantineStat.ino) {
|
|
121
|
+
throw new Error('旧写入锁在隔离期间发生变化,未移除 canonical 锁');
|
|
122
|
+
}
|
|
123
|
+
releaseWorkspaceLock(lockPath, lockToken);
|
|
124
|
+
canonicalLockRemoved = true;
|
|
125
|
+
if (lockToken !== undefined) {
|
|
126
|
+
staleStagingRelative = `${DOCUMENT_STAGING_PREFIX}${lockToken}`;
|
|
127
|
+
staleStagingPath = resolveAuthorizedPath(directory, staleStagingRelative);
|
|
128
|
+
if (existsSync(staleStagingPath)) {
|
|
129
|
+
try {
|
|
130
|
+
rmSync(staleStagingPath, { recursive: true, force: true });
|
|
131
|
+
diskChanged = true;
|
|
132
|
+
}
|
|
133
|
+
catch (error) {
|
|
134
|
+
return {
|
|
135
|
+
status: 'failed',
|
|
136
|
+
message: `无法清理上次异常退出的临时目录:${failureMessage(error)}`,
|
|
137
|
+
diskChanged,
|
|
138
|
+
changedPaths: [
|
|
139
|
+
WORKSPACE_WRITE_LOCK,
|
|
140
|
+
staleStagingRelative,
|
|
141
|
+
quarantineRelative,
|
|
142
|
+
],
|
|
143
|
+
cleanupComplete: false,
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
stalePrepareRelative = `${LOCK_PREPARE_PREFIX}${lockToken}`;
|
|
148
|
+
stalePreparePath = resolveAuthorizedPath(directory, stalePrepareRelative);
|
|
149
|
+
if (existsSync(stalePreparePath)) {
|
|
150
|
+
const prepareRecord = parseLockRecord(readFileSync(stalePreparePath, 'utf8'));
|
|
151
|
+
const prepareStat = statSync(stalePreparePath);
|
|
152
|
+
const quarantineStatAfterRelease = statSync(quarantinePath);
|
|
153
|
+
if (prepareRecord?.token !== lockToken ||
|
|
154
|
+
prepareStat.dev !== quarantineStatAfterRelease.dev ||
|
|
155
|
+
prepareStat.ino !== quarantineStatAfterRelease.ino) {
|
|
156
|
+
return {
|
|
157
|
+
status: 'failed',
|
|
158
|
+
message: '旧写入锁已经移除,但同 token 的锁准备文件无法证明属于该事务。',
|
|
159
|
+
diskChanged: true,
|
|
160
|
+
changedPaths: [
|
|
161
|
+
WORKSPACE_WRITE_LOCK,
|
|
162
|
+
stalePrepareRelative,
|
|
163
|
+
quarantineRelative,
|
|
164
|
+
],
|
|
165
|
+
cleanupComplete: false,
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
try {
|
|
169
|
+
unlinkSync(stalePreparePath);
|
|
170
|
+
diskChanged = true;
|
|
171
|
+
}
|
|
172
|
+
catch (error) {
|
|
173
|
+
return {
|
|
174
|
+
status: 'failed',
|
|
175
|
+
message: `无法清理上次异常退出的锁准备文件:${failureMessage(error)}`,
|
|
176
|
+
diskChanged: true,
|
|
177
|
+
changedPaths: [
|
|
178
|
+
WORKSPACE_WRITE_LOCK,
|
|
179
|
+
stalePrepareRelative,
|
|
180
|
+
quarantineRelative,
|
|
181
|
+
],
|
|
182
|
+
cleanupComplete: false,
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
try {
|
|
188
|
+
unlinkSync(quarantinePath);
|
|
189
|
+
quarantineLinked = false;
|
|
190
|
+
}
|
|
191
|
+
catch (error) {
|
|
192
|
+
residualPaths.push(quarantineRelative);
|
|
193
|
+
return {
|
|
194
|
+
status: 'failed',
|
|
195
|
+
message: `旧写入锁已隔离,但清理失败:${failureMessage(error)}`,
|
|
196
|
+
diskChanged: true,
|
|
197
|
+
changedPaths: residualPaths,
|
|
198
|
+
cleanupComplete: false,
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
return undefined;
|
|
202
|
+
}
|
|
203
|
+
catch (error) {
|
|
204
|
+
if (quarantineLinked &&
|
|
205
|
+
!canonicalLockRemoved &&
|
|
206
|
+
existsSync(quarantinePath)) {
|
|
207
|
+
try {
|
|
208
|
+
unlinkSync(quarantinePath);
|
|
209
|
+
quarantineLinked = false;
|
|
210
|
+
}
|
|
211
|
+
catch {
|
|
212
|
+
residualPaths.push(quarantineRelative);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
if (staleStagingRelative !== undefined &&
|
|
216
|
+
staleStagingPath !== undefined &&
|
|
217
|
+
existsSync(staleStagingPath)) {
|
|
218
|
+
residualPaths.push(staleStagingRelative);
|
|
219
|
+
}
|
|
220
|
+
if (stalePrepareRelative !== undefined &&
|
|
221
|
+
stalePreparePath !== undefined &&
|
|
222
|
+
existsSync(stalePreparePath)) {
|
|
223
|
+
residualPaths.push(stalePrepareRelative);
|
|
224
|
+
}
|
|
225
|
+
if (quarantineLinked && existsSync(quarantinePath)) {
|
|
226
|
+
residualPaths.push(quarantineRelative);
|
|
227
|
+
}
|
|
228
|
+
if (canonicalLockRemoved) {
|
|
229
|
+
residualPaths.unshift(WORKSPACE_WRITE_LOCK);
|
|
230
|
+
}
|
|
231
|
+
const rolledBackLink = !canonicalLockRemoved &&
|
|
232
|
+
!quarantineLinked &&
|
|
233
|
+
residualPaths.length === 0;
|
|
234
|
+
return {
|
|
235
|
+
status: 'failed',
|
|
236
|
+
message: `无法安全回收上次异常退出的写入锁:${failureMessage(error)}`,
|
|
237
|
+
diskChanged: rolledBackLink ? false : diskChanged,
|
|
238
|
+
changedPaths: residualPaths,
|
|
239
|
+
cleanupComplete: residualPaths.length === 0,
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
class TraceRecoveryError extends Error {
|
|
244
|
+
diskChanged;
|
|
245
|
+
changedPaths;
|
|
246
|
+
cleanupComplete;
|
|
247
|
+
constructor(message, facts) {
|
|
248
|
+
super(message);
|
|
249
|
+
this.name = 'TraceRecoveryError';
|
|
250
|
+
this.diskChanged = facts.diskChanged;
|
|
251
|
+
this.changedPaths = facts.changedPaths;
|
|
252
|
+
this.cleanupComplete = facts.cleanupComplete;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
function prepareTokenFromEntry(entry) {
|
|
256
|
+
if (!entry.startsWith(LOCK_PREPARE_PREFIX))
|
|
257
|
+
return undefined;
|
|
258
|
+
const token = entry.slice(LOCK_PREPARE_PREFIX.length);
|
|
259
|
+
return /^[0-9a-f-]{16,80}$/i.test(token) ? token : undefined;
|
|
260
|
+
}
|
|
261
|
+
function reclaimOrphanPrepare(directory, entry, record, reclaimToken, isProcessAlive) {
|
|
262
|
+
const preparePath = resolveAuthorizedPath(directory, entry);
|
|
263
|
+
const quarantineRelative = `${LOCK_RECLAIM_PREFIX}${reclaimToken}-${record.token}`;
|
|
264
|
+
const quarantinePath = resolveAuthorizedPath(directory, quarantineRelative);
|
|
265
|
+
let quarantineLinked = false;
|
|
266
|
+
let prepareRemoved = false;
|
|
267
|
+
try {
|
|
268
|
+
linkSync(preparePath, quarantinePath);
|
|
269
|
+
quarantineLinked = true;
|
|
270
|
+
const currentRecord = parseLockRecord(readFileSync(preparePath, 'utf8'));
|
|
271
|
+
const quarantineRecord = parseLockRecord(readFileSync(quarantinePath, 'utf8'));
|
|
272
|
+
const prepareStat = statSync(preparePath);
|
|
273
|
+
const quarantineStat = statSync(quarantinePath);
|
|
274
|
+
if (currentRecord?.token !== record.token ||
|
|
275
|
+
quarantineRecord?.token !== record.token ||
|
|
276
|
+
currentRecord.processId !== record.processId ||
|
|
277
|
+
currentRecord.startedAt !== record.startedAt ||
|
|
278
|
+
prepareStat.dev !== quarantineStat.dev ||
|
|
279
|
+
prepareStat.ino !== quarantineStat.ino ||
|
|
280
|
+
!recordCanBeReclaimed(currentRecord, isProcessAlive)) {
|
|
281
|
+
unlinkSync(quarantinePath);
|
|
282
|
+
quarantineLinked = false;
|
|
283
|
+
return false;
|
|
284
|
+
}
|
|
285
|
+
unlinkSync(preparePath);
|
|
286
|
+
prepareRemoved = true;
|
|
287
|
+
unlinkSync(quarantinePath);
|
|
288
|
+
quarantineLinked = false;
|
|
289
|
+
return true;
|
|
290
|
+
}
|
|
291
|
+
catch (error) {
|
|
292
|
+
if (quarantineLinked && !prepareRemoved && existsSync(quarantinePath)) {
|
|
293
|
+
try {
|
|
294
|
+
unlinkSync(quarantinePath);
|
|
295
|
+
quarantineLinked = false;
|
|
296
|
+
}
|
|
297
|
+
catch {
|
|
298
|
+
// Report the exact residual below.
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
if (prepareRemoved || quarantineLinked) {
|
|
302
|
+
throw new TraceRecoveryError(`锁准备文件恢复没有完整完成:${failureMessage(error)}`, {
|
|
303
|
+
diskChanged: true,
|
|
304
|
+
changedPaths: [
|
|
305
|
+
...(prepareRemoved ? [entry] : []),
|
|
306
|
+
...(quarantineLinked ? [quarantineRelative] : []),
|
|
307
|
+
],
|
|
308
|
+
cleanupComplete: !quarantineLinked,
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
// An unproven or no-clobber collision is left untouched. It does not own
|
|
312
|
+
// the canonical lock and therefore must not block this document commit.
|
|
313
|
+
return false;
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
function cleanupAbandonedPrepareFiles(directory, reclaimToken, isProcessAlive) {
|
|
317
|
+
for (const entry of readdirSync(directory)) {
|
|
318
|
+
const token = prepareTokenFromEntry(entry);
|
|
319
|
+
if (token === undefined || activePrepareTokens.has(token))
|
|
320
|
+
continue;
|
|
321
|
+
const path = resolveAuthorizedPath(directory, entry);
|
|
322
|
+
let record;
|
|
323
|
+
try {
|
|
324
|
+
if (!lstatSync(path).isFile())
|
|
325
|
+
continue;
|
|
326
|
+
record = parseLockRecord(readFileSync(path, 'utf8'));
|
|
327
|
+
}
|
|
328
|
+
catch {
|
|
329
|
+
continue;
|
|
330
|
+
}
|
|
331
|
+
if (record === undefined ||
|
|
332
|
+
record.token !== token ||
|
|
333
|
+
!recordCanBeReclaimed(record, isProcessAlive)) {
|
|
334
|
+
continue;
|
|
335
|
+
}
|
|
336
|
+
reclaimOrphanPrepare(directory, entry, record, reclaimToken, isProcessAlive);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
function createLockFile(directory, lockPath, record, beforeLockSync) {
|
|
340
|
+
const prepareRelative = `${LOCK_PREPARE_PREFIX}${record.token}`;
|
|
341
|
+
const preparePath = resolveAuthorizedPath(directory, prepareRelative);
|
|
342
|
+
let descriptor;
|
|
343
|
+
let prepareCreated = false;
|
|
344
|
+
let lockLinked = false;
|
|
345
|
+
let closeFailed = false;
|
|
346
|
+
activePrepareTokens.add(record.token);
|
|
347
|
+
try {
|
|
348
|
+
descriptor = openSync(preparePath, 'wx', 0o600);
|
|
349
|
+
prepareCreated = true;
|
|
350
|
+
writeFileSync(descriptor, `${JSON.stringify(record)}\n`);
|
|
351
|
+
beforeLockSync?.(preparePath);
|
|
352
|
+
fsyncSync(descriptor);
|
|
353
|
+
closeSync(descriptor);
|
|
354
|
+
descriptor = undefined;
|
|
355
|
+
linkSync(preparePath, lockPath);
|
|
356
|
+
lockLinked = true;
|
|
357
|
+
unlinkSync(preparePath);
|
|
358
|
+
prepareCreated = false;
|
|
359
|
+
activeLockTokens.add(record.token);
|
|
360
|
+
return { status: 'acquired', path: lockPath, token: record.token };
|
|
361
|
+
}
|
|
362
|
+
catch (error) {
|
|
363
|
+
if (lockLinked === false &&
|
|
364
|
+
error !== null &&
|
|
365
|
+
typeof error === 'object' &&
|
|
366
|
+
'code' in error &&
|
|
367
|
+
error.code === 'EEXIST') {
|
|
368
|
+
if (!prepareCreated) {
|
|
369
|
+
return {
|
|
370
|
+
status: 'failed',
|
|
371
|
+
message: '锁准备文件名已被占用,无法证明该路径属于本次事务。',
|
|
372
|
+
diskChanged: false,
|
|
373
|
+
changedPaths: [],
|
|
374
|
+
cleanupComplete: true,
|
|
375
|
+
};
|
|
376
|
+
}
|
|
377
|
+
if (descriptor !== undefined) {
|
|
378
|
+
try {
|
|
379
|
+
closeSync(descriptor);
|
|
380
|
+
}
|
|
381
|
+
catch {
|
|
382
|
+
closeFailed = true;
|
|
383
|
+
}
|
|
384
|
+
descriptor = undefined;
|
|
385
|
+
}
|
|
386
|
+
if (prepareCreated) {
|
|
387
|
+
try {
|
|
388
|
+
unlinkSync(preparePath);
|
|
389
|
+
prepareCreated = false;
|
|
390
|
+
}
|
|
391
|
+
catch {
|
|
392
|
+
return {
|
|
393
|
+
status: 'failed',
|
|
394
|
+
message: '写入锁发生竞争,且本次锁准备文件未能清理。',
|
|
395
|
+
diskChanged: true,
|
|
396
|
+
changedPaths: [prepareRelative],
|
|
397
|
+
cleanupComplete: false,
|
|
398
|
+
};
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
return closeFailed
|
|
402
|
+
? {
|
|
403
|
+
status: 'failed',
|
|
404
|
+
message: '写入锁发生竞争,且锁准备文件描述符未能确认关闭。',
|
|
405
|
+
diskChanged: false,
|
|
406
|
+
changedPaths: [],
|
|
407
|
+
cleanupComplete: false,
|
|
408
|
+
}
|
|
409
|
+
: { status: 'busy' };
|
|
410
|
+
}
|
|
411
|
+
const cleanupErrors = [];
|
|
412
|
+
if (descriptor !== undefined) {
|
|
413
|
+
try {
|
|
414
|
+
closeSync(descriptor);
|
|
415
|
+
}
|
|
416
|
+
catch (closeError) {
|
|
417
|
+
closeFailed = true;
|
|
418
|
+
cleanupErrors.push(`关闭锁文件失败:${failureMessage(closeError)}`);
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
if (lockLinked) {
|
|
422
|
+
try {
|
|
423
|
+
releaseWorkspaceLock(lockPath, record.token);
|
|
424
|
+
lockLinked = false;
|
|
425
|
+
}
|
|
426
|
+
catch (releaseError) {
|
|
427
|
+
cleanupErrors.push(`移除本次写入锁失败:${failureMessage(releaseError)}`);
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
if (prepareCreated && existsSync(preparePath)) {
|
|
431
|
+
try {
|
|
432
|
+
unlinkSync(preparePath);
|
|
433
|
+
prepareCreated = false;
|
|
434
|
+
}
|
|
435
|
+
catch (unlinkError) {
|
|
436
|
+
cleanupErrors.push(`移除锁准备文件失败:${failureMessage(unlinkError)}`);
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
const lockRemains = lockLinked && existsSync(lockPath);
|
|
440
|
+
const prepareRemains = prepareCreated && existsSync(preparePath);
|
|
441
|
+
return {
|
|
442
|
+
status: 'failed',
|
|
443
|
+
message: [
|
|
444
|
+
`无法建立安全写入锁:${failureMessage(error)}`,
|
|
445
|
+
...cleanupErrors,
|
|
446
|
+
].join(';'),
|
|
447
|
+
diskChanged: lockRemains || prepareRemains,
|
|
448
|
+
changedPaths: [
|
|
449
|
+
...(lockRemains ? [WORKSPACE_WRITE_LOCK] : []),
|
|
450
|
+
...(prepareRemains ? [prepareRelative] : []),
|
|
451
|
+
],
|
|
452
|
+
cleanupComplete: !closeFailed && !lockRemains && !prepareRemains,
|
|
453
|
+
};
|
|
454
|
+
}
|
|
455
|
+
finally {
|
|
456
|
+
activePrepareTokens.delete(record.token);
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
function acquireWorkspaceLock(directory, token, now, isProcessAlive, beforeLockSync, afterReclaimLink) {
|
|
460
|
+
const lockPath = resolveAuthorizedPath(directory, WORKSPACE_WRITE_LOCK);
|
|
461
|
+
let acquisition = createLockFile(directory, lockPath, {
|
|
462
|
+
version: 1,
|
|
463
|
+
token,
|
|
464
|
+
processId: process.pid,
|
|
465
|
+
startedAt: now.toISOString(),
|
|
466
|
+
}, beforeLockSync);
|
|
467
|
+
if (acquisition.status !== 'busy')
|
|
468
|
+
return acquisition;
|
|
469
|
+
let abandoned;
|
|
470
|
+
try {
|
|
471
|
+
abandoned = shouldReclaimLock(lockPath, isProcessAlive);
|
|
472
|
+
}
|
|
473
|
+
catch (error) {
|
|
474
|
+
return {
|
|
475
|
+
status: 'failed',
|
|
476
|
+
message: `无法检查当前写入锁:${failureMessage(error)}`,
|
|
477
|
+
diskChanged: false,
|
|
478
|
+
changedPaths: [],
|
|
479
|
+
cleanupComplete: true,
|
|
480
|
+
};
|
|
481
|
+
}
|
|
482
|
+
if (!abandoned.reclaim)
|
|
483
|
+
return acquisition;
|
|
484
|
+
const reclaimFailure = reclaimAbandonedLock(directory, lockPath, abandoned.token, token, afterReclaimLink);
|
|
485
|
+
if (reclaimFailure !== undefined)
|
|
486
|
+
return reclaimFailure;
|
|
487
|
+
acquisition = createLockFile(directory, lockPath, {
|
|
488
|
+
version: 1,
|
|
489
|
+
token,
|
|
490
|
+
processId: process.pid,
|
|
491
|
+
startedAt: now.toISOString(),
|
|
492
|
+
}, beforeLockSync);
|
|
493
|
+
return acquisition;
|
|
494
|
+
}
|
|
495
|
+
function releaseWorkspaceLock(lockPath, token) {
|
|
496
|
+
const record = parseLockRecord(readFileSync(lockPath, 'utf8'));
|
|
497
|
+
if (record?.token !== token) {
|
|
498
|
+
throw new Error('写入锁的所有权已经变化,未移除该锁');
|
|
499
|
+
}
|
|
500
|
+
unlinkSync(lockPath);
|
|
501
|
+
}
|
|
502
|
+
function syncFile(path) {
|
|
503
|
+
const descriptor = openSync(path, 'r');
|
|
504
|
+
try {
|
|
505
|
+
fsyncSync(descriptor);
|
|
506
|
+
}
|
|
507
|
+
finally {
|
|
508
|
+
closeSync(descriptor);
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
/**
|
|
512
|
+
* Commits one already-authorized document with a workspace-wide exclusive
|
|
513
|
+
* lock. Domain ownership and patching stay with the caller. Atomicity means
|
|
514
|
+
* readers see either the old or new target after rename; durability is only
|
|
515
|
+
* reported as committed after both the staged file and its parent directory
|
|
516
|
+
* have been fsynced.
|
|
517
|
+
*/
|
|
518
|
+
export function commitAuthorizedDocument(request, dependencies = {}) {
|
|
519
|
+
let authorization;
|
|
520
|
+
try {
|
|
521
|
+
authorization = authorizeExistingDirectory(request.directory);
|
|
522
|
+
}
|
|
523
|
+
catch (error) {
|
|
524
|
+
return {
|
|
525
|
+
status: 'failed',
|
|
526
|
+
message: `无法获得写入授权:${failureMessage(error)}`,
|
|
527
|
+
diskChanged: false,
|
|
528
|
+
changedPaths: [],
|
|
529
|
+
cleanupComplete: true,
|
|
530
|
+
};
|
|
531
|
+
}
|
|
532
|
+
const token = (dependencies.createToken ?? randomUUID)();
|
|
533
|
+
const lock = acquireWorkspaceLock(authorization.canonicalPath, token, (dependencies.now ?? (() => new Date()))(), dependencies.isProcessAlive ?? defaultProcessAlive, dependencies.beforeLockSync, dependencies.afterReclaimLink);
|
|
534
|
+
if (lock.status === 'failed')
|
|
535
|
+
return lock;
|
|
536
|
+
if (lock.status === 'busy') {
|
|
537
|
+
return {
|
|
538
|
+
status: 'busy',
|
|
539
|
+
message: '这个 Marketplace 正在执行另一项写入;请等待完成后重试。',
|
|
540
|
+
diskChanged: false,
|
|
541
|
+
changedPaths: [],
|
|
542
|
+
cleanupComplete: true,
|
|
543
|
+
};
|
|
544
|
+
}
|
|
545
|
+
const stagingRelative = `${DOCUMENT_STAGING_PREFIX}${token}`;
|
|
546
|
+
let stagingRoot;
|
|
547
|
+
let stagingCreated = false;
|
|
548
|
+
let replaced = false;
|
|
549
|
+
let outcome;
|
|
550
|
+
try {
|
|
551
|
+
dependencies.afterLock?.();
|
|
552
|
+
cleanupAbandonedPrepareFiles(authorization.canonicalPath, token, dependencies.isProcessAlive ?? defaultProcessAlive);
|
|
553
|
+
const targetPath = resolveAuthorizedPath(authorization, request.relativePath);
|
|
554
|
+
const initialBytes = readRegularFile(targetPath);
|
|
555
|
+
const currentRevision = documentRevision(initialBytes);
|
|
556
|
+
if (currentRevision !== request.expectedRevision) {
|
|
557
|
+
outcome = {
|
|
558
|
+
status: 'conflict',
|
|
559
|
+
currentRevision,
|
|
560
|
+
diskChanged: false,
|
|
561
|
+
changedPaths: [],
|
|
562
|
+
cleanupComplete: true,
|
|
563
|
+
};
|
|
564
|
+
}
|
|
565
|
+
else if (Buffer.from(request.nextBytes).equals(initialBytes)) {
|
|
566
|
+
dependencies.beforeRevisionRecheck?.(targetPath);
|
|
567
|
+
const precommitRevision = documentRevision(readRegularFile(targetPath));
|
|
568
|
+
outcome =
|
|
569
|
+
precommitRevision === request.expectedRevision
|
|
570
|
+
? {
|
|
571
|
+
status: 'verified',
|
|
572
|
+
revision: precommitRevision,
|
|
573
|
+
diskChanged: false,
|
|
574
|
+
changedPaths: [],
|
|
575
|
+
cleanupComplete: true,
|
|
576
|
+
}
|
|
577
|
+
: {
|
|
578
|
+
status: 'conflict',
|
|
579
|
+
currentRevision: precommitRevision,
|
|
580
|
+
diskChanged: false,
|
|
581
|
+
changedPaths: [],
|
|
582
|
+
cleanupComplete: true,
|
|
583
|
+
};
|
|
584
|
+
}
|
|
585
|
+
else {
|
|
586
|
+
stagingRoot = resolveAuthorizedPath(authorization, stagingRelative);
|
|
587
|
+
mkdirSync(stagingRoot, { mode: 0o700 });
|
|
588
|
+
stagingCreated = true;
|
|
589
|
+
const stagedPath = join(stagingRoot, 'document.next');
|
|
590
|
+
dependencies.beforeStageWrite?.(stagedPath);
|
|
591
|
+
writeFileSync(stagedPath, request.nextBytes, { mode: 0o600 });
|
|
592
|
+
chmodSync(stagedPath, lstatSync(targetPath).mode & 0o777);
|
|
593
|
+
(dependencies.syncStagedFile ?? syncFile)(stagedPath);
|
|
594
|
+
dependencies.afterStageWrite?.(stagedPath);
|
|
595
|
+
dependencies.beforeRevisionRecheck?.(targetPath);
|
|
596
|
+
const precommitRevision = documentRevision(readRegularFile(targetPath));
|
|
597
|
+
if (precommitRevision !== request.expectedRevision) {
|
|
598
|
+
outcome = {
|
|
599
|
+
status: 'conflict',
|
|
600
|
+
currentRevision: precommitRevision,
|
|
601
|
+
diskChanged: false,
|
|
602
|
+
changedPaths: [],
|
|
603
|
+
cleanupComplete: true,
|
|
604
|
+
};
|
|
605
|
+
}
|
|
606
|
+
else {
|
|
607
|
+
(dependencies.replaceDocument ?? renameSync)(stagedPath, targetPath);
|
|
608
|
+
replaced = true;
|
|
609
|
+
(dependencies.syncParentDirectory ??
|
|
610
|
+
((directory) => syncFile(directory)))(authorization.canonicalPath);
|
|
611
|
+
dependencies.afterReplace?.(targetPath);
|
|
612
|
+
outcome = {
|
|
613
|
+
status: 'committed',
|
|
614
|
+
revision: documentRevision(request.nextBytes),
|
|
615
|
+
diskChanged: true,
|
|
616
|
+
changedPaths: [request.relativePath],
|
|
617
|
+
cleanupComplete: true,
|
|
618
|
+
};
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
catch (error) {
|
|
623
|
+
outcome =
|
|
624
|
+
error instanceof TraceRecoveryError
|
|
625
|
+
? {
|
|
626
|
+
status: 'failed',
|
|
627
|
+
message: error.message,
|
|
628
|
+
diskChanged: error.diskChanged,
|
|
629
|
+
changedPaths: error.changedPaths,
|
|
630
|
+
cleanupComplete: error.cleanupComplete,
|
|
631
|
+
}
|
|
632
|
+
: {
|
|
633
|
+
status: 'failed',
|
|
634
|
+
message: `安全写入没有完整完成:${failureMessage(error)}`,
|
|
635
|
+
diskChanged: replaced,
|
|
636
|
+
changedPaths: replaced ? [request.relativePath] : [],
|
|
637
|
+
cleanupComplete: true,
|
|
638
|
+
};
|
|
639
|
+
}
|
|
640
|
+
const cleanupFailures = [];
|
|
641
|
+
const residualPaths = new Set(outcome.changedPaths);
|
|
642
|
+
if (stagingRoot !== undefined && stagingCreated) {
|
|
643
|
+
try {
|
|
644
|
+
(dependencies.cleanupStaging ??
|
|
645
|
+
((path) => rmSync(path, { recursive: true, force: true })))(stagingRoot);
|
|
646
|
+
}
|
|
647
|
+
catch (error) {
|
|
648
|
+
cleanupFailures.push(`临时目录清理失败:${failureMessage(error)}`);
|
|
649
|
+
residualPaths.add(stagingRelative);
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
try {
|
|
653
|
+
(dependencies.cleanupLock ?? releaseWorkspaceLock)(lock.path, token);
|
|
654
|
+
}
|
|
655
|
+
catch (error) {
|
|
656
|
+
cleanupFailures.push(`写入锁清理失败:${failureMessage(error)}`);
|
|
657
|
+
if (existsSync(lock.path))
|
|
658
|
+
residualPaths.add(WORKSPACE_WRITE_LOCK);
|
|
659
|
+
}
|
|
660
|
+
finally {
|
|
661
|
+
activeLockTokens.delete(token);
|
|
662
|
+
}
|
|
663
|
+
if (cleanupFailures.length > 0) {
|
|
664
|
+
const baseMessage = outcome.status === 'failed'
|
|
665
|
+
? outcome.message
|
|
666
|
+
: outcome.status === 'conflict'
|
|
667
|
+
? '源文件 revision 已变化,没有替换 canonical 文件'
|
|
668
|
+
: outcome.status === 'verified'
|
|
669
|
+
? 'canonical 文件 revision 已验证且无需替换'
|
|
670
|
+
: 'canonical 文件已原子替换并完成耐久同步';
|
|
671
|
+
return {
|
|
672
|
+
status: 'failed',
|
|
673
|
+
message: `${baseMessage};${cleanupFailures.join(';')}`,
|
|
674
|
+
diskChanged: replaced || residualPaths.size > 0,
|
|
675
|
+
changedPaths: [...residualPaths],
|
|
676
|
+
cleanupComplete: false,
|
|
677
|
+
};
|
|
678
|
+
}
|
|
679
|
+
return outcome;
|
|
680
|
+
}
|
|
681
|
+
function validateCreateFiles(documentRelativePath, files) {
|
|
682
|
+
const seen = new Set();
|
|
683
|
+
for (const file of files) {
|
|
684
|
+
if (file.relativePath.length === 0 ||
|
|
685
|
+
file.relativePath === documentRelativePath ||
|
|
686
|
+
file.relativePath === WORKSPACE_WRITE_LOCK ||
|
|
687
|
+
file.relativePath.startsWith(DOCUMENT_STAGING_PREFIX) ||
|
|
688
|
+
file.relativePath.startsWith(LOCK_PREPARE_PREFIX) ||
|
|
689
|
+
file.relativePath.startsWith(LOCK_RECLAIM_PREFIX)) {
|
|
690
|
+
return `新增文件目标无效:${file.relativePath}`;
|
|
691
|
+
}
|
|
692
|
+
if (seen.has(file.relativePath)) {
|
|
693
|
+
return `新增文件目标重复:${file.relativePath}`;
|
|
694
|
+
}
|
|
695
|
+
if (!Number.isInteger(file.mode) || file.mode < 0 || file.mode > 0o777) {
|
|
696
|
+
return `新增文件权限无效:${file.relativePath}`;
|
|
697
|
+
}
|
|
698
|
+
seen.add(file.relativePath);
|
|
699
|
+
}
|
|
700
|
+
return undefined;
|
|
701
|
+
}
|
|
702
|
+
function createAuthorizedParents(authorization, targetPath, createdDirectories) {
|
|
703
|
+
const parentRelative = relative(authorization.canonicalPath, dirname(targetPath));
|
|
704
|
+
if (parentRelative === "")
|
|
705
|
+
return;
|
|
706
|
+
let cursor = "";
|
|
707
|
+
for (const segment of parentRelative.split(sep)) {
|
|
708
|
+
cursor = cursor.length === 0 ? segment : join(cursor, segment);
|
|
709
|
+
const path = resolveAuthorizedPath(authorization, cursor);
|
|
710
|
+
if (existsSync(path)) {
|
|
711
|
+
if (!lstatSync(path).isDirectory()) {
|
|
712
|
+
throw new Error(`新增文件的父级不是目录:${cursor}`);
|
|
713
|
+
}
|
|
714
|
+
continue;
|
|
715
|
+
}
|
|
716
|
+
mkdirSync(path, { mode: 0o700 });
|
|
717
|
+
createdDirectories.push(cursor);
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
/**
|
|
721
|
+
* Commits a canonical document plus exclusive-create scaffold files under one
|
|
722
|
+
* existing workspace-wide lock. It deliberately does not support update or
|
|
723
|
+
* deletion of auxiliary files: those objects remain user-owned unless a
|
|
724
|
+
* separate, explicit object-level Trash action is confirmed.
|
|
725
|
+
*/
|
|
726
|
+
export function commitAuthorizedDocumentAndFiles(request, dependencies = {}) {
|
|
727
|
+
if (request.createFiles.length === 0) {
|
|
728
|
+
const result = commitAuthorizedDocument(request, dependencies);
|
|
729
|
+
return {
|
|
730
|
+
...result,
|
|
731
|
+
transaction: {
|
|
732
|
+
canonical: result.status === "committed"
|
|
733
|
+
? "committed"
|
|
734
|
+
: result.status === "failed" && result.diskChanged
|
|
735
|
+
? "uncertain"
|
|
736
|
+
: "unchanged",
|
|
737
|
+
scaffolds: "none",
|
|
738
|
+
cleanupComplete: result.cleanupComplete,
|
|
739
|
+
},
|
|
740
|
+
};
|
|
741
|
+
}
|
|
742
|
+
const invalidFiles = validateCreateFiles(request.relativePath, request.createFiles);
|
|
743
|
+
if (invalidFiles !== undefined) {
|
|
744
|
+
return {
|
|
745
|
+
status: "failed",
|
|
746
|
+
message: invalidFiles,
|
|
747
|
+
diskChanged: false,
|
|
748
|
+
changedPaths: [],
|
|
749
|
+
cleanupComplete: true,
|
|
750
|
+
transaction: {
|
|
751
|
+
canonical: "unchanged",
|
|
752
|
+
scaffolds: "none",
|
|
753
|
+
cleanupComplete: true,
|
|
754
|
+
},
|
|
755
|
+
};
|
|
756
|
+
}
|
|
757
|
+
let authorization;
|
|
758
|
+
try {
|
|
759
|
+
authorization = authorizeExistingDirectory(request.directory);
|
|
760
|
+
}
|
|
761
|
+
catch (error) {
|
|
762
|
+
return {
|
|
763
|
+
status: "failed",
|
|
764
|
+
message: `无法获得写入授权:${failureMessage(error)}`,
|
|
765
|
+
diskChanged: false,
|
|
766
|
+
changedPaths: [],
|
|
767
|
+
cleanupComplete: true,
|
|
768
|
+
transaction: {
|
|
769
|
+
canonical: "unchanged",
|
|
770
|
+
scaffolds: "none",
|
|
771
|
+
cleanupComplete: true,
|
|
772
|
+
},
|
|
773
|
+
};
|
|
774
|
+
}
|
|
775
|
+
const token = (dependencies.createToken ?? randomUUID)();
|
|
776
|
+
const lock = acquireWorkspaceLock(authorization.canonicalPath, token, (dependencies.now ?? (() => new Date()))(), dependencies.isProcessAlive ?? defaultProcessAlive, dependencies.beforeLockSync, dependencies.afterReclaimLink);
|
|
777
|
+
if (lock.status === "failed") {
|
|
778
|
+
return {
|
|
779
|
+
...lock,
|
|
780
|
+
transaction: {
|
|
781
|
+
canonical: "unchanged",
|
|
782
|
+
scaffolds: "none",
|
|
783
|
+
cleanupComplete: lock.cleanupComplete,
|
|
784
|
+
},
|
|
785
|
+
};
|
|
786
|
+
}
|
|
787
|
+
if (lock.status === "busy") {
|
|
788
|
+
return {
|
|
789
|
+
status: "busy",
|
|
790
|
+
message: "这个 Marketplace 正在执行另一项写入;请等待完成后重试。",
|
|
791
|
+
diskChanged: false,
|
|
792
|
+
changedPaths: [],
|
|
793
|
+
cleanupComplete: true,
|
|
794
|
+
transaction: {
|
|
795
|
+
canonical: "unchanged",
|
|
796
|
+
scaffolds: "none",
|
|
797
|
+
cleanupComplete: true,
|
|
798
|
+
},
|
|
799
|
+
};
|
|
800
|
+
}
|
|
801
|
+
const stagingRelative = `${DOCUMENT_STAGING_PREFIX}${token}`;
|
|
802
|
+
let stagingRoot;
|
|
803
|
+
let stagingCreated = false;
|
|
804
|
+
let targetPath;
|
|
805
|
+
let backupPath;
|
|
806
|
+
let documentReplaced = false;
|
|
807
|
+
const createdFiles = [];
|
|
808
|
+
const createdDirectories = [];
|
|
809
|
+
let outcome;
|
|
810
|
+
let transactionCanonical = "unchanged";
|
|
811
|
+
let transactionScaffolds = "none";
|
|
812
|
+
try {
|
|
813
|
+
dependencies.afterLock?.();
|
|
814
|
+
cleanupAbandonedPrepareFiles(authorization.canonicalPath, token, dependencies.isProcessAlive ?? defaultProcessAlive);
|
|
815
|
+
targetPath = resolveAuthorizedPath(authorization, request.relativePath);
|
|
816
|
+
const initialBytes = readRegularFile(targetPath);
|
|
817
|
+
const initialMode = lstatSync(targetPath).mode & 0o777;
|
|
818
|
+
const currentRevision = documentRevision(initialBytes);
|
|
819
|
+
if (currentRevision !== request.expectedRevision) {
|
|
820
|
+
outcome = {
|
|
821
|
+
status: "conflict",
|
|
822
|
+
currentRevision,
|
|
823
|
+
diskChanged: false,
|
|
824
|
+
changedPaths: [],
|
|
825
|
+
cleanupComplete: true,
|
|
826
|
+
};
|
|
827
|
+
}
|
|
828
|
+
else {
|
|
829
|
+
const targets = request.createFiles.map((file) => ({
|
|
830
|
+
file,
|
|
831
|
+
path: resolveAuthorizedPath(authorization, file.relativePath),
|
|
832
|
+
}));
|
|
833
|
+
const occupied = targets.find(({ path }) => existsSync(path));
|
|
834
|
+
if (occupied !== undefined) {
|
|
835
|
+
throw new Error(`新增文件已经存在,未覆盖:${occupied.file.relativePath}`);
|
|
836
|
+
}
|
|
837
|
+
stagingRoot = resolveAuthorizedPath(authorization, stagingRelative);
|
|
838
|
+
mkdirSync(stagingRoot, { mode: 0o700 });
|
|
839
|
+
stagingCreated = true;
|
|
840
|
+
const nextPath = join(stagingRoot, "document.next");
|
|
841
|
+
backupPath = join(stagingRoot, "document.previous");
|
|
842
|
+
dependencies.beforeStageWrite?.(nextPath);
|
|
843
|
+
writeFileSync(nextPath, request.nextBytes, { flag: "wx", mode: 0o600 });
|
|
844
|
+
chmodSync(nextPath, initialMode);
|
|
845
|
+
(dependencies.syncStagedFile ?? syncFile)(nextPath);
|
|
846
|
+
writeFileSync(backupPath, initialBytes, { flag: "wx", mode: 0o600 });
|
|
847
|
+
chmodSync(backupPath, initialMode);
|
|
848
|
+
(dependencies.syncStagedFile ?? syncFile)(backupPath);
|
|
849
|
+
const stagedFiles = targets.map(({ file, path }, index) => {
|
|
850
|
+
const stagedPath = join(stagingRoot, `create-${index}`);
|
|
851
|
+
writeFileSync(stagedPath, file.bytes, { flag: "wx", mode: 0o600 });
|
|
852
|
+
chmodSync(stagedPath, file.mode);
|
|
853
|
+
(dependencies.syncStagedFile ?? syncFile)(stagedPath);
|
|
854
|
+
return { file, path, stagedPath };
|
|
855
|
+
});
|
|
856
|
+
dependencies.afterStageWrite?.(nextPath);
|
|
857
|
+
dependencies.beforeRevisionRecheck?.(targetPath);
|
|
858
|
+
const precommitRevision = documentRevision(readRegularFile(targetPath));
|
|
859
|
+
if (precommitRevision !== request.expectedRevision) {
|
|
860
|
+
outcome = {
|
|
861
|
+
status: "conflict",
|
|
862
|
+
currentRevision: precommitRevision,
|
|
863
|
+
diskChanged: false,
|
|
864
|
+
changedPaths: [],
|
|
865
|
+
cleanupComplete: true,
|
|
866
|
+
};
|
|
867
|
+
}
|
|
868
|
+
else {
|
|
869
|
+
for (const entry of stagedFiles) {
|
|
870
|
+
const resolvedAgain = resolveAuthorizedPath(authorization, entry.file.relativePath);
|
|
871
|
+
if (resolvedAgain !== entry.path || existsSync(resolvedAgain)) {
|
|
872
|
+
throw new Error(`新增文件在提交前发生变化,未覆盖:${entry.file.relativePath}`);
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
for (const entry of stagedFiles) {
|
|
876
|
+
createAuthorizedParents(authorization, entry.path, createdDirectories);
|
|
877
|
+
dependencies.beforeCreateFile?.(entry.stagedPath, entry.path);
|
|
878
|
+
(dependencies.createFile ??
|
|
879
|
+
((source, target) => copyFileSync(source, target, fsConstants.COPYFILE_EXCL)))(entry.stagedPath, entry.path);
|
|
880
|
+
chmodSync(entry.path, entry.file.mode);
|
|
881
|
+
(dependencies.syncStagedFile ?? syncFile)(entry.path);
|
|
882
|
+
createdFiles.push({
|
|
883
|
+
relativePath: entry.file.relativePath,
|
|
884
|
+
path: entry.path,
|
|
885
|
+
});
|
|
886
|
+
}
|
|
887
|
+
(dependencies.replaceDocument ?? renameSync)(nextPath, targetPath);
|
|
888
|
+
documentReplaced = true;
|
|
889
|
+
const syncDirectory = dependencies.syncParentDirectory ??
|
|
890
|
+
((directory) => syncFile(directory));
|
|
891
|
+
const parents = new Set([
|
|
892
|
+
authorization.canonicalPath,
|
|
893
|
+
dirname(targetPath),
|
|
894
|
+
...createdFiles.map((entry) => dirname(entry.path)),
|
|
895
|
+
]);
|
|
896
|
+
for (const parent of parents)
|
|
897
|
+
syncDirectory(parent);
|
|
898
|
+
dependencies.afterReplace?.(targetPath);
|
|
899
|
+
transactionCanonical = "committed";
|
|
900
|
+
transactionScaffolds = "committed";
|
|
901
|
+
outcome = {
|
|
902
|
+
status: "committed",
|
|
903
|
+
revision: documentRevision(request.nextBytes),
|
|
904
|
+
diskChanged: true,
|
|
905
|
+
changedPaths: [
|
|
906
|
+
request.relativePath,
|
|
907
|
+
...createdFiles.map((entry) => entry.relativePath),
|
|
908
|
+
],
|
|
909
|
+
cleanupComplete: true,
|
|
910
|
+
};
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
catch (error) {
|
|
915
|
+
const rollbackFailures = [];
|
|
916
|
+
const documentWasReplaced = documentReplaced;
|
|
917
|
+
let documentRestored = false;
|
|
918
|
+
if (documentReplaced &&
|
|
919
|
+
backupPath !== undefined &&
|
|
920
|
+
targetPath !== undefined) {
|
|
921
|
+
try {
|
|
922
|
+
(dependencies.rollbackDocument ?? renameSync)(backupPath, targetPath);
|
|
923
|
+
documentReplaced = false;
|
|
924
|
+
documentRestored = true;
|
|
925
|
+
}
|
|
926
|
+
catch (rollbackError) {
|
|
927
|
+
rollbackFailures.push(`恢复插件声明失败:${failureMessage(rollbackError)}`);
|
|
928
|
+
}
|
|
929
|
+
}
|
|
930
|
+
for (const entry of [...createdFiles].reverse()) {
|
|
931
|
+
if (!existsSync(entry.path))
|
|
932
|
+
continue;
|
|
933
|
+
try {
|
|
934
|
+
(dependencies.rollbackFile ?? unlinkSync)(entry.path);
|
|
935
|
+
}
|
|
936
|
+
catch (rollbackError) {
|
|
937
|
+
rollbackFailures.push(`回滚新增文件失败(${entry.relativePath}):${failureMessage(rollbackError)}`);
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
for (const directoryRelative of [...createdDirectories].reverse()) {
|
|
941
|
+
const path = resolveAuthorizedPath(authorization, directoryRelative);
|
|
942
|
+
if (!existsSync(path))
|
|
943
|
+
continue;
|
|
944
|
+
try {
|
|
945
|
+
rmdirSync(path);
|
|
946
|
+
}
|
|
947
|
+
catch (rollbackError) {
|
|
948
|
+
rollbackFailures.push(`回滚新增目录失败(${directoryRelative}):${failureMessage(rollbackError)}`);
|
|
949
|
+
}
|
|
950
|
+
}
|
|
951
|
+
if (documentRestored && targetPath !== undefined) {
|
|
952
|
+
try {
|
|
953
|
+
const syncDirectory = dependencies.syncParentDirectory ??
|
|
954
|
+
((directory) => syncFile(directory));
|
|
955
|
+
syncDirectory(dirname(targetPath));
|
|
956
|
+
syncDirectory(authorization.canonicalPath);
|
|
957
|
+
}
|
|
958
|
+
catch (rollbackSyncError) {
|
|
959
|
+
rollbackFailures.push(`恢复后的声明未能确认耐久同步:${failureMessage(rollbackSyncError)}`);
|
|
960
|
+
}
|
|
961
|
+
}
|
|
962
|
+
const residual = [
|
|
963
|
+
...(documentReplaced ? [request.relativePath] : []),
|
|
964
|
+
...createdFiles
|
|
965
|
+
.filter((entry) => existsSync(entry.path))
|
|
966
|
+
.map((entry) => entry.relativePath),
|
|
967
|
+
];
|
|
968
|
+
transactionCanonical = documentWasReplaced
|
|
969
|
+
? documentRestored && rollbackFailures.length === 0
|
|
970
|
+
? "restored"
|
|
971
|
+
: "uncertain"
|
|
972
|
+
: "unchanged";
|
|
973
|
+
transactionScaffolds =
|
|
974
|
+
createdFiles.length === 0
|
|
975
|
+
? "none"
|
|
976
|
+
: residual.some((path) => createdFiles.some((entry) => entry.relativePath === path))
|
|
977
|
+
? "residual"
|
|
978
|
+
: "rolled-back";
|
|
979
|
+
if (transactionCanonical === "uncertain" &&
|
|
980
|
+
!residual.includes(request.relativePath)) {
|
|
981
|
+
residual.unshift(request.relativePath);
|
|
982
|
+
}
|
|
983
|
+
outcome = {
|
|
984
|
+
status: "failed",
|
|
985
|
+
message: [
|
|
986
|
+
`组件变更没有完整提交:${failureMessage(error)}`,
|
|
987
|
+
...rollbackFailures,
|
|
988
|
+
].join(";"),
|
|
989
|
+
diskChanged: residual.length > 0,
|
|
990
|
+
changedPaths: residual,
|
|
991
|
+
cleanupComplete: rollbackFailures.length === 0,
|
|
992
|
+
};
|
|
993
|
+
}
|
|
994
|
+
const cleanupFailures = [];
|
|
995
|
+
const residualPaths = new Set(outcome.changedPaths);
|
|
996
|
+
if (stagingRoot !== undefined && stagingCreated) {
|
|
997
|
+
try {
|
|
998
|
+
(dependencies.cleanupStaging ??
|
|
999
|
+
((path) => rmSync(path, { recursive: true, force: true })))(stagingRoot);
|
|
1000
|
+
}
|
|
1001
|
+
catch (error) {
|
|
1002
|
+
cleanupFailures.push(`临时目录清理失败:${failureMessage(error)}`);
|
|
1003
|
+
residualPaths.add(stagingRelative);
|
|
1004
|
+
}
|
|
1005
|
+
}
|
|
1006
|
+
try {
|
|
1007
|
+
(dependencies.cleanupLock ?? releaseWorkspaceLock)(lock.path, token);
|
|
1008
|
+
}
|
|
1009
|
+
catch (error) {
|
|
1010
|
+
cleanupFailures.push(`写入锁清理失败:${failureMessage(error)}`);
|
|
1011
|
+
if (existsSync(lock.path))
|
|
1012
|
+
residualPaths.add(WORKSPACE_WRITE_LOCK);
|
|
1013
|
+
}
|
|
1014
|
+
finally {
|
|
1015
|
+
activeLockTokens.delete(token);
|
|
1016
|
+
}
|
|
1017
|
+
if (cleanupFailures.length > 0) {
|
|
1018
|
+
return {
|
|
1019
|
+
status: "failed",
|
|
1020
|
+
message: `${outcome.status === "failed"
|
|
1021
|
+
? outcome.message
|
|
1022
|
+
: outcome.status === "conflict"
|
|
1023
|
+
? "源文件 revision 已变化,没有提交组件变更"
|
|
1024
|
+
: "组件声明和必要文件已提交"};${cleanupFailures.join(";")}`,
|
|
1025
|
+
diskChanged: outcome.diskChanged || residualPaths.size > 0,
|
|
1026
|
+
changedPaths: [...residualPaths],
|
|
1027
|
+
cleanupComplete: false,
|
|
1028
|
+
transaction: {
|
|
1029
|
+
canonical: transactionCanonical,
|
|
1030
|
+
scaffolds: transactionScaffolds,
|
|
1031
|
+
cleanupComplete: false,
|
|
1032
|
+
},
|
|
1033
|
+
};
|
|
1034
|
+
}
|
|
1035
|
+
return {
|
|
1036
|
+
...outcome,
|
|
1037
|
+
transaction: {
|
|
1038
|
+
canonical: transactionCanonical,
|
|
1039
|
+
scaffolds: transactionScaffolds,
|
|
1040
|
+
cleanupComplete: outcome.cleanupComplete,
|
|
1041
|
+
},
|
|
1042
|
+
};
|
|
1043
|
+
}
|
|
1044
|
+
function validArtifactRelativePath(relativePath) {
|
|
1045
|
+
return (relativePath.length > 0 &&
|
|
1046
|
+
relativePath !== "." &&
|
|
1047
|
+
relativePath !== ".." &&
|
|
1048
|
+
!isAbsolute(relativePath) &&
|
|
1049
|
+
relativePath !== WORKSPACE_WRITE_LOCK &&
|
|
1050
|
+
!relativePath.startsWith(DOCUMENT_STAGING_PREFIX) &&
|
|
1051
|
+
!relativePath.startsWith(LOCK_PREPARE_PREFIX) &&
|
|
1052
|
+
!relativePath.startsWith(LOCK_RECLAIM_PREFIX));
|
|
1053
|
+
}
|
|
1054
|
+
function artifactRevisionAt(authorization, relativePath) {
|
|
1055
|
+
const path = resolveAuthorizedPath(authorization, relativePath);
|
|
1056
|
+
if (!existsSync(path))
|
|
1057
|
+
return "missing";
|
|
1058
|
+
return documentRevision(readRegularFile(path));
|
|
1059
|
+
}
|
|
1060
|
+
/**
|
|
1061
|
+
* Commits one generated-artifact consistency group under the same
|
|
1062
|
+
* workspace-wide lock used by canonical document writes. All bytes and
|
|
1063
|
+
* deletions are staged before the first canonical mutation. A failure rolls
|
|
1064
|
+
* already-applied members back from private backups; callers must treat any
|
|
1065
|
+
* cleanup uncertainty as failure rather than declaring the group successful.
|
|
1066
|
+
*/
|
|
1067
|
+
export function commitAuthorizedArtifactGroup(request, dependencies = {}) {
|
|
1068
|
+
const paths = [
|
|
1069
|
+
...request.expectations.map((item) => item.relativePath),
|
|
1070
|
+
...request.mutations.map((item) => item.relativePath),
|
|
1071
|
+
];
|
|
1072
|
+
if (request.mutations.length === 0 ||
|
|
1073
|
+
paths.some((path) => !validArtifactRelativePath(path)) ||
|
|
1074
|
+
new Set(request.mutations.map((item) => item.relativePath)).size !==
|
|
1075
|
+
request.mutations.length ||
|
|
1076
|
+
new Set(request.expectations.map((item) => item.relativePath)).size !==
|
|
1077
|
+
request.expectations.length) {
|
|
1078
|
+
return {
|
|
1079
|
+
status: "failed",
|
|
1080
|
+
message: "生成物提交计划包含空、重复、保留或越界目标。",
|
|
1081
|
+
changedPaths: [],
|
|
1082
|
+
diskChanged: false,
|
|
1083
|
+
rolledBack: false,
|
|
1084
|
+
cleanupComplete: true,
|
|
1085
|
+
};
|
|
1086
|
+
}
|
|
1087
|
+
let authorization;
|
|
1088
|
+
try {
|
|
1089
|
+
authorization = authorizeExistingDirectory(request.directory);
|
|
1090
|
+
for (const path of paths)
|
|
1091
|
+
resolveAuthorizedPath(authorization, path);
|
|
1092
|
+
}
|
|
1093
|
+
catch (error) {
|
|
1094
|
+
return {
|
|
1095
|
+
status: "failed",
|
|
1096
|
+
message: `无法获得生成物写入授权:${failureMessage(error)}`,
|
|
1097
|
+
changedPaths: [],
|
|
1098
|
+
diskChanged: false,
|
|
1099
|
+
rolledBack: false,
|
|
1100
|
+
cleanupComplete: true,
|
|
1101
|
+
};
|
|
1102
|
+
}
|
|
1103
|
+
const token = (dependencies.createToken ?? randomUUID)();
|
|
1104
|
+
const lock = acquireWorkspaceLock(authorization.canonicalPath, token, (dependencies.now ?? (() => new Date()))(), dependencies.isProcessAlive ?? defaultProcessAlive, dependencies.beforeLockSync, dependencies.afterReclaimLink);
|
|
1105
|
+
if (lock.status === "failed") {
|
|
1106
|
+
return { ...lock, rolledBack: false };
|
|
1107
|
+
}
|
|
1108
|
+
if (lock.status === "busy") {
|
|
1109
|
+
return {
|
|
1110
|
+
status: "busy",
|
|
1111
|
+
message: "这个 Marketplace 正在执行另一项写入;生成物没有在后台排队。",
|
|
1112
|
+
changedPaths: [],
|
|
1113
|
+
diskChanged: false,
|
|
1114
|
+
rolledBack: false,
|
|
1115
|
+
cleanupComplete: true,
|
|
1116
|
+
};
|
|
1117
|
+
}
|
|
1118
|
+
const stagingRelative = `${DOCUMENT_STAGING_PREFIX}${token}`;
|
|
1119
|
+
const stagingRoot = resolveAuthorizedPath(authorization, stagingRelative);
|
|
1120
|
+
const staged = new Map();
|
|
1121
|
+
const backups = new Map();
|
|
1122
|
+
const createdDirectories = [];
|
|
1123
|
+
const applied = [];
|
|
1124
|
+
const finalizationFailures = [];
|
|
1125
|
+
let stagingCreated = false;
|
|
1126
|
+
let rollbackAttempted = false;
|
|
1127
|
+
let rollbackConfirmed = false;
|
|
1128
|
+
let outcome;
|
|
1129
|
+
try {
|
|
1130
|
+
cleanupAbandonedPrepareFiles(authorization.canonicalPath, token, dependencies.isProcessAlive ?? defaultProcessAlive);
|
|
1131
|
+
for (const expectation of request.expectations) {
|
|
1132
|
+
if (artifactRevisionAt(authorization, expectation.relativePath) !==
|
|
1133
|
+
expectation.revision) {
|
|
1134
|
+
outcome = {
|
|
1135
|
+
status: "conflict",
|
|
1136
|
+
message: "生成计划建立后 canonical source 或目标生成物已变化;没有提交旧快照。",
|
|
1137
|
+
changedPaths: [],
|
|
1138
|
+
diskChanged: false,
|
|
1139
|
+
rolledBack: false,
|
|
1140
|
+
cleanupComplete: true,
|
|
1141
|
+
};
|
|
1142
|
+
throw new ArtifactGroupOutcome(outcome);
|
|
1143
|
+
}
|
|
1144
|
+
}
|
|
1145
|
+
const unchanged = request.mutations.every((mutation) => {
|
|
1146
|
+
const target = resolveAuthorizedPath(authorization, mutation.relativePath);
|
|
1147
|
+
if (mutation.action === "delete")
|
|
1148
|
+
return !existsSync(target);
|
|
1149
|
+
return (existsSync(target) &&
|
|
1150
|
+
lstatSync(target).isFile() &&
|
|
1151
|
+
Buffer.from(mutation.bytes).equals(readFileSync(target)));
|
|
1152
|
+
});
|
|
1153
|
+
if (unchanged) {
|
|
1154
|
+
outcome = {
|
|
1155
|
+
status: "verified",
|
|
1156
|
+
changedPaths: [],
|
|
1157
|
+
diskChanged: false,
|
|
1158
|
+
rolledBack: false,
|
|
1159
|
+
cleanupComplete: true,
|
|
1160
|
+
};
|
|
1161
|
+
throw new ArtifactGroupOutcome(outcome);
|
|
1162
|
+
}
|
|
1163
|
+
mkdirSync(stagingRoot, { mode: 0o700 });
|
|
1164
|
+
stagingCreated = true;
|
|
1165
|
+
request.mutations.forEach((mutation, index) => {
|
|
1166
|
+
if (mutation.action !== "write")
|
|
1167
|
+
return;
|
|
1168
|
+
const path = join(stagingRoot, `next-${index}`);
|
|
1169
|
+
writeFileSync(path, mutation.bytes, { mode: mutation.mode ?? 0o600 });
|
|
1170
|
+
syncFile(path);
|
|
1171
|
+
staged.set(mutation.relativePath, path);
|
|
1172
|
+
});
|
|
1173
|
+
request.mutations.forEach((mutation, index) => {
|
|
1174
|
+
const target = resolveAuthorizedPath(authorization, mutation.relativePath);
|
|
1175
|
+
if (existsSync(target)) {
|
|
1176
|
+
if (!lstatSync(target).isFile()) {
|
|
1177
|
+
throw new Error(`生成物目标不是普通文件:${mutation.relativePath}`);
|
|
1178
|
+
}
|
|
1179
|
+
const backup = join(stagingRoot, `before-${index}`);
|
|
1180
|
+
copyFileSync(target, backup);
|
|
1181
|
+
chmodSync(backup, lstatSync(target).mode & 0o777);
|
|
1182
|
+
syncFile(backup);
|
|
1183
|
+
backups.set(mutation.relativePath, backup);
|
|
1184
|
+
}
|
|
1185
|
+
});
|
|
1186
|
+
for (const mutation of request.mutations) {
|
|
1187
|
+
const target = resolveAuthorizedPath(authorization, mutation.relativePath);
|
|
1188
|
+
if (mutation.action === "delete" && !existsSync(target)) {
|
|
1189
|
+
continue;
|
|
1190
|
+
}
|
|
1191
|
+
dependencies.beforeMutation?.(mutation.relativePath);
|
|
1192
|
+
const backup = backups.get(mutation.relativePath);
|
|
1193
|
+
if (backup !== undefined && existsSync(target)) {
|
|
1194
|
+
unlinkSync(target);
|
|
1195
|
+
applied.push(mutation.relativePath);
|
|
1196
|
+
}
|
|
1197
|
+
if (mutation.action === "write") {
|
|
1198
|
+
createAuthorizedParents(authorization, target, createdDirectories);
|
|
1199
|
+
(dependencies.replaceArtifact ?? renameSync)(staged.get(mutation.relativePath), target);
|
|
1200
|
+
if (!applied.includes(mutation.relativePath)) {
|
|
1201
|
+
applied.push(mutation.relativePath);
|
|
1202
|
+
}
|
|
1203
|
+
chmodSync(target, mutation.mode ?? 0o644);
|
|
1204
|
+
}
|
|
1205
|
+
syncFile(dirname(target));
|
|
1206
|
+
}
|
|
1207
|
+
outcome = {
|
|
1208
|
+
status: "committed",
|
|
1209
|
+
changedPaths: [...applied],
|
|
1210
|
+
diskChanged: applied.length > 0,
|
|
1211
|
+
rolledBack: false,
|
|
1212
|
+
cleanupComplete: true,
|
|
1213
|
+
};
|
|
1214
|
+
}
|
|
1215
|
+
catch (error) {
|
|
1216
|
+
if (error instanceof ArtifactGroupOutcome) {
|
|
1217
|
+
outcome = error.outcome;
|
|
1218
|
+
}
|
|
1219
|
+
else {
|
|
1220
|
+
rollbackAttempted = true;
|
|
1221
|
+
const rollbackFailures = [];
|
|
1222
|
+
for (const relativePath of [...applied].reverse()) {
|
|
1223
|
+
const target = resolveAuthorizedPath(authorization, relativePath);
|
|
1224
|
+
const backup = backups.get(relativePath);
|
|
1225
|
+
try {
|
|
1226
|
+
dependencies.beforeRollback?.(relativePath);
|
|
1227
|
+
if (existsSync(target))
|
|
1228
|
+
unlinkSync(target);
|
|
1229
|
+
if (backup !== undefined && existsSync(backup)) {
|
|
1230
|
+
renameSync(backup, target);
|
|
1231
|
+
}
|
|
1232
|
+
syncFile(dirname(target));
|
|
1233
|
+
}
|
|
1234
|
+
catch (rollbackError) {
|
|
1235
|
+
rollbackFailures.push(`${relativePath}: ${failureMessage(rollbackError)}`);
|
|
1236
|
+
}
|
|
1237
|
+
}
|
|
1238
|
+
for (const relativeDirectory of [...createdDirectories].reverse()) {
|
|
1239
|
+
const path = resolveAuthorizedPath(authorization, relativeDirectory);
|
|
1240
|
+
try {
|
|
1241
|
+
if (existsSync(path) && readdirSync(path).length === 0) {
|
|
1242
|
+
(dependencies.removeCreatedDirectory ??
|
|
1243
|
+
((targetPath) => rmdirSync(targetPath)))(path, relativeDirectory);
|
|
1244
|
+
}
|
|
1245
|
+
}
|
|
1246
|
+
catch (cleanupError) {
|
|
1247
|
+
finalizationFailures.push(`${relativeDirectory}: ${failureMessage(cleanupError)}`);
|
|
1248
|
+
}
|
|
1249
|
+
}
|
|
1250
|
+
rollbackConfirmed = rollbackFailures.length === 0;
|
|
1251
|
+
outcome = {
|
|
1252
|
+
status: "failed",
|
|
1253
|
+
message: [
|
|
1254
|
+
`生成物一致性组没有完整提交:${failureMessage(error)}`,
|
|
1255
|
+
...rollbackFailures,
|
|
1256
|
+
].join(";"),
|
|
1257
|
+
changedPaths: [],
|
|
1258
|
+
diskChanged: false,
|
|
1259
|
+
rolledBack: false,
|
|
1260
|
+
cleanupComplete: true,
|
|
1261
|
+
};
|
|
1262
|
+
}
|
|
1263
|
+
}
|
|
1264
|
+
if (stagingCreated) {
|
|
1265
|
+
try {
|
|
1266
|
+
(dependencies.cleanupStaging ??
|
|
1267
|
+
((path) => rmSync(path, { recursive: true, force: true })))(stagingRoot);
|
|
1268
|
+
}
|
|
1269
|
+
catch (error) {
|
|
1270
|
+
finalizationFailures.push(`临时目录清理失败:${failureMessage(error)}`);
|
|
1271
|
+
}
|
|
1272
|
+
}
|
|
1273
|
+
try {
|
|
1274
|
+
(dependencies.cleanupLock ?? releaseWorkspaceLock)(lock.path, token);
|
|
1275
|
+
}
|
|
1276
|
+
catch (error) {
|
|
1277
|
+
finalizationFailures.push(`写入锁清理失败:${failureMessage(error)}`);
|
|
1278
|
+
}
|
|
1279
|
+
finally {
|
|
1280
|
+
activeLockTokens.delete(token);
|
|
1281
|
+
}
|
|
1282
|
+
const expectationByPath = new Map(request.expectations.map((item) => [item.relativePath, item.revision]));
|
|
1283
|
+
const canonicalResiduals = [...new Set(applied)].filter((relativePath) => {
|
|
1284
|
+
const expected = expectationByPath.get(relativePath);
|
|
1285
|
+
if (expected === undefined)
|
|
1286
|
+
return true;
|
|
1287
|
+
try {
|
|
1288
|
+
return artifactRevisionAt(authorization, relativePath) !== expected;
|
|
1289
|
+
}
|
|
1290
|
+
catch {
|
|
1291
|
+
return true;
|
|
1292
|
+
}
|
|
1293
|
+
});
|
|
1294
|
+
const transactionResiduals = [
|
|
1295
|
+
...(rollbackAttempted
|
|
1296
|
+
? createdDirectories.filter((relativePath) => existsSync(resolveAuthorizedPath(authorization, relativePath)))
|
|
1297
|
+
: []),
|
|
1298
|
+
...(existsSync(stagingRoot) ? [stagingRelative] : []),
|
|
1299
|
+
...(existsSync(lock.path) ? [WORKSPACE_WRITE_LOCK] : []),
|
|
1300
|
+
];
|
|
1301
|
+
if (transactionResiduals.length > 0 &&
|
|
1302
|
+
finalizationFailures.length === 0) {
|
|
1303
|
+
finalizationFailures.push("生成物事务仍有未清理的本机残留");
|
|
1304
|
+
}
|
|
1305
|
+
const reportedCanonicalPaths = outcome.status === "committed"
|
|
1306
|
+
? [...new Set(applied)]
|
|
1307
|
+
: canonicalResiduals;
|
|
1308
|
+
const changedPaths = [
|
|
1309
|
+
...reportedCanonicalPaths,
|
|
1310
|
+
...transactionResiduals.filter((path) => !reportedCanonicalPaths.includes(path)),
|
|
1311
|
+
];
|
|
1312
|
+
const cleanupComplete = transactionResiduals.length === 0;
|
|
1313
|
+
if (outcome.status === "failed" ||
|
|
1314
|
+
finalizationFailures.length > 0 ||
|
|
1315
|
+
!cleanupComplete) {
|
|
1316
|
+
return {
|
|
1317
|
+
status: "failed",
|
|
1318
|
+
message: [
|
|
1319
|
+
outcome.status === "failed"
|
|
1320
|
+
? outcome.message
|
|
1321
|
+
: "生成物 canonical 变更已结束",
|
|
1322
|
+
...finalizationFailures,
|
|
1323
|
+
].join(";"),
|
|
1324
|
+
changedPaths,
|
|
1325
|
+
diskChanged: changedPaths.length > 0,
|
|
1326
|
+
rolledBack: outcome.status === "failed" &&
|
|
1327
|
+
applied.length > 0 &&
|
|
1328
|
+
rollbackConfirmed &&
|
|
1329
|
+
canonicalResiduals.length === 0,
|
|
1330
|
+
cleanupComplete,
|
|
1331
|
+
};
|
|
1332
|
+
}
|
|
1333
|
+
if (outcome.status === "committed") {
|
|
1334
|
+
return {
|
|
1335
|
+
status: "committed",
|
|
1336
|
+
changedPaths: reportedCanonicalPaths,
|
|
1337
|
+
diskChanged: reportedCanonicalPaths.length > 0,
|
|
1338
|
+
rolledBack: false,
|
|
1339
|
+
cleanupComplete: true,
|
|
1340
|
+
};
|
|
1341
|
+
}
|
|
1342
|
+
return outcome;
|
|
1343
|
+
}
|
|
1344
|
+
class ArtifactGroupOutcome extends Error {
|
|
1345
|
+
outcome;
|
|
1346
|
+
constructor(outcome) {
|
|
1347
|
+
super(outcome.status);
|
|
1348
|
+
this.name = "ArtifactGroupOutcome";
|
|
1349
|
+
this.outcome = outcome;
|
|
1350
|
+
}
|
|
1351
|
+
}
|
|
1352
|
+
//# sourceMappingURL=document-commit.js.map
|