mixdog 0.9.175 → 1.0.1
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/package.json +1 -1
- package/scripts/context-renewal-live-check.mjs +2 -1
- package/scripts/plan-runtime-release-tag.mjs +57 -0
- package/src/defaults/skills/computer-use/SKILL.md +13 -12
- package/src/defaults/skills/setup/SKILL.md +1 -4
- package/src/defaults/skills/setup/references/actions.md +0 -2
- package/src/defaults/skills/setup/references/surfaces.md +2 -3
- package/src/defaults/skills/xlsx/SKILL.md +1 -0
- package/src/rules/shared/70-delivery.md +3 -0
- package/src/runtime/agent/orchestrator/agent-trace-format.mjs +12 -12
- package/src/runtime/agent/orchestrator/config.mjs +8 -1
- package/src/runtime/agent/orchestrator/providers/anthropic-api-transport.mjs +6 -1
- package/src/runtime/agent/orchestrator/providers/anthropic-oauth-request.mjs +2 -0
- package/src/runtime/agent/orchestrator/providers/cursor.mjs +20 -2
- package/src/runtime/agent/orchestrator/providers/openai-oauth.mjs +3 -0
- package/src/runtime/agent/orchestrator/providers/openai-ws.mjs +2 -0
- package/src/runtime/agent/orchestrator/providers/registry-errors.mjs +9 -0
- package/src/runtime/agent/orchestrator/providers/registry.mjs +3 -2
- package/src/runtime/agent/orchestrator/runtime-core/goal-tasks.mjs +25 -6
- package/src/runtime/agent/orchestrator/runtime-core/goal-text.mjs +3 -2
- package/src/runtime/agent/orchestrator/runtime-core/goal-tool-defs.mjs +1 -1
- package/src/runtime/agent/orchestrator/runtime-core/goal-tool-exec.mjs +1 -0
- package/src/runtime/agent/orchestrator/session/result-classification.mjs +1 -1
- package/src/runtime/agent/orchestrator/session/transcript-restore/tool-result-status.mjs +7 -3
- package/src/runtime/agent/orchestrator/tools/builtin/arg-guard.mjs +27 -4
- package/src/runtime/agent/orchestrator/tools/builtin/bash-tool/benign-exit.mjs +7 -3
- package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +1 -0
- package/src/runtime/agent/orchestrator/tools/builtin/git-command-tool.mjs +39 -3
- package/src/runtime/agent/orchestrator/tools/builtin/read-batch.mjs +3 -1
- package/src/runtime/agent/orchestrator/tools/builtin/read-tool/batch-dispatch.mjs +2 -1
- package/src/runtime/agent/orchestrator/tools/code-graph/build.mjs +13 -5
- package/src/runtime/computer-bridge/error-recovery.mjs +1 -1
- package/src/runtime/memory/lib/runtime-fetcher.mjs +94 -23
- package/src/runtime/office/capabilities-catalog.mjs +2 -0
- package/src/runtime/office/capabilities-signatures.mjs +12 -6
- package/src/runtime/office/capabilities.mjs +30 -10
- package/src/runtime/office/core/office-actions-open.mjs +1 -6
- package/src/runtime/office/core/office-sessions.mjs +9 -0
- package/src/runtime/office/portable/portable-xlsx-operations.mjs +2 -49
- package/src/runtime/office/portable/xlsx-contract.mjs +37 -3
- package/src/runtime/office/tool-defs.mjs +2 -2
- package/src/runtime/shared/config-patch.mjs +2 -0
- package/src/runtime/shared/llm/cost.mjs +16 -4
- package/src/runtime/shared/llm/model-catalog-projection.mjs +3 -2
- package/src/runtime/shared/llm/model-catalog.mjs +48 -0
- package/src/runtime/shared/llm/model-pricing-rates.mjs +11 -4
- package/src/runtime/shared/llm/usage-accounting.mjs +12 -11
- package/src/runtime/shared/llm/usage-context.mjs +7 -0
- package/src/runtime/shared/llm/usage-ledger-repair.mjs +3 -1
- package/src/runtime/shared/llm/usage-ledger.mjs +1 -1
- package/src/runtime/shared/tool-card-model/card-status.mjs +7 -5
- package/src/runtime/shared/tool-card-model/git-result.mjs +10 -0
- package/src/runtime/shared/tool-card-model/terminal-status.mjs +6 -2
- package/src/runtime/shared/tool-card-model.golden-cases.mjs +2 -1
- package/src/runtime/shared/tool-result-summary.mjs +1 -0
- package/src/runtime/shared/tool-status.mjs +35 -2
- package/src/runtime/shared/user-data-guard.mjs +1 -1
- package/src/runtime/tidy/report.mjs +2 -2
- package/src/runtime/tidy/tool.mjs +6 -1
- package/src/session-runtime/config-lifecycle.mjs +11 -1
- package/src/session-runtime/cwd-plugins/core-memory-context.mjs +1 -1
- package/src/session-runtime/native-web-search/tool-shape.mjs +16 -4
- package/src/session-runtime/provider-models/catalog-load.mjs +18 -3
- package/src/session-runtime/setup-tool/executor.mjs +0 -1
- package/src/session-runtime/setup-tool/settings-contract.mjs +2 -13
- package/src/tui/dist/index.mjs +89 -38
- package/src/tui/session/session-api/settings.mjs +16 -10
- package/src/vendor/statusline/src/gateway/route-meta.mjs +7 -2
- package/src/workflows/default/WORKFLOW.md +0 -2
- package/src/workflows/headless/WORKFLOW.md +0 -2
|
@@ -174,6 +174,23 @@ function hasMultipleAbsoluteWindowsPaths(value) {
|
|
|
174
174
|
return Array.isArray(matches) && matches.length > 1;
|
|
175
175
|
}
|
|
176
176
|
|
|
177
|
+
// A string that only lists absolute Windows paths ("C:\a C:\b", "C:\a,C:\b",
|
|
178
|
+
// "[C:/a, C:/b]", '["C:/a", "D:/b"]') splits exactly: every piece starts at a
|
|
179
|
+
// drive root, and no drive root can occur inside a Windows path, so spaces
|
|
180
|
+
// within a path stay put. Any other text around the paths returns null.
|
|
181
|
+
function splitListedAbsoluteWindowsPaths(value) {
|
|
182
|
+
const body = value
|
|
183
|
+
.trim()
|
|
184
|
+
.replace(/^\[([\s\S]*)\]$/, '$1')
|
|
185
|
+
.replace(/^[\s,;]+|[\s,;]+$/g, '');
|
|
186
|
+
const pieces = body
|
|
187
|
+
.split(/[\s,;]+(?=["']?[A-Za-z]:[\\/])/)
|
|
188
|
+
.map((piece) => piece.replace(/^(["'])(.*)\1$/, '$2'));
|
|
189
|
+
return pieces.every((piece) => /^[A-Za-z]:[\\/]/.test(piece) && !/[A-Za-z]:[\\/]/.test(piece.slice(3)))
|
|
190
|
+
? pieces
|
|
191
|
+
: null;
|
|
192
|
+
}
|
|
193
|
+
|
|
177
194
|
function isFiniteInt(v) {
|
|
178
195
|
return typeof v === 'number' && Number.isFinite(v) && Math.floor(v) === v;
|
|
179
196
|
}
|
|
@@ -418,8 +435,17 @@ function guardGrep(a) {
|
|
|
418
435
|
return `Error: grep arg "${k}" must be string (got ${describeType(a[k])})`;
|
|
419
436
|
}
|
|
420
437
|
}
|
|
421
|
-
// Independent explicit scopes share one request
|
|
438
|
+
// Independent explicit scopes share one request. A string that only lists
|
|
439
|
+
// absolute paths is those scopes; any other string holding several drive
|
|
440
|
+
// roots is refused rather than guessed at.
|
|
422
441
|
for (const k of ['path', 'root']) {
|
|
442
|
+
if (hasOwn(a, k) && hasMultipleAbsoluteWindowsPaths(a[k])) {
|
|
443
|
+
const listed = splitListedAbsoluteWindowsPaths(a[k]);
|
|
444
|
+
if (!listed) {
|
|
445
|
+
return `Error: grep arg "${k}" contains multiple absolute paths in one string. Use one common parent path plus glob, or separate grep calls.`;
|
|
446
|
+
}
|
|
447
|
+
a[k] = listed;
|
|
448
|
+
}
|
|
423
449
|
if (hasOwn(a, k) && Array.isArray(a[k])) {
|
|
424
450
|
if (a[k].length === 0 || a[k].length > PUBLIC_PATH_BATCH_LIMIT || !a[k].every(isNonEmptyString)) {
|
|
425
451
|
return `Error: grep arg "${k}" must contain 1-${PUBLIC_PATH_BATCH_LIMIT} non-empty path strings`;
|
|
@@ -429,9 +455,6 @@ function guardGrep(a) {
|
|
|
429
455
|
if (hasOwn(a, k) && !isString(a[k])) {
|
|
430
456
|
return `Error: grep arg "${k}" must be string or string[] (got ${describeType(a[k])})`;
|
|
431
457
|
}
|
|
432
|
-
if (hasOwn(a, k) && hasMultipleAbsoluteWindowsPaths(a[k])) {
|
|
433
|
-
return `Error: grep arg "${k}" contains multiple absolute paths in one string. Use one common parent path plus glob, or separate grep calls.`;
|
|
434
|
-
}
|
|
435
458
|
}
|
|
436
459
|
for (const k of ['head_limit', 'offset']) {
|
|
437
460
|
const err = checkIntInRange(a, k, 0, MAX_INT);
|
|
@@ -14,6 +14,8 @@ import {
|
|
|
14
14
|
// shared shell tokenizers, so quoted/commented `;` `|` `grep` can never
|
|
15
15
|
// masquerade as a connector/command and hide a real failure.
|
|
16
16
|
const _SEARCH_HEADS = new Set(['select-string', 'sls', 'grep', 'egrep', 'fgrep', 'findstr']);
|
|
17
|
+
// `git diff` flags whose exit 1 reports a difference, not a failure.
|
|
18
|
+
export const GIT_DIFF_EXIT_SIGNAL_FLAGS = new Set(['--exit-code', '--quiet', '--check']);
|
|
17
19
|
const _GIT_GLOBAL_VALUE_OPTS = new Set([
|
|
18
20
|
'-c',
|
|
19
21
|
'-C',
|
|
@@ -75,12 +77,14 @@ export function _isBenignSearchExitOne(command, exitCode, signal, stderr) {
|
|
|
75
77
|
const head = _normalizeHead(tokens[0]);
|
|
76
78
|
if (_SEARCH_HEADS.has(head)) return true;
|
|
77
79
|
if (head !== 'git') return false;
|
|
78
|
-
// `git [global-opts]
|
|
79
|
-
// diff-index/diff-files/difftool —
|
|
80
|
+
// `git [global-opts] grep ...` (no match), or `git [global-opts] diff ...`
|
|
81
|
+
// only — exact `diff` subcommand, never diff-index/diff-files/difftool —
|
|
82
|
+
// with exit-code semantics.
|
|
80
83
|
let i = 1;
|
|
81
84
|
while (i < tokens.length && tokens[i].startsWith('-')) {
|
|
82
85
|
i += _GIT_GLOBAL_VALUE_OPTS.has(tokens[i]) && !tokens[i].includes('=') ? 2 : 1;
|
|
83
86
|
}
|
|
87
|
+
if (tokens[i] === 'grep') return true;
|
|
84
88
|
if (tokens[i] !== 'diff') return false;
|
|
85
|
-
return tokens.slice(i + 1).some((t) => t
|
|
89
|
+
return tokens.slice(i + 1).some((t) => GIT_DIFF_EXIT_SIGNAL_FLAGS.has(t));
|
|
86
90
|
}
|
|
@@ -3,6 +3,7 @@ import { mkdtemp, rm, writeFile } from 'node:fs/promises';
|
|
|
3
3
|
import { tmpdir } from 'node:os';
|
|
4
4
|
import { basename, dirname, join, resolve } from 'node:path';
|
|
5
5
|
import { tokenizeDirectArgv } from './shell-direct-exe.mjs';
|
|
6
|
+
import { GIT_DIFF_EXIT_SIGNAL_FLAGS } from './bash-tool/benign-exit.mjs';
|
|
6
7
|
import { withBuiltinPathLocks } from './path-locks.mjs';
|
|
7
8
|
import { withAdvisoryLocks } from './advisory-lock.mjs';
|
|
8
9
|
import { withGitRepoReadLock, withGitRepoWriteLock } from './git-repo-rw-lock.mjs';
|
|
@@ -154,8 +155,13 @@ function commandResult(plan, result, limit) {
|
|
|
154
155
|
const output = capOutput(appendText(String(result.stdout || ''), String(result.stderr || '')), limit);
|
|
155
156
|
if (succeeded(result)) return { text: output, failed: false };
|
|
156
157
|
const processError = result.error || result.timedOut || result.aborted || result.overflow || result.exitCode == null;
|
|
158
|
+
// An exit signal is still a non-zero exit, so a command array stops on it;
|
|
159
|
+
// the outcome line only keeps the card from reporting a failure.
|
|
160
|
+
const exitHeader = exitSignal(plan, result)
|
|
161
|
+
? `exit ${result.exitCode}\n[outcome: no-match]`
|
|
162
|
+
: `exit ${result.exitCode}`;
|
|
157
163
|
return {
|
|
158
|
-
text: appendText(processError ? fail(gitFailureReason(plan, result)) :
|
|
164
|
+
text: appendText(processError ? fail(gitFailureReason(plan, result)) : exitHeader, output),
|
|
159
165
|
failed: true,
|
|
160
166
|
};
|
|
161
167
|
}
|
|
@@ -337,6 +343,18 @@ function succeeded(result) {
|
|
|
337
343
|
return result?.exitCode === 0 && !result.error && !result.timedOut && !result.aborted && !result.overflow;
|
|
338
344
|
}
|
|
339
345
|
|
|
346
|
+
// `git grep` exits 1 for no match and `git diff --exit-code/--quiet/--check`
|
|
347
|
+
// exits 1 to report a difference; the shell tool marks the same exits as
|
|
348
|
+
// signals, so the git tool does too.
|
|
349
|
+
function exitSignal(plan, result) {
|
|
350
|
+
if (result.exitCode !== 1 || String(result.stderr || '').trim()) return false;
|
|
351
|
+
if (plan.operation === 'grep') return true;
|
|
352
|
+
if (plan.operation !== 'diff') return false;
|
|
353
|
+
const separator = plan.args.indexOf('--');
|
|
354
|
+
const options = separator === -1 ? plan.args : plan.args.slice(0, separator);
|
|
355
|
+
return options.some((arg) => GIT_DIFF_EXIT_SIGNAL_FLAGS.has(arg));
|
|
356
|
+
}
|
|
357
|
+
|
|
340
358
|
// ENOENT from the spawn itself is a capability fact, not a git error: the
|
|
341
359
|
// executable is absent. Naming it stops the caller from re-running git a
|
|
342
360
|
// different way to find out.
|
|
@@ -780,8 +798,26 @@ function splitChainedGitCommands(command) {
|
|
|
780
798
|
return commands.length > 1 ? commands : null;
|
|
781
799
|
}
|
|
782
800
|
|
|
783
|
-
|
|
784
|
-
|
|
801
|
+
// The tool only runs git, so a submitted command that omits the leading `git`
|
|
802
|
+
// ("status --short") gets it instead of a rejection. Only whole submitted
|
|
803
|
+
// commands are prefixed; a `&&` piece after the first still has to be a full
|
|
804
|
+
// git command, so non-git chain segments stay refused.
|
|
805
|
+
function withGitPrefix(command) {
|
|
806
|
+
if (typeof command !== 'string') return command;
|
|
807
|
+
const text = String(unwrapQuotedCommand(command)).trim();
|
|
808
|
+
const first = text ? tokenizeDirectArgv(text)?.[0] : '';
|
|
809
|
+
if (!first || /(^|[\\/])git(?:\.exe)?$/i.test(first)) return command;
|
|
810
|
+
return `git ${text}`;
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
export async function executeGitTool(rawInput, workDir, options = {}) {
|
|
814
|
+
if (!rawInput || typeof rawInput !== 'object' || Array.isArray(rawInput))
|
|
815
|
+
return fail('git requires an arguments object');
|
|
816
|
+
const input = {
|
|
817
|
+
...rawInput,
|
|
818
|
+
command: Array.isArray(rawInput.command) ? rawInput.command.map(withGitPrefix) : withGitPrefix(rawInput.command),
|
|
819
|
+
};
|
|
820
|
+
if (rawInput.command === undefined) delete input.command;
|
|
785
821
|
const action = input.action ?? 'command';
|
|
786
822
|
if (action === 'stage') {
|
|
787
823
|
if (input.command !== undefined || input.include_stage_ids !== undefined) {
|
|
@@ -33,7 +33,9 @@ export function sliceReadBodyByLines(body, origOffset, origLimit, readOffsetBase
|
|
|
33
33
|
if (ln < minSeen) minSeen = ln;
|
|
34
34
|
if (ln > maxSeen) maxSeen = ln;
|
|
35
35
|
if (ln >= firstLine && ln <= lastLine) kept.push(line);
|
|
36
|
-
} else if (kept.length === 0 && footerIdx === -1) {
|
|
36
|
+
} else if (kept.length === 0 && footerIdx === -1 && !Number.isFinite(maxSeen)) {
|
|
37
|
+
// Only a preamble before the first numbered row; a blank line or a
|
|
38
|
+
// truncation marker after the rows is not this slot's content.
|
|
37
39
|
kept.push(line);
|
|
38
40
|
}
|
|
39
41
|
}
|
|
@@ -42,7 +42,8 @@ async function estimateTaskBytes(entry, workDir) {
|
|
|
42
42
|
if (!st?.isFile()) return Infinity;
|
|
43
43
|
const lines = Math.max(1, st.size / AVG_LINE_BYTES);
|
|
44
44
|
let need = st.size + st.size / MIN_LINE_BYTES * LINE_PREFIX_BYTES;
|
|
45
|
-
|
|
45
|
+
// A coalesced entry reads its whole union window, not only its own slot.
|
|
46
|
+
const limit = Number(readEntryCoalescedDiskWindow(entry)?.limit ?? entry?.limit);
|
|
46
47
|
if (Number.isFinite(limit) && limit > 0) need *= Math.min(1, limit / lines);
|
|
47
48
|
return Math.ceil(need) + READ_RENDER_SLACK_BYTES;
|
|
48
49
|
}
|
|
@@ -100,9 +100,12 @@ function _throwIfAborted(signal) {
|
|
|
100
100
|
if (signal?.aborted) throw new Error('aborted');
|
|
101
101
|
}
|
|
102
102
|
|
|
103
|
-
|
|
103
|
+
// `graph` is the build that finished anyway: the tree as of its own manifest,
|
|
104
|
+
// never cached as current. The last retry answers from it instead of failing.
|
|
105
|
+
function codeGraphBuildInvalidatedError(graph = null) {
|
|
104
106
|
const error = new Error('code-graph build invalidated during prewarm');
|
|
105
107
|
error.code = 'ERR_CODE_GRAPH_BUILD_INVALIDATED';
|
|
108
|
+
if (graph) error.graph = graph;
|
|
106
109
|
return error;
|
|
107
110
|
}
|
|
108
111
|
|
|
@@ -115,7 +118,12 @@ export async function _retryCodeGraphBuildAfterInvalidation(run, { signal = null
|
|
|
115
118
|
try {
|
|
116
119
|
return await run(attempt);
|
|
117
120
|
} catch (error) {
|
|
118
|
-
if (signal?.aborted || !isCodeGraphBuildInvalidatedError(error)
|
|
121
|
+
if (signal?.aborted || !isCodeGraphBuildInvalidatedError(error)) throw error;
|
|
122
|
+
// Files kept changing through the retry too (another session editing
|
|
123
|
+
// the same tree). The finished graph is a consistent snapshot a few
|
|
124
|
+
// seconds old; answer this query from it rather than fail it.
|
|
125
|
+
if (attempt === 1) {
|
|
126
|
+
if (error.graph) return error.graph;
|
|
119
127
|
throw error;
|
|
120
128
|
}
|
|
121
129
|
}
|
|
@@ -159,7 +167,7 @@ async function _validateDiskCodeGraphHit({
|
|
|
159
167
|
if (diskEntry.signature !== signature) return { graph: null, manifest, signature };
|
|
160
168
|
const graph = deserializeGraph(graphCwd, diskEntry);
|
|
161
169
|
if (!graph) return { graph: null, manifest, signature };
|
|
162
|
-
if (getGeneration(graphCwd) !== genAtStart) return { invalidated: true };
|
|
170
|
+
if (getGeneration(graphCwd) !== genAtStart) return { invalidated: true, graph };
|
|
163
171
|
setMemoryCache(graphCwd, { ts: now, signature, graph });
|
|
164
172
|
return { graph, manifest, signature };
|
|
165
173
|
}
|
|
@@ -195,7 +203,7 @@ export async function _runDiskCodeGraphFastPath({
|
|
|
195
203
|
_throwIfAborted(signal);
|
|
196
204
|
const hit = await validateDiskHit({ graphCwd, diskEntry, genAtStart, signal });
|
|
197
205
|
_throwIfAborted(signal);
|
|
198
|
-
if (hit?.invalidated) throw codeGraphBuildInvalidatedError();
|
|
206
|
+
if (hit?.invalidated) throw codeGraphBuildInvalidatedError(hit.graph);
|
|
199
207
|
if (hit?.graph) return hit.graph;
|
|
200
208
|
_throwIfAborted(signal);
|
|
201
209
|
const workerPromise = spawnWorker(release, hit?.manifest || null, hit?.signature || null);
|
|
@@ -463,7 +471,7 @@ export function _spawnCodeGraphWorker(
|
|
|
463
471
|
// manifest lock.
|
|
464
472
|
setDiskCache(graphCwd, msg.graph, { persist: false });
|
|
465
473
|
}
|
|
466
|
-
settle(genStillCurrent ? msg.graph : codeGraphBuildInvalidatedError());
|
|
474
|
+
settle(genStillCurrent ? msg.graph : codeGraphBuildInvalidatedError(msg.graph));
|
|
467
475
|
} else {
|
|
468
476
|
settle(_codeGraphWorkerFailure(msg));
|
|
469
477
|
}
|
|
@@ -79,7 +79,7 @@ function recoveryForCode(code, args) {
|
|
|
79
79
|
code,
|
|
80
80
|
next: 'wait_for_user',
|
|
81
81
|
guidance:
|
|
82
|
-
|
|
82
|
+
"Computer Use yielded to the user. Call wait_for_user for bounded waiting. The user's Resume on the overlay continues the pause, and ordinary physical input may also resume after the host-configured quiet interval (default 5 seconds); explicit stops and uncertain cleanup/observation require the user. Stop cancels the task. Timeout does not authorize input. After resumed, capture fresh state; never replay interrupted input.",
|
|
83
83
|
};
|
|
84
84
|
}
|
|
85
85
|
if (DIAGNOSE_CODES.has(code)) {
|
|
@@ -7,9 +7,12 @@ import { __mixdogMemoryLog } from './memory-log.mjs';
|
|
|
7
7
|
// Downloads and verifies a prebuilt native PG runtime from the mixdog GitHub
|
|
8
8
|
// release manifest.
|
|
9
9
|
//
|
|
10
|
-
// Layout: <dataDir>/runtime/runtime-{
|
|
10
|
+
// Layout: <dataDir>/runtime/runtime-{key}/ + <dataDir>/runtime/active-version
|
|
11
|
+
// holding {key}. New installs key the directory by version plus asset digest;
|
|
12
|
+
// installs from before that keep their bare-version key and stay valid.
|
|
11
13
|
// Atomic swap: write active-version.tmp then rename → active-version.
|
|
12
|
-
// GC: removes stale runtime-* dirs and staging-* dirs on every ensureRuntime
|
|
14
|
+
// GC: removes stale runtime-* dirs and staging-* dirs on every ensureRuntime
|
|
15
|
+
// call, but only a dir that can be moved aside whole (never one in use).
|
|
13
16
|
//
|
|
14
17
|
// Public API: ensureRuntime(dataDir) → { runtimeDir, pgBinDir, libDir, sharePath, version }
|
|
15
18
|
|
|
@@ -21,6 +24,7 @@ import {
|
|
|
21
24
|
openSync,
|
|
22
25
|
readFileSync,
|
|
23
26
|
readdirSync,
|
|
27
|
+
renameSync,
|
|
24
28
|
rmSync,
|
|
25
29
|
statSync,
|
|
26
30
|
unlinkSync,
|
|
@@ -109,6 +113,38 @@ function runtimeVerDir(runtimeDir, ver) {
|
|
|
109
113
|
return join(runtimeDir, `runtime-${ver}`);
|
|
110
114
|
}
|
|
111
115
|
|
|
116
|
+
// A rebuilt asset of the same version gets its own directory, so installing it
|
|
117
|
+
// never touches the tree a still-running PostgreSQL may hold open.
|
|
118
|
+
function runtimeInstallKey(version, sha256) {
|
|
119
|
+
return `${version}-${String(sha256).slice(0, 12)}`;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// True while a PostgreSQL from this runtime is running: a running executable
|
|
123
|
+
// cannot be opened for writing (EBUSY on Windows, ETXTBSY on Linux).
|
|
124
|
+
function runtimeDirInUse(dir) {
|
|
125
|
+
const exe = join(dir, 'bin', process.platform === 'win32' ? 'postgres.exe' : 'postgres');
|
|
126
|
+
try {
|
|
127
|
+
closeSync(openSync(exe, 'r+'));
|
|
128
|
+
return false;
|
|
129
|
+
} catch (error) {
|
|
130
|
+
return error?.code === 'EBUSY' || error?.code === 'ETXTBSY';
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Remove a runtime dir only when nothing runs from it, moving it aside whole
|
|
135
|
+
// first. Deleting in place could strip a running PostgreSQL's files and the
|
|
136
|
+
// sha stamp while the locked binaries survive, leaving a tree that looks
|
|
137
|
+
// installed but is not; an in-use dir makes this throw and stays intact.
|
|
138
|
+
function discardRuntimeDir(runtimeDir, dir) {
|
|
139
|
+
if (runtimeDirInUse(dir)) throw new Error(`[runtime-fetcher] runtime dir is in use: ${dir}`);
|
|
140
|
+
const trash = join(
|
|
141
|
+
runtimeDir,
|
|
142
|
+
`staging-discard-${Date.now()}-${process.pid}-${Math.random().toString(36).slice(2, 8)}`
|
|
143
|
+
);
|
|
144
|
+
renameSync(dir, trash);
|
|
145
|
+
rmSync(trash, { recursive: true, force: true });
|
|
146
|
+
}
|
|
147
|
+
|
|
112
148
|
function runtimePaths(verDir) {
|
|
113
149
|
return {
|
|
114
150
|
pgBinDir: join(verDir, 'bin'),
|
|
@@ -370,9 +406,11 @@ function gcRuntimeDir(runtimeDir, keepVer) {
|
|
|
370
406
|
continue;
|
|
371
407
|
}
|
|
372
408
|
const dir = join(runtimeDir, name);
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
409
|
+
// A download (staging-{tag}.tar.gz) is guarded by its staging dir's lock.
|
|
410
|
+
const guardName = name.endsWith('.tar.gz') ? name.slice(0, -'.tar.gz'.length) : name;
|
|
411
|
+
const lockPath = stagingLockPath(join(runtimeDir, guardName));
|
|
412
|
+
// Never wipe a staging dir or download guarded by a LIVE lock — that is
|
|
413
|
+
// a sibling process mid-download/extract/swap.
|
|
376
414
|
if (_stagingLockIsLive(lockPath)) continue;
|
|
377
415
|
try {
|
|
378
416
|
rmSync(dir, { recursive: true, force: true });
|
|
@@ -382,8 +420,10 @@ function gcRuntimeDir(runtimeDir, keepVer) {
|
|
|
382
420
|
if (existsSync(lockPath)) unlinkSync(lockPath);
|
|
383
421
|
} catch {}
|
|
384
422
|
} else if (name.startsWith('runtime-') && name !== `runtime-${keepVer}`) {
|
|
423
|
+
const path = join(runtimeDir, name);
|
|
385
424
|
try {
|
|
386
|
-
|
|
425
|
+
if (statSync(path).isDirectory()) discardRuntimeDir(runtimeDir, path);
|
|
426
|
+
else rmSync(path, { force: true });
|
|
387
427
|
} catch {}
|
|
388
428
|
}
|
|
389
429
|
}
|
|
@@ -451,11 +491,36 @@ function selectRuntimeAsset(manifest, pkey) {
|
|
|
451
491
|
/** Fast path: the active-version pointer names this version and the extracted
|
|
452
492
|
* tree carries the expected sha256. Returns the reusable dir, else null. */
|
|
453
493
|
function cachedRuntimeDir(runtimeBaseDir, version, sha256) {
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
494
|
+
const key = readActiveVersion(runtimeBaseDir);
|
|
495
|
+
if (key !== version && key !== runtimeInstallKey(version, sha256)) return null;
|
|
496
|
+
const verDir = runtimeVerDir(runtimeBaseDir, key);
|
|
497
|
+
return readRuntimeStamp(verDir) === sha256 ? verDir : null;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
// The asset digest stamped into a complete runtime tree, '' when absent.
|
|
501
|
+
function readRuntimeStamp(verDir) {
|
|
502
|
+
try {
|
|
503
|
+
return readFileSync(join(verDir, '.version-sha256'), 'utf8').trim();
|
|
504
|
+
} catch {
|
|
505
|
+
return '';
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
// Publish an extracted, stamped staging tree as runtime-{key} and point
|
|
510
|
+
// active-version at it. A sibling that raced this install may already have
|
|
511
|
+
// published the same asset: that tree is kept (it may be running) and this
|
|
512
|
+
// copy dropped. Any other runtime-{key} tree is an interrupted install of this
|
|
513
|
+
// asset; it is moved aside whole, and an in-use one fails the publish intact.
|
|
514
|
+
function publishRuntimeDir(runtimeBaseDir, stagingDir, key, sha256) {
|
|
515
|
+
const verDir = runtimeVerDir(runtimeBaseDir, key);
|
|
516
|
+
if (readRuntimeStamp(verDir) === sha256) {
|
|
517
|
+
rmSync(stagingDir, { recursive: true, force: true });
|
|
518
|
+
} else {
|
|
519
|
+
if (existsSync(verDir)) discardRuntimeDir(runtimeBaseDir, verDir);
|
|
520
|
+
renameWithRetrySync(stagingDir, verDir);
|
|
521
|
+
}
|
|
522
|
+
writeFileAtomicSync(activeVersionPath(runtimeBaseDir), key, { fsyncDir: true });
|
|
523
|
+
return verDir;
|
|
459
524
|
}
|
|
460
525
|
|
|
461
526
|
/**
|
|
@@ -476,10 +541,11 @@ async function installRuntimeVersion({ runtimeBaseDir, url, sha256, size, versio
|
|
|
476
541
|
// process fail to obtain the lock for its own staging dir).
|
|
477
542
|
const stagingTag = `${Date.now()}-${process.pid}-${Math.random().toString(36).slice(2, 8)}`;
|
|
478
543
|
const stagingDir = join(runtimeBaseDir, `staging-${stagingTag}`);
|
|
479
|
-
|
|
544
|
+
// Named after the staging dir so the same live lock guards it from GC.
|
|
545
|
+
const tarPath = `${stagingDir}.tar.gz`;
|
|
480
546
|
|
|
481
|
-
const
|
|
482
|
-
const
|
|
547
|
+
const key = runtimeInstallKey(version, sha256);
|
|
548
|
+
const verDir = runtimeVerDir(runtimeBaseDir, key);
|
|
483
549
|
|
|
484
550
|
// Cross-process lock: hold the staging lockfile (O_EXCL) for the WHOLE
|
|
485
551
|
// download → extract → swap lifetime. GC in any concurrently-booting sibling
|
|
@@ -527,16 +593,11 @@ async function installRuntimeVersion({ runtimeBaseDir, url, sha256, size, versio
|
|
|
527
593
|
normalizeBinExecBit(stagingDir);
|
|
528
594
|
|
|
529
595
|
// Atomic swap:
|
|
530
|
-
// 1. Rename staging → runtime-{
|
|
596
|
+
// 1. Rename staging → runtime-{key} (or reuse a sibling's identical publish)
|
|
531
597
|
// 2. Write active-version.tmp → rename to active-version
|
|
532
598
|
// Stale dirs cleaned up by GC after.
|
|
533
599
|
try {
|
|
534
|
-
|
|
535
|
-
if (existsSync(verDir)) {
|
|
536
|
-
rmSync(verDir, { recursive: true, force: true });
|
|
537
|
-
}
|
|
538
|
-
renameWithRetrySync(stagingDir, verDir);
|
|
539
|
-
writeFileAtomicSync(avPath, version, { fsyncDir: true });
|
|
600
|
+
publishRuntimeDir(runtimeBaseDir, stagingDir, key, sha256);
|
|
540
601
|
} catch (swapErr) {
|
|
541
602
|
__mixdogMemoryLog(`[runtime-fetcher] atomic swap failed: ${swapErr.message}\n`);
|
|
542
603
|
// Attempt to leave things in a recoverable state: if verDir landed but
|
|
@@ -544,10 +605,10 @@ async function installRuntimeVersion({ runtimeBaseDir, url, sha256, size, versio
|
|
|
544
605
|
throw swapErr;
|
|
545
606
|
}
|
|
546
607
|
|
|
547
|
-
// GC: remove stale runtime-* dirs (anything that isn't runtime-{
|
|
608
|
+
// GC: remove stale runtime-* dirs (anything that isn't runtime-{key}).
|
|
548
609
|
// Still under the lock so the just-swapped staging lockfile reap below sees
|
|
549
610
|
// a consistent view.
|
|
550
|
-
gcRuntimeDir(runtimeBaseDir,
|
|
611
|
+
gcRuntimeDir(runtimeBaseDir, key);
|
|
551
612
|
} finally {
|
|
552
613
|
releaseStagingLock(stagingLock);
|
|
553
614
|
}
|
|
@@ -556,6 +617,16 @@ async function installRuntimeVersion({ runtimeBaseDir, url, sha256, size, versio
|
|
|
556
617
|
return verDir;
|
|
557
618
|
}
|
|
558
619
|
|
|
620
|
+
export const _runtimeLayoutInternals = {
|
|
621
|
+
acquireStagingLock,
|
|
622
|
+
cachedRuntimeDir,
|
|
623
|
+
discardRuntimeDir,
|
|
624
|
+
gcRuntimeDir,
|
|
625
|
+
publishRuntimeDir,
|
|
626
|
+
releaseStagingLock,
|
|
627
|
+
runtimeInstallKey,
|
|
628
|
+
};
|
|
629
|
+
|
|
559
630
|
export async function ensureRuntime(dataDir) {
|
|
560
631
|
const key = resolve(dataDir);
|
|
561
632
|
if (runtimeCache.has(key)) return runtimeCache.get(key);
|
|
@@ -139,6 +139,7 @@ export const CATALOG = {
|
|
|
139
139
|
properties: {
|
|
140
140
|
paragraph: ['style'],
|
|
141
141
|
font: ['name', 'nameEastAsia', 'size', 'bold', 'italic', 'underline', 'color', 'hidden'],
|
|
142
|
+
documentFont: ['name', 'nameEastAsia', 'size', 'color'],
|
|
142
143
|
page: [
|
|
143
144
|
'pageSize',
|
|
144
145
|
'orientation',
|
|
@@ -169,6 +170,7 @@ export const CATALOG = {
|
|
|
169
170
|
'headerRows',
|
|
170
171
|
'keepWithNext',
|
|
171
172
|
],
|
|
173
|
+
tableStyle: ['style', 'alignment', 'columnAlignments', 'columnWidths', 'borders', 'shading'],
|
|
172
174
|
tableCell: [
|
|
173
175
|
'fillColor',
|
|
174
176
|
'horizontalAlignment',
|
|
@@ -1,8 +1,13 @@
|
|
|
1
1
|
// Operation input signatures: which fields each Office operation requires,
|
|
2
2
|
// accepts, or takes as alternatives, per format.
|
|
3
|
+
import { XLSX_VALIDATION_OPERATORS, XLSX_VALIDATION_TYPES } from './portable/xlsx-contract.mjs';
|
|
3
4
|
|
|
4
|
-
export function signature(
|
|
5
|
-
|
|
5
|
+
export function signature(
|
|
6
|
+
required = [],
|
|
7
|
+
optional = [],
|
|
8
|
+
{ oneOf = [], propertySets = [], propertyBackends = {}, notes = '' } = {}
|
|
9
|
+
) {
|
|
10
|
+
return { required, optional, oneOf, propertySets, propertyBackends, notes };
|
|
6
11
|
}
|
|
7
12
|
|
|
8
13
|
export const COMMON_SIGNATURES = {
|
|
@@ -66,9 +71,10 @@ export const FORMAT_SIGNATURES = {
|
|
|
66
71
|
'properties.alignment places the table on the page (left, center, right); properties.columnAlignments sets the text of each column (one of left, center, right, justify per column); without it a column of figures (184,200, 2.1%, 2.6억 원) sets right and the rest left. Without style, borders, or shading the table takes a bold header row with a rule under it and hairlines between rows; headerBold:false keeps the header plain. properties.headerRows (default 1) is how many rows the header takes: a two-level header, its group label merged across the columns it spans (merge_table_cells), is headerRows:2, and every header row is set bold on its bottom edge and repeats on a continuation page.',
|
|
67
72
|
}),
|
|
68
73
|
set_table_style: signature(['table', 'properties'], [], {
|
|
69
|
-
propertySets: ['
|
|
74
|
+
propertySets: ['tableStyle'],
|
|
75
|
+
propertyBackends: { columnWidths: ['microsoft-office-com'] },
|
|
70
76
|
notes:
|
|
71
|
-
'Replaces
|
|
77
|
+
'Replaces table-level properties; columnAlignments re-aligns existing cells. columnWidths is Microsoft Office only. For existing cell fonts or fills use set_table_cell_style, not set_table_style.',
|
|
72
78
|
}),
|
|
73
79
|
merge_table_cells: signature(['table', 'row', 'col'], ['rowSpan', 'colSpan'], {
|
|
74
80
|
notes:
|
|
@@ -91,7 +97,7 @@ export const FORMAT_SIGNATURES = {
|
|
|
91
97
|
move_paragraph: signature(['paragraph', 'index']),
|
|
92
98
|
set_paragraph_style: signature(['paragraph', 'style']),
|
|
93
99
|
set_document_font: signature(['properties'], [], {
|
|
94
|
-
propertySets: ['
|
|
100
|
+
propertySets: ['documentFont'],
|
|
95
101
|
notes:
|
|
96
102
|
"The document's own face, size, and ink — what every paragraph without its own reads, and what a paragraph added later in Word or by append_text starts from. properties: name, nameEastAsia, size, color.",
|
|
97
103
|
}),
|
|
@@ -325,7 +331,7 @@ export const FORMAT_SIGNATURES = {
|
|
|
325
331
|
['sheet', 'type', 'operator', 'formula2', 'inputMessage', 'errorMessage'],
|
|
326
332
|
{
|
|
327
333
|
notes:
|
|
328
|
-
|
|
334
|
+
`type: ${XLSX_VALIDATION_TYPES.join(', ')}. Without type, choices ("a,b,c" or $A$1:$A$9) mean list; other formulas mean custom. Numeric/date/time/textLength rules take operator: ${XLSX_VALIDATION_OPERATORS.join(', ')} (default between). between/notBetween require formula2; other operators use formula1. Example: type:"whole", operator:"greaterThanOrEqual", formula1:"0". Under protect_sheet unlock entry cells with set_style properties:{ locked:false }.`,
|
|
329
335
|
}
|
|
330
336
|
),
|
|
331
337
|
freeze_panes: signature([], ['sheet', 'row', 'column'], {
|
|
@@ -3,6 +3,7 @@ import { plainObject } from './shared/values.mjs';
|
|
|
3
3
|
import { FIELD_ALIASES, OPERATION_ALIASES, PROPERTY_ALIASES } from './capabilities-aliases.mjs';
|
|
4
4
|
import { BACKENDS, CATALOG, COMMON, VIRTUAL_OPERATIONS } from './capabilities-catalog.mjs';
|
|
5
5
|
import { COMMON_SIGNATURES, FORMAT_SIGNATURES, signature } from './capabilities-signatures.mjs';
|
|
6
|
+
import { normalizeXlsxValidation } from './portable/xlsx-contract.mjs';
|
|
6
7
|
|
|
7
8
|
export { OFFICE_ACTIONS } from './capabilities-catalog.mjs';
|
|
8
9
|
|
|
@@ -141,6 +142,19 @@ function describeHint(format, backend, operation) {
|
|
|
141
142
|
})}.`;
|
|
142
143
|
}
|
|
143
144
|
|
|
145
|
+
function operationProperties(catalog, signatureValue, backend) {
|
|
146
|
+
return Object.fromEntries(
|
|
147
|
+
signatureValue.propertySets
|
|
148
|
+
.filter((name) => catalog.properties[name])
|
|
149
|
+
.map((name) => [
|
|
150
|
+
name,
|
|
151
|
+
catalog.properties[name].filter(
|
|
152
|
+
(field) => !backend || !signatureValue.propertyBackends[field] || signatureValue.propertyBackends[field].includes(backend)
|
|
153
|
+
),
|
|
154
|
+
])
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
|
|
144
158
|
function operationDescription(format, backend, catalog, requested) {
|
|
145
159
|
const knownOperations = catalogOperations(format);
|
|
146
160
|
const operation = resolveOperationAlias(format, requested);
|
|
@@ -152,11 +166,7 @@ function operationDescription(format, backend, catalog, requested) {
|
|
|
152
166
|
}
|
|
153
167
|
const available = operationsForBackend(format, backend);
|
|
154
168
|
const signatureValue = operationSignature(format, operation);
|
|
155
|
-
const properties =
|
|
156
|
-
signatureValue.propertySets
|
|
157
|
-
.filter((name) => catalog.properties[name])
|
|
158
|
-
.map((name) => [name, catalog.properties[name]])
|
|
159
|
-
);
|
|
169
|
+
const properties = operationProperties(catalog, signatureValue, backend);
|
|
160
170
|
return {
|
|
161
171
|
name: operation,
|
|
162
172
|
...(VIRTUAL_OPERATIONS.has(operation) || VIRTUAL_OPERATIONS.has(`${format}:${operation}`) ? { virtual: true } : {}),
|
|
@@ -311,10 +321,10 @@ function hoistGeometryProperties(signatureValue, operation, allowed) {
|
|
|
311
321
|
delete operation.properties;
|
|
312
322
|
}
|
|
313
323
|
|
|
314
|
-
function propertyKeySet(catalog, signatureValue) {
|
|
324
|
+
function propertyKeySet(catalog, signatureValue, backend) {
|
|
315
325
|
return new Set(
|
|
316
|
-
signatureValue
|
|
317
|
-
.
|
|
326
|
+
Object.values(operationProperties(catalog, signatureValue, backend))
|
|
327
|
+
.flat()
|
|
318
328
|
.map((entry) => String(entry).split('.')[0])
|
|
319
329
|
);
|
|
320
330
|
}
|
|
@@ -474,14 +484,24 @@ function operationContractFaults(batch, operation, index) {
|
|
|
474
484
|
if (cellFault) return [cellFault];
|
|
475
485
|
hoistComposeSheetTable(format, name, operation);
|
|
476
486
|
hoistGeometryProperties(signatureValue, operation, allowed);
|
|
477
|
-
const propertyKeys = propertyKeySet(catalog, signatureValue);
|
|
487
|
+
const propertyKeys = propertyKeySet(catalog, signatureValue, backend);
|
|
478
488
|
const missing = signatureValue.required.filter((field) => !suppliedField(operation, field, stableTargets));
|
|
479
|
-
|
|
489
|
+
const faults = [
|
|
480
490
|
unknownFieldsFault(batch, name, operation, index, { allowed, propertyKeys, missing }),
|
|
481
491
|
unknownPropertiesFault(batch, name, operation, index, propertyKeys),
|
|
482
492
|
...docxTableAlignmentFaults(format, name, operation, index),
|
|
483
493
|
requiredInputFault(batch, name, operation, index, signatureValue, stableTargets),
|
|
484
494
|
].filter(Boolean);
|
|
495
|
+
if (!faults.length && format === 'xlsx' && name === 'add_validation') {
|
|
496
|
+
try {
|
|
497
|
+
Object.assign(operation, normalizeXlsxValidation(operation));
|
|
498
|
+
} catch (error) {
|
|
499
|
+
faults.push(
|
|
500
|
+
`XLSX operation "${name}" at operation ${index + 1}: ${error.message}. ${describeHint(format, backend, name)}`
|
|
501
|
+
);
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
return faults;
|
|
485
505
|
}
|
|
486
506
|
|
|
487
507
|
export function assertOfficeOperationContracts({ format = '', backend = '', operations = [] } = {}) {
|
|
@@ -10,12 +10,7 @@ import {
|
|
|
10
10
|
releaseOfficeSession,
|
|
11
11
|
toolResult,
|
|
12
12
|
} from './office-core.mjs';
|
|
13
|
-
import { createSession, openSession, snapshot } from './office-sessions.mjs';
|
|
14
|
-
|
|
15
|
-
function initialOfficeOperations(args) {
|
|
16
|
-
if (Array.isArray(args.operations) && args.operations.length) return args.operations;
|
|
17
|
-
return Array.isArray(args.design?.operations) ? args.design.operations : [];
|
|
18
|
-
}
|
|
13
|
+
import { createSession, initialOfficeOperations, openSession, snapshot } from './office-sessions.mjs';
|
|
19
14
|
|
|
20
15
|
async function discardFailedOfficeSession(session, { action, initialEditSettled }) {
|
|
21
16
|
if (session.reused) return;
|
|
@@ -3,6 +3,7 @@ import { constants as fsConstants } from 'node:fs';
|
|
|
3
3
|
import { basename, dirname, extname, isAbsolute, join, resolve } from 'node:path';
|
|
4
4
|
import { tmpdir } from 'node:os';
|
|
5
5
|
import { randomUUID } from 'node:crypto';
|
|
6
|
+
import { assertOfficeOperationContracts } from '../capabilities.mjs';
|
|
6
7
|
import {
|
|
7
8
|
callMicrosoftOffice,
|
|
8
9
|
detectMicrosoftOffice,
|
|
@@ -496,6 +497,11 @@ async function createMicrosoftOfficeSession(creation, args, mode) {
|
|
|
496
497
|
});
|
|
497
498
|
}
|
|
498
499
|
|
|
500
|
+
export function initialOfficeOperations(args) {
|
|
501
|
+
if (Array.isArray(args.operations) && args.operations.length) return args.operations;
|
|
502
|
+
return Array.isArray(args.design?.operations) ? args.design.operations : [];
|
|
503
|
+
}
|
|
504
|
+
|
|
499
505
|
export async function createSession(args, cwd, dataDir) {
|
|
500
506
|
const requestedPath = String(args.path || args.output || '').trim();
|
|
501
507
|
if (!requestedPath) throw new Error('create requires path or output');
|
|
@@ -506,6 +512,8 @@ export async function createSession(args, cwd, dataDir) {
|
|
|
506
512
|
if (format !== inferredFormat)
|
|
507
513
|
throw new Error(`Office create format ${args.format} does not match target .${fileKind}`);
|
|
508
514
|
assertCreateContentFields(format, args);
|
|
515
|
+
const initialOperations = initialOfficeOperations(args);
|
|
516
|
+
assertOfficeOperationContracts({ format, operations: initialOperations });
|
|
509
517
|
// Whether this call brings the file into being decides what a failed create
|
|
510
518
|
// may clean up afterwards: a file it wrote itself, never one already there.
|
|
511
519
|
const targetExisted = await exists(target);
|
|
@@ -528,6 +536,7 @@ export async function createSession(args, cwd, dataDir) {
|
|
|
528
536
|
throw new Error('Office create requires visible, background, or portable mode');
|
|
529
537
|
}
|
|
530
538
|
const selected = await selectMode(requestedMode, format, target);
|
|
539
|
+
assertOfficeOperationContracts({ format, backend: selected.backend, operations: initialOperations });
|
|
531
540
|
if (selected.backend === 'mixdog-ooxml') return createPortableOoxmlSession(creation);
|
|
532
541
|
return createMicrosoftOfficeSession(creation, args, selected.mode);
|
|
533
542
|
}
|