iterate-plugin 3.5.6 → 3.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +64 -20
- package/README.zh-CN.md +53 -17
- package/dist/approval-gate.js +143 -23
- package/dist/atomic-fs.js +148 -17
- package/dist/config-loader.js +509 -34
- package/dist/config-write.js +83 -22
- package/dist/evidence.js +141 -87
- package/dist/file-lock.js +165 -0
- package/dist/index.js +54 -28
- package/dist/jobs.js +56 -11
- package/dist/live.js +52 -13
- package/dist/meta-review.js +132 -28
- package/dist/method-scope.js +31 -8
- package/dist/review-scope.js +32 -9
- package/dist/review.js +96 -17
- package/dist/session-hooks.js +55 -13
- package/dist/skill-prompt.js +417 -67
- package/dist/tools/checkpoint.js +85 -19
- package/dist/tools/config.js +44 -2
- package/dist/tools/context.js +22 -5
- package/dist/tools/decision-log.js +122 -27
- package/dist/tools/defense-events.js +90 -31
- package/dist/tools/defense-store.js +28 -3
- package/dist/tools/experience-bank.js +108 -43
- package/dist/tools/experience-store.js +54 -4
- package/dist/tools/fix.js +347 -119
- package/dist/tools/history.js +68 -14
- package/dist/tools/present.js +80 -0
- package/dist/tools/prune.js +156 -53
- package/dist/tools/quality-gate.js +52 -2
- package/dist/tools/quality-store.js +17 -1
- package/dist/tools/review.js +158 -7
- package/dist/tools/transcript.js +173 -36
- package/dist/tools/triage.js +67 -12
- package/dist/tools/validate.js +115 -24
- package/dist/transcript.js +267 -4
- package/lib/client.js +945 -181
- package/lib/parse.js +760 -57
- package/package.json +15 -12
- package/src/approval-gate.ts +160 -23
- package/src/atomic-fs.ts +152 -17
- package/src/client/index.ts +600 -129
- package/src/config-loader.ts +542 -35
- package/src/config-write.ts +108 -26
- package/src/evidence.ts +194 -92
- package/src/file-lock.ts +184 -0
- package/src/index.ts +53 -29
- package/src/jobs.ts +63 -12
- package/src/live.ts +48 -11
- package/src/meta-review.ts +151 -34
- package/src/method-scope.ts +31 -8
- package/src/review-scope.ts +33 -10
- package/src/review.ts +96 -17
- package/src/session-hooks.ts +54 -12
- package/src/skill-prompt.ts +417 -67
- package/src/tools/checkpoint.ts +81 -19
- package/src/tools/config.ts +44 -2
- package/src/tools/context.ts +25 -5
- package/src/tools/decision-log.ts +127 -22
- package/src/tools/defense-events.ts +87 -31
- package/src/tools/defense-store.ts +30 -3
- package/src/tools/experience-bank.ts +112 -44
- package/src/tools/experience-store.ts +54 -5
- package/src/tools/fix.ts +258 -20
- package/src/tools/history.ts +74 -15
- package/src/tools/present.ts +89 -0
- package/src/tools/prune.ts +160 -51
- package/src/tools/quality-gate.ts +57 -2
- package/src/tools/quality-store.ts +18 -1
- package/src/tools/review.ts +155 -6
- package/src/tools/transcript.ts +178 -36
- package/src/tools/triage.ts +70 -13
- package/src/tools/validate.ts +138 -32
- package/src/transcript.ts +279 -4
- package/src/types.ts +29 -1
package/package.json
CHANGED
|
@@ -1,17 +1,16 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "iterate-plugin",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.6.0",
|
|
4
4
|
"description": "dsh plugin that turns the iterate skill into an autonomous closed-loop harness with quality command center and experience bank (v3.3). Features: plan -> parallel review xN -> atomic fixes -> validate -> loop -> auto-stop, plus dry-run pure-review mode, quality gate compute/persist, writable experience bank, defense events stream (record + bilingual labels), and native command buttons — with a live quality command center (F8/F9/F10 render real session data), §8 assign-fix instruction, and task_mode indicator wired end to end.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"repository": {
|
|
8
8
|
"type": "git",
|
|
9
|
-
"url": "https://github.com/jingzhao-l/iterate-
|
|
10
|
-
"directory": "harness/iterate-plugin"
|
|
9
|
+
"url": "https://github.com/jingzhao-l/iterate-plugin.git"
|
|
11
10
|
},
|
|
12
|
-
"homepage": "https://github.com/jingzhao-l/iterate-
|
|
11
|
+
"homepage": "https://github.com/jingzhao-l/iterate-plugin",
|
|
13
12
|
"bugs": {
|
|
14
|
-
"url": "https://github.com/jingzhao-l/iterate-
|
|
13
|
+
"url": "https://github.com/jingzhao-l/iterate-plugin/issues"
|
|
15
14
|
},
|
|
16
15
|
"keywords": [
|
|
17
16
|
"dsh-plugin",
|
|
@@ -42,7 +41,11 @@
|
|
|
42
41
|
"0.1.6-alpha.2": "compatible",
|
|
43
42
|
"0.1.7-alpha.1": "compatible",
|
|
44
43
|
"0.1.7-alpha.2": "compatible",
|
|
45
|
-
"0.1.7-rc.1": "compatible"
|
|
44
|
+
"0.1.7-rc.1": "compatible",
|
|
45
|
+
"0.1.7-rc.2": "compatible",
|
|
46
|
+
"0.2.0-rc.1": "compatible",
|
|
47
|
+
"0.2.0-rc.2": "compatible",
|
|
48
|
+
"0.2.1-alpha.1": "compatible"
|
|
46
49
|
}
|
|
47
50
|
},
|
|
48
51
|
"client": {
|
|
@@ -79,15 +82,15 @@
|
|
|
79
82
|
"test:validate": "tsx --test test/validate.test.ts"
|
|
80
83
|
},
|
|
81
84
|
"dependencies": {
|
|
82
|
-
"@deepseek-ai/cordis": "4.0.
|
|
83
|
-
"@deepseek-ai/dsh-tools": "0.1
|
|
84
|
-
"@deepseek-ai/dsh-util-values": "0.1
|
|
85
|
+
"@deepseek-ai/cordis": "4.0.5-alpha.1",
|
|
86
|
+
"@deepseek-ai/dsh-tools": "0.2.1-alpha.1",
|
|
87
|
+
"@deepseek-ai/dsh-util-values": "0.2.1-alpha.1",
|
|
85
88
|
"js-yaml": "4.3.2"
|
|
86
89
|
},
|
|
87
90
|
"devDependencies": {
|
|
88
|
-
"@deepseek-ai/dsh-agent": "0.1
|
|
89
|
-
"@deepseek-ai/dsh-jobs": "0.1
|
|
90
|
-
"@deepseek-ai/dsh-session": "0.1
|
|
91
|
+
"@deepseek-ai/dsh-agent": "0.2.1-alpha.1",
|
|
92
|
+
"@deepseek-ai/dsh-jobs": "0.2.1-alpha.1",
|
|
93
|
+
"@deepseek-ai/dsh-session": "0.2.1-alpha.1",
|
|
91
94
|
"@types/js-yaml": "4.0.9",
|
|
92
95
|
"@types/node": "22.15.0",
|
|
93
96
|
"@types/react": "19.2.2",
|
package/src/approval-gate.ts
CHANGED
|
@@ -6,9 +6,19 @@
|
|
|
6
6
|
* any I/O, so it is fully unit-testable:
|
|
7
7
|
*
|
|
8
8
|
* - `{ kind: 'allow' }` → run the call.
|
|
9
|
-
* - `{ kind: 'ask', reason }`
|
|
9
|
+
* - `{ kind: 'ask', reason, displayReason }` → prompt the human via the dsh
|
|
10
|
+
* approval service (`displayReason` carries the localized prompt text;
|
|
11
|
+
* dsh 0.2.x renders it when present).
|
|
10
12
|
* - `{ kind: 'deny', reason }` → refuse; the caller surfaces the reason.
|
|
11
13
|
*
|
|
14
|
+
* LANGUAGE RULE (project `config.language`, zh|en):
|
|
15
|
+
* - Human-facing text (the `ask` prompt / `displayReason`) FOLLOWS the
|
|
16
|
+
* configured language.
|
|
17
|
+
* - Structured/durable text (the `deny` reason, `deny.info.reason`,
|
|
18
|
+
* decision-log and console output) STAYS ENGLISH so logs, audits, and
|
|
19
|
+
* machine matchers never depend on a locale. `ask.reason` is the audited
|
|
20
|
+
* English summary; `displayReason` is the localized prompt.
|
|
21
|
+
*
|
|
12
22
|
* Policy (config `observatory.approval`, default 'ask'):
|
|
13
23
|
* - `allow` → destructive iterate calls always run (debug/trusted).
|
|
14
24
|
* - `deny` → destructive iterate calls are always refused (fail-closed).
|
|
@@ -16,14 +26,33 @@
|
|
|
16
26
|
*
|
|
17
27
|
* Destructive calls are exactly the ones that mutate the workspace:
|
|
18
28
|
* `iterate_fix` (writes files), `iterate_rollback` (restores from backups),
|
|
19
|
-
*
|
|
29
|
+
* `iterate_prune` with `dryRun === false` (deletes `.iterate/` artifacts —
|
|
30
|
+
* prune is read-only by default, so only an explicit `dryRun: false` gates),
|
|
31
|
+
* and `iterate_config` with `operation === 'write'` (rewrites
|
|
32
|
+
* `iterate.config.yaml`). `iterate_config` writes matter because the config
|
|
33
|
+
* is policy: a silent `reviewer: {evidence_validation: false}` or a
|
|
34
|
+
* `validation.commands` addition changes what later rounds are allowed to do,
|
|
35
|
+
* so a write is consented to exactly like a file write — the read operation
|
|
36
|
+
* stays free.
|
|
20
37
|
* Read-only calls are always allowed. Non-iterate calls are untouched — the
|
|
21
38
|
* gate only ever inspects iterate tools so it cannot alter unrelated behavior.
|
|
22
39
|
*/
|
|
23
40
|
|
|
41
|
+
/** The languages the consent prompt can render (mirrors config.language). */
|
|
42
|
+
export type ApprovalLanguage = 'zh' | 'en'
|
|
43
|
+
|
|
44
|
+
/** Locale map accepted by dsh's `PreToolDecision.displayReason` (en is required). */
|
|
45
|
+
export type ApprovalDisplayReason = { readonly en: string; readonly [locale: string]: string }
|
|
46
|
+
|
|
24
47
|
export type ApprovalDecision =
|
|
25
48
|
| { kind: 'allow' }
|
|
26
|
-
| {
|
|
49
|
+
| {
|
|
50
|
+
kind: 'ask'
|
|
51
|
+
/** Audited, English, machine-readable summary of what is being asked. */
|
|
52
|
+
reason: string
|
|
53
|
+
/** Localized prompt text (en, plus zh when configured). */
|
|
54
|
+
displayReason: ApprovalDisplayReason
|
|
55
|
+
}
|
|
27
56
|
| { kind: 'deny'; reason: string }
|
|
28
57
|
|
|
29
58
|
/** The subset of a tool execution the gate inspects. */
|
|
@@ -32,38 +61,134 @@ export interface ToolExecutionLike {
|
|
|
32
61
|
arguments?: unknown
|
|
33
62
|
}
|
|
34
63
|
|
|
35
|
-
/**
|
|
36
|
-
const DESTRUCTIVE_TOOLS = new Set([
|
|
64
|
+
/** Iterate tools subject to the gate (some only in specific operations). */
|
|
65
|
+
const DESTRUCTIVE_TOOLS = new Set([
|
|
66
|
+
'iterate_fix',
|
|
67
|
+
'iterate_rollback',
|
|
68
|
+
'iterate_prune',
|
|
69
|
+
'iterate_config',
|
|
70
|
+
])
|
|
37
71
|
|
|
38
|
-
/**
|
|
39
|
-
|
|
72
|
+
/**
|
|
73
|
+
* True when this specific call MUTATES the workspace and therefore needs
|
|
74
|
+
* consent. Two of the four gated tools are operation-conditional:
|
|
75
|
+
* - `iterate_prune` is read-only unless `dryRun === false`;
|
|
76
|
+
* - `iterate_config` reads freely, but `operation === 'write'` rewrites
|
|
77
|
+
* `iterate.config.yaml` — the file that carries the approval policy,
|
|
78
|
+
* the reviewer gates, and the command allow-list.
|
|
79
|
+
*/
|
|
80
|
+
function mutatesWorkspace(toolName: string, args: Record<string, unknown>): boolean {
|
|
81
|
+
if (toolName === 'iterate_prune') return args.dryRun === false
|
|
82
|
+
if (toolName === 'iterate_config') return args.operation === 'write'
|
|
83
|
+
return DESTRUCTIVE_TOOLS.has(toolName)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Config updates the model supplied (empty when absent/malformed). */
|
|
87
|
+
function readUpdates(args: Record<string, unknown>): Record<string, unknown> {
|
|
88
|
+
const u = args.updates
|
|
89
|
+
return u && typeof u === 'object' && !Array.isArray(u) ? (u as Record<string, unknown>) : {}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Config switches that DISABLE a safety gate, for the consent text. Writing
|
|
94
|
+
* `reviewer.evidence_validation: false` is legal config — but the human being
|
|
95
|
+
* asked must see that the call is about to turn a review gate off, not just
|
|
96
|
+
* "update a file".
|
|
97
|
+
*/
|
|
98
|
+
function disabledReviewGates(updates: Record<string, unknown>): string[] {
|
|
99
|
+
const rv = updates.reviewer
|
|
100
|
+
if (!rv || typeof rv !== 'object' || Array.isArray(rv)) return []
|
|
101
|
+
const off: string[] = []
|
|
102
|
+
for (const key of ['output_schema_validation', 'evidence_validation', 'coverage_validation'] as const) {
|
|
103
|
+
if ((rv as Record<string, unknown>)[key] === false) off.push(`reviewer.${key}`)
|
|
104
|
+
}
|
|
105
|
+
return off
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Human-readable reason rendered in the approval prompt.
|
|
110
|
+
*
|
|
111
|
+
* `language` selects the wording of the HUMAN-FACING text; call it with
|
|
112
|
+
* `'en'` for the structured, machine-readable string (deny reasons, audited
|
|
113
|
+
* ask summaries) and with the project's configured language for the prompt
|
|
114
|
+
* text (`displayReason`). The structured deny reason therefore never
|
|
115
|
+
* localizes, while the consent prompt follows `config.language`.
|
|
116
|
+
*/
|
|
117
|
+
export function describe(
|
|
118
|
+
toolName: string,
|
|
119
|
+
arguments0?: Record<string, unknown>,
|
|
120
|
+
language: ApprovalLanguage = 'en',
|
|
121
|
+
): string {
|
|
122
|
+
const zh = language === 'zh'
|
|
40
123
|
const file =
|
|
41
124
|
arguments0 && typeof arguments0.file === 'string'
|
|
42
125
|
? `\`${arguments0.file}\``
|
|
43
|
-
:
|
|
126
|
+
: zh
|
|
127
|
+
? '工作区'
|
|
128
|
+
: 'the workspace'
|
|
44
129
|
switch (toolName) {
|
|
45
130
|
case 'iterate_fix':
|
|
46
|
-
return
|
|
131
|
+
return zh
|
|
132
|
+
? `对 ${file} 应用一次原子修复`
|
|
133
|
+
: `Apply an atomic fix to ${file}`
|
|
47
134
|
case 'iterate_rollback': {
|
|
135
|
+
// Rollback's parameters are `id` (required) + `path` (project root) —
|
|
136
|
+
// it has NO `file` param, so naming `arguments.file` could never
|
|
137
|
+
// identify the target ("restore the workspace from backup" every time).
|
|
138
|
+
// Name the fix id and, when present, the project scope instead.
|
|
48
139
|
const id =
|
|
49
|
-
arguments0 && typeof arguments0.id === 'string'
|
|
50
|
-
|
|
140
|
+
arguments0 && typeof arguments0.id === 'string' && arguments0.id
|
|
141
|
+
? `\`${arguments0.id}\``
|
|
142
|
+
: null
|
|
143
|
+
const scope =
|
|
144
|
+
arguments0 && typeof arguments0.path === 'string' && arguments0.path
|
|
145
|
+
? `\`${arguments0.path}\``
|
|
146
|
+
: null
|
|
147
|
+
const target = id ?? scope ?? (zh ? '一次修复' : 'a fix')
|
|
148
|
+
const inScope = id && scope ? (zh ? `(位于 ${scope})` : ` in ${scope}`) : ''
|
|
149
|
+
return zh
|
|
150
|
+
? `回滚修复 ${target}${inScope}(从备份恢复其文件)`
|
|
151
|
+
: `Revert fix ${target}${inScope} (restore its file from backup)`
|
|
51
152
|
}
|
|
52
153
|
case 'iterate_prune':
|
|
53
|
-
return
|
|
154
|
+
return zh
|
|
155
|
+
? '删除过期的 `.iterate/` 运行时产物'
|
|
156
|
+
: 'Delete stale `.iterate/` runtime artifacts'
|
|
157
|
+
case 'iterate_config': {
|
|
158
|
+
// Name WHAT is being written and shout when the write disables a review
|
|
159
|
+
// gate — the consent prompt is the last human look before the model
|
|
160
|
+
// rewrites the policy file. The WARNING marker stays literal in both
|
|
161
|
+
// languages so the UI's highlight rule matches on it.
|
|
162
|
+
const updates = readUpdates(arguments0 ?? {})
|
|
163
|
+
const keys = Object.keys(updates)
|
|
164
|
+
const scope = keys.length > 0 ? keys.map((k) => `\`${k}\``).join(', ') : (zh ? '配置' : 'the configuration')
|
|
165
|
+
const base = zh
|
|
166
|
+
? `更新 \`iterate.config.yaml\`(${scope})`
|
|
167
|
+
: `Update \`iterate.config.yaml\` (${scope})`
|
|
168
|
+
const off = disabledReviewGates(updates)
|
|
169
|
+
if (off.length === 0) return base
|
|
170
|
+
return zh
|
|
171
|
+
? `${base} — WARNING:将关闭 ${off.join(', ')}`
|
|
172
|
+
: `${base} — WARNING: turns OFF ${off.join(', ')}`
|
|
173
|
+
}
|
|
54
174
|
default:
|
|
55
|
-
return `Run ${toolName}`
|
|
175
|
+
return zh ? `运行 ${toolName}` : `Run ${toolName}`
|
|
56
176
|
}
|
|
57
177
|
}
|
|
58
178
|
|
|
59
179
|
/**
|
|
60
180
|
* Decide whether a tool execution may proceed under the given policy.
|
|
61
|
-
* Returns `allow` for read-only prune (`dryRun: true`), for
|
|
181
|
+
* Returns `allow` for read-only prune (`dryRun: true`), for config READS
|
|
182
|
+
* (`iterate_config` without `operation: 'write'`), for `allow`-policy
|
|
62
183
|
* deployments, and for any non-iterate tool.
|
|
184
|
+
*
|
|
185
|
+
* `language` only affects the HUMAN-FACING `ask.displayReason` text; the
|
|
186
|
+
* audited `ask.reason` and the `deny.reason` stay English (see header).
|
|
63
187
|
*/
|
|
64
188
|
export function decideApproval(
|
|
65
189
|
execution: ToolExecutionLike,
|
|
66
190
|
policy: 'ask' | 'deny' | 'allow',
|
|
191
|
+
language: ApprovalLanguage = 'en',
|
|
67
192
|
): ApprovalDecision {
|
|
68
193
|
// Defensive reads: a hostile/proxied execution object must degrade to "not
|
|
69
194
|
// our tool" (allow) rather than throw inside the gate.
|
|
@@ -86,20 +211,32 @@ export function decideApproval(
|
|
|
86
211
|
rawArgs && typeof rawArgs === 'object' && !Array.isArray(rawArgs)
|
|
87
212
|
? (rawArgs as Record<string, unknown>)
|
|
88
213
|
: {}
|
|
89
|
-
//
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
} else if (name === 'iterate_prune') {
|
|
93
|
-
return { kind: 'allow' }
|
|
94
|
-
}
|
|
214
|
+
// Operation-conditional tools are read-only in their default operation —
|
|
215
|
+
// `iterate_prune` dry-runs and `iterate_config` reads need no consent.
|
|
216
|
+
if (!mutatesWorkspace(name, args)) return { kind: 'allow' }
|
|
95
217
|
|
|
96
218
|
if (policy === 'allow') return { kind: 'allow' }
|
|
97
|
-
|
|
219
|
+
// Structured text (deny reason / audited ask summary) is always English;
|
|
220
|
+
// only the human prompt (`displayReason`) follows the project language.
|
|
221
|
+
const reason = describe(name, args, 'en')
|
|
98
222
|
if (policy === 'deny') return { kind: 'deny', reason }
|
|
99
|
-
|
|
223
|
+
const localized = describe(name, args, language)
|
|
224
|
+
return {
|
|
225
|
+
kind: 'ask',
|
|
226
|
+
reason,
|
|
227
|
+
displayReason:
|
|
228
|
+
language === 'zh' ? { en: reason, zh: localized } : { en: reason },
|
|
229
|
+
}
|
|
100
230
|
}
|
|
101
231
|
|
|
102
|
-
/**
|
|
232
|
+
/**
|
|
233
|
+
* True when a tool name is ROUTED through the approval gate.
|
|
234
|
+
*
|
|
235
|
+
* Name-level only (the caller has not parsed arguments yet), so the two
|
|
236
|
+
* operation-conditional tools are included for their whole name and
|
|
237
|
+
* {@link decideApproval} narrows per call: prune with a dry run and
|
|
238
|
+
* `iterate_config` reads both come back `allow`.
|
|
239
|
+
*/
|
|
103
240
|
export function isDestructiveIterateTool(name: unknown): boolean {
|
|
104
241
|
return typeof name === 'string' && DESTRUCTIVE_TOOLS.has(name)
|
|
105
242
|
}
|
package/src/atomic-fs.ts
CHANGED
|
@@ -4,16 +4,37 @@
|
|
|
4
4
|
* Every JSON/YAML artifact the plugin persists (.iterate/*.json,
|
|
5
5
|
* iterate.config.yaml) is written through here: content goes to a
|
|
6
6
|
* uniquely-named temp file in the SAME directory as the target (rename is
|
|
7
|
-
* only atomic within one filesystem),
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
7
|
+
* only atomic within one filesystem), is fsynced to plating, then is
|
|
8
|
+
* `renameSync`ed over the destination, followed by a best-effort directory
|
|
9
|
+
* fsync so the rename itself is durable. A crash mid-write can therefore
|
|
10
|
+
* leave a stale temp file behind, but never a truncated / half-written state
|
|
11
|
+
* file — readers (which all tolerate missing/corrupt files) see either the
|
|
12
|
+
* previous or the new content, never a mixture.
|
|
13
|
+
*
|
|
14
|
+
* Two properties travel with the rename (which REPLACES the target inode):
|
|
15
|
+
* - permissions: the temp is given the existing target's mode before the
|
|
16
|
+
* swap, so rewriting a 0755 script stays 0755 and a 0600 secret never
|
|
17
|
+
* widens to the umask default (0644).
|
|
18
|
+
* - symlinks: when the target is a symlink, the atomic replace runs on the
|
|
19
|
+
* link's REAL target instead — a fix written through a linked source file
|
|
20
|
+
* must reach the real file, and the link must survive as a link.
|
|
12
21
|
*/
|
|
13
22
|
|
|
14
|
-
import { dirname, join } from 'node:path'
|
|
15
|
-
import {
|
|
16
|
-
|
|
23
|
+
import { basename, dirname, join } from 'node:path'
|
|
24
|
+
import {
|
|
25
|
+
closeSync,
|
|
26
|
+
fchmodSync,
|
|
27
|
+
fsyncSync,
|
|
28
|
+
lstatSync,
|
|
29
|
+
openSync,
|
|
30
|
+
realpathSync,
|
|
31
|
+
renameSync,
|
|
32
|
+
rmSync,
|
|
33
|
+
statSync,
|
|
34
|
+
writeSync,
|
|
35
|
+
} from 'node:fs'
|
|
36
|
+
import { lstat, open, realpath, rename, rm, stat } from 'node:fs/promises'
|
|
37
|
+
import type { FileHandle } from 'node:fs/promises'
|
|
17
38
|
|
|
18
39
|
/** Best-effort unlink of a leftover temp file (never throws). */
|
|
19
40
|
function cleanupTemp(tmp: string): void {
|
|
@@ -37,25 +58,126 @@ async function cleanupTempAsync(tmp: string): Promise<void> {
|
|
|
37
58
|
* Unique temp file path for `filePath`, in the same directory, following the
|
|
38
59
|
* prunable `.<basename>.tmp-<pid>-<random>` convention (see prune.ts
|
|
39
60
|
* `isPrunableTemp`). The pid+random suffix makes concurrent writers safe.
|
|
61
|
+
* `basename` from node:path handles the platform separator (a hand-rolled
|
|
62
|
+
* `split('/')` breaks on Windows paths).
|
|
40
63
|
*/
|
|
41
64
|
function tempPathFor(filePath: string): string {
|
|
42
65
|
return join(
|
|
43
66
|
dirname(filePath),
|
|
44
|
-
`.${filePath
|
|
67
|
+
`.${basename(filePath)}.tmp-${process.pid}-${Math.random().toString(36).slice(2, 8)}`,
|
|
45
68
|
)
|
|
46
69
|
}
|
|
47
70
|
|
|
48
71
|
/**
|
|
49
|
-
*
|
|
72
|
+
* Resolve the path the atomic replace must actually run on. A symlinked
|
|
73
|
+
* target is resolved to its real path: `renameSync(tmp, link)` would swap the
|
|
74
|
+
* LINK for a regular file and the linked-to file would silently keep its old
|
|
75
|
+
* content (a fix that never reaches the real source). When lstat/realpath
|
|
76
|
+
* cannot resolve (missing target, dangling or unreadable link), fall back to
|
|
77
|
+
* writing `filePath` itself — the pre-symlink-aware behavior.
|
|
78
|
+
*/
|
|
79
|
+
function resolveAtomicTarget(filePath: string): string {
|
|
80
|
+
try {
|
|
81
|
+
if (lstatSync(filePath).isSymbolicLink()) return realpathSync(filePath)
|
|
82
|
+
} catch {
|
|
83
|
+
// Missing target or unresolvable link — write `filePath` itself.
|
|
84
|
+
}
|
|
85
|
+
return filePath
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Async variant of {@link resolveAtomicTarget}. */
|
|
89
|
+
async function resolveAtomicTargetAsync(filePath: string): Promise<string> {
|
|
90
|
+
try {
|
|
91
|
+
if ((await lstat(filePath)).isSymbolicLink()) return await realpath(filePath)
|
|
92
|
+
} catch {
|
|
93
|
+
// Missing target or unresolvable link — write `filePath` itself.
|
|
94
|
+
}
|
|
95
|
+
return filePath
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* The permission bits to give the temp file: the existing target's mode (so
|
|
100
|
+
* the rename-over swap preserves it), or `null` for a brand-new file — then
|
|
101
|
+
* the default open mode (0o666 & ~umask) applies, exactly like a plain
|
|
102
|
+
* `writeFileSync` of a new file.
|
|
103
|
+
*/
|
|
104
|
+
function existingMode(target: string): number | null {
|
|
105
|
+
try {
|
|
106
|
+
return statSync(target).mode & 0o777
|
|
107
|
+
} catch {
|
|
108
|
+
return null
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Async variant of {@link existingMode}. */
|
|
113
|
+
async function existingModeAsync(target: string): Promise<number | null> {
|
|
114
|
+
try {
|
|
115
|
+
return (await stat(target)).mode & 0o777
|
|
116
|
+
} catch {
|
|
117
|
+
return null
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Best-effort fsync of the directory holding the final file, so the rename
|
|
123
|
+
* itself survives a crash. Unsupported on some platforms/filesystems — the
|
|
124
|
+
* data is already durable (fsynced on the temp), this only covers the
|
|
125
|
+
* directory entry, so failure is ignored by design.
|
|
126
|
+
*/
|
|
127
|
+
function fsyncDirBestEffort(dir: string): void {
|
|
128
|
+
try {
|
|
129
|
+
const fd = openSync(dir, 'r')
|
|
130
|
+
try {
|
|
131
|
+
fsyncSync(fd)
|
|
132
|
+
} finally {
|
|
133
|
+
closeSync(fd)
|
|
134
|
+
}
|
|
135
|
+
} catch {
|
|
136
|
+
// Directory fsync is unavailable (Windows, some FUSE mounts) — durability
|
|
137
|
+
// of the content does not depend on it.
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Async variant of {@link fsyncDirBestEffort}. */
|
|
142
|
+
async function fsyncDirBestEffortAsync(dir: string): Promise<void> {
|
|
143
|
+
let dh: FileHandle | undefined
|
|
144
|
+
try {
|
|
145
|
+
dh = await open(dir, 'r')
|
|
146
|
+
await dh.sync()
|
|
147
|
+
} catch {
|
|
148
|
+
// Directory fsync is unavailable — see fsyncDirBestEffort.
|
|
149
|
+
} finally {
|
|
150
|
+
if (dh) {
|
|
151
|
+
await dh.close().catch(() => {})
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Write `data` to `filePath` atomically (temp file + fsync + rename, same dir).
|
|
50
158
|
* Temp files follow the `<name>.tmp-<pid>-<random>` convention also used by
|
|
51
|
-
* prune.ts / checkpoint.ts so they are recognizable and prunable.
|
|
159
|
+
* prune.ts / checkpoint.ts so they are recognizable and prunable. The existing
|
|
160
|
+
* target's mode is preserved across the swap and symlinked targets are
|
|
161
|
+
* resolved first (see the header).
|
|
52
162
|
* Throws on failure (callers own error reporting — they map failures to
|
|
53
163
|
* structured tool results).
|
|
54
164
|
*/
|
|
55
165
|
export function writeTextAtomic(filePath: string, data: string): void {
|
|
56
|
-
const
|
|
166
|
+
const target = resolveAtomicTarget(filePath)
|
|
167
|
+
const tmp = tempPathFor(target)
|
|
168
|
+
const mode = existingMode(target)
|
|
57
169
|
try {
|
|
58
|
-
|
|
170
|
+
// fd-based write so the mode can be pinned and the CONTENT fsynced before
|
|
171
|
+
// the rename — a crash after rename but before the data hit the disk would
|
|
172
|
+
// otherwise leave a zero-length / torn state file.
|
|
173
|
+
const fd = openSync(tmp, 'w')
|
|
174
|
+
try {
|
|
175
|
+
writeSync(fd, data, null, 'utf-8')
|
|
176
|
+
if (mode !== null) fchmodSync(fd, mode)
|
|
177
|
+
fsyncSync(fd)
|
|
178
|
+
} finally {
|
|
179
|
+
closeSync(fd)
|
|
180
|
+
}
|
|
59
181
|
} catch (err) {
|
|
60
182
|
// The write itself failed (disk full, permission, …): the temp file may
|
|
61
183
|
// already be partially written, so remove it before propagating — a
|
|
@@ -64,19 +186,31 @@ export function writeTextAtomic(filePath: string, data: string): void {
|
|
|
64
186
|
throw err
|
|
65
187
|
}
|
|
66
188
|
try {
|
|
67
|
-
renameSync(tmp,
|
|
189
|
+
renameSync(tmp, target)
|
|
68
190
|
} catch (err) {
|
|
69
191
|
// Best-effort temp cleanup so a failed rename never litters the directory.
|
|
70
192
|
cleanupTemp(tmp)
|
|
71
193
|
throw err
|
|
72
194
|
}
|
|
195
|
+
fsyncDirBestEffort(dirname(target))
|
|
73
196
|
}
|
|
74
197
|
|
|
75
198
|
/** Async variant of {@link writeTextAtomic} (same temp naming convention). */
|
|
76
199
|
export async function writeTextAtomicAsync(filePath: string, data: string): Promise<void> {
|
|
77
|
-
const
|
|
200
|
+
const target = await resolveAtomicTargetAsync(filePath)
|
|
201
|
+
const tmp = tempPathFor(target)
|
|
202
|
+
const mode = await existingModeAsync(target)
|
|
78
203
|
try {
|
|
79
|
-
|
|
204
|
+
// fd-based write: pin the target's mode and fsync the content before the
|
|
205
|
+
// rename (mirrors the sync variant — see writeTextAtomic).
|
|
206
|
+
const fh = await open(tmp, 'w')
|
|
207
|
+
try {
|
|
208
|
+
await fh.writeFile(data, 'utf-8')
|
|
209
|
+
if (mode !== null) await fh.chmod(mode)
|
|
210
|
+
await fh.sync()
|
|
211
|
+
} finally {
|
|
212
|
+
await fh.close()
|
|
213
|
+
}
|
|
80
214
|
} catch (err) {
|
|
81
215
|
// The write itself failed (disk full, permission, …): the temp file may
|
|
82
216
|
// already be partially written, so remove it before propagating — a
|
|
@@ -85,12 +219,13 @@ export async function writeTextAtomicAsync(filePath: string, data: string): Prom
|
|
|
85
219
|
throw err
|
|
86
220
|
}
|
|
87
221
|
try {
|
|
88
|
-
await rename(tmp,
|
|
222
|
+
await rename(tmp, target)
|
|
89
223
|
} catch (err) {
|
|
90
224
|
// Best-effort temp cleanup so a failed rename never litters the directory.
|
|
91
225
|
await cleanupTempAsync(tmp)
|
|
92
226
|
throw err
|
|
93
227
|
}
|
|
228
|
+
await fsyncDirBestEffortAsync(dirname(target))
|
|
94
229
|
}
|
|
95
230
|
|
|
96
231
|
/** JSON.stringify (2-space indent) + atomic write in one call. */
|