editmamei 1.7.0 → 1.8.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 +5 -4
- package/dist/backends/gimp/bridge/lib.py +833 -4
- package/dist/backends/gimp/bridge/ops.py +1943 -110
- package/dist/bin/editmamei-core-darwin-arm64 +0 -0
- package/dist/bin/editmamei-core-darwin-x64 +0 -0
- package/dist/bin/editmamei-core-win-x64.exe +0 -0
- package/dist/cli/config.js +7 -0
- package/dist/cli/help.js +2 -2
- package/dist/cli/install.js +2 -2
- package/dist/core/settings.js +7 -0
- package/dist/core/tool-activity.js +13 -1
- package/dist/core/tool-groups.js +7 -1
- package/dist/core/tool-tiers.js +7 -1
- package/dist/install-channel.js +2 -0
- package/dist/modules/gimp/index.js +8 -2
- package/dist/skills/editmamei-skill.zip +0 -0
- package/dist/telemetry/client.js +23 -16
- package/dist/telemetry/outbox.js +248 -28
- package/dist/telemetry/sanitize.js +15 -1
- package/dist/tools/adjustment-tools.js +9 -1
- package/dist/tools/document-tools.js +50 -9
- package/dist/tools/filter-tools.js +2 -1
- package/dist/tools/gimp-adjustment-tools.js +3 -3
- package/dist/tools/gimp-core-tools.js +7 -8
- package/dist/tools/gimp-document-tools.js +4 -1
- package/dist/tools/gimp-effect-tools.js +2 -2
- package/dist/tools/gimp-geometry-tools.js +2 -2
- package/dist/tools/gimp-inspect-tools.js +35 -5
- package/dist/tools/gimp-layer-tools.js +5 -24
- package/dist/tools/gimp-match-tools.js +133 -0
- package/dist/tools/gimp-selection-tools.js +374 -0
- package/dist/tools/gimp-shared.js +12 -0
- package/dist/tools/gimp-text-tools.js +181 -0
- package/dist/tools/gimp-transform-layer-tools.js +215 -0
- package/dist/tools/gimp-verify-tools.js +2 -13
- package/dist/tools/image-placement-tools.js +6 -1
- package/dist/tools/sky-tools.js +2 -1
- package/dist/update/check.js +2 -0
- package/dist/utils/gimp-path.js +18 -22
- package/dist/utils/local-path.js +99 -0
- package/dist/utils/operation-timeouts.js +7 -1
- package/dist/version.js +1 -1
- package/package.json +2 -2
- package/dist/tools/gimp-mask-tools.js +0 -102
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/dist/cli/config.js
CHANGED
|
@@ -50,6 +50,13 @@ const KEYS = {
|
|
|
50
50
|
},
|
|
51
51
|
coerce: coerceBool,
|
|
52
52
|
},
|
|
53
|
+
'automation.allow_execute_script': {
|
|
54
|
+
get: (s) => s.automation.allow_execute_script,
|
|
55
|
+
set: (s, v) => {
|
|
56
|
+
s.automation.allow_execute_script = v;
|
|
57
|
+
},
|
|
58
|
+
coerce: coerceBool,
|
|
59
|
+
},
|
|
53
60
|
ps_path: {
|
|
54
61
|
get: (s) => s.ps_path,
|
|
55
62
|
set: (s, v) => {
|
package/dist/cli/help.js
CHANGED
|
@@ -27,8 +27,8 @@ Install options:
|
|
|
27
27
|
|
|
28
28
|
--skip-skill Skip copying the editmamei skill bundle to your Downloads folder.
|
|
29
29
|
By default, install drops editmamei-skill.zip in Downloads with
|
|
30
|
-
instructions for uploading it to claude.ai (Settings >
|
|
31
|
-
|
|
30
|
+
instructions for uploading it to claude.ai (Settings > Skills).
|
|
31
|
+
Pass this flag for headless / CI installs where the skill
|
|
32
32
|
upload step doesn't apply.
|
|
33
33
|
|
|
34
34
|
Config examples:
|
package/dist/cli/install.js
CHANGED
|
@@ -61,8 +61,8 @@ export async function runInstall(opts = {}) {
|
|
|
61
61
|
}
|
|
62
62
|
if (skillResult?.status === 'copied') {
|
|
63
63
|
out(` 2. Upload the editmamei skill to your Claude account:\n`);
|
|
64
|
-
out(` - Open https://claude.ai/settings (
|
|
65
|
-
out(` - Click "Upload skill"\n`);
|
|
64
|
+
out(` - Open https://claude.ai/settings (Settings > Skills)\n`);
|
|
65
|
+
out(` - Click Add, then "Upload skill"\n`);
|
|
66
66
|
out(` - Choose the file at ${skillResult.destPath}\n`);
|
|
67
67
|
out(` The skill auto-loads in any photo-editing conversation. It's a one-time upload.\n`);
|
|
68
68
|
out(` 3. Then ask the AI: "Is Photoshop connected?" — it will call ps_ping and return your Photoshop version.\n`);
|
package/dist/core/settings.js
CHANGED
|
@@ -21,6 +21,7 @@ function defaults(installId) {
|
|
|
21
21
|
return {
|
|
22
22
|
telemetry: { usage: true, diagnostics: false, install_id: installId },
|
|
23
23
|
privacy: { send_previews_to_llm: true },
|
|
24
|
+
automation: { allow_execute_script: true },
|
|
24
25
|
ps_path: null,
|
|
25
26
|
update_check: true,
|
|
26
27
|
editor: 'auto',
|
|
@@ -34,6 +35,7 @@ function coerce(raw, installId) {
|
|
|
34
35
|
const r = raw;
|
|
35
36
|
const t = (r.telemetry ?? {});
|
|
36
37
|
const p = (r.privacy ?? {});
|
|
38
|
+
const a = (r.automation ?? {});
|
|
37
39
|
return {
|
|
38
40
|
telemetry: {
|
|
39
41
|
usage: typeof t.usage === 'boolean' ? t.usage : base.telemetry.usage,
|
|
@@ -47,6 +49,11 @@ function coerce(raw, installId) {
|
|
|
47
49
|
? p.send_previews_to_llm
|
|
48
50
|
: base.privacy.send_previews_to_llm,
|
|
49
51
|
},
|
|
52
|
+
automation: {
|
|
53
|
+
allow_execute_script: typeof a.allow_execute_script === 'boolean'
|
|
54
|
+
? a.allow_execute_script
|
|
55
|
+
: base.automation.allow_execute_script,
|
|
56
|
+
},
|
|
50
57
|
ps_path: typeof r.ps_path === 'string' ? r.ps_path : null,
|
|
51
58
|
update_check: typeof r.update_check === 'boolean' ? r.update_check : base.update_check,
|
|
52
59
|
editor: typeof r.editor === 'string' && EDITOR_PINS.includes(r.editor)
|
|
@@ -1,3 +1,9 @@
|
|
|
1
|
+
export const RECIPE_TOOLS = new Set([
|
|
2
|
+
'ps_template_apply',
|
|
3
|
+
'ps_batch',
|
|
4
|
+
'ps_sequence',
|
|
5
|
+
'ps_play_action',
|
|
6
|
+
]);
|
|
1
7
|
export const READ_ONLY_TOOLS = new Set([
|
|
2
8
|
'ps_sequence',
|
|
3
9
|
'ps_batch',
|
|
@@ -29,6 +35,7 @@ export const READ_ONLY_TOOLS = new Set([
|
|
|
29
35
|
'gimp_get_preview',
|
|
30
36
|
'gimp_get_histogram',
|
|
31
37
|
'gimp_compare',
|
|
38
|
+
'gimp_get_selection_preview',
|
|
32
39
|
]);
|
|
33
40
|
export const KEPT_WORK_TOOLS = new Set([
|
|
34
41
|
'ps_export',
|
|
@@ -109,14 +116,19 @@ export const MUTATING_TOOLS = new Set([
|
|
|
109
116
|
'gimp_crop_document',
|
|
110
117
|
'gimp_resize_image',
|
|
111
118
|
'gimp_transform_canvas',
|
|
112
|
-
'gimp_create_mask',
|
|
113
119
|
'gimp_checkpoint',
|
|
114
120
|
'gimp_layer',
|
|
115
121
|
'gimp_bake',
|
|
116
122
|
'gimp_create_document',
|
|
117
123
|
'gimp_place_image',
|
|
118
124
|
'gimp_canvas',
|
|
125
|
+
'gimp_text',
|
|
119
126
|
'gimp_convert_image_mode',
|
|
127
|
+
'gimp_select',
|
|
128
|
+
'gimp_modify_selection',
|
|
129
|
+
'gimp_layer_mask',
|
|
130
|
+
'gimp_transform_layer',
|
|
131
|
+
'gimp_match_layer',
|
|
120
132
|
]);
|
|
121
133
|
export const RAW_DEVELOP_TOOL = 'ps_develop_raw';
|
|
122
134
|
export const CAMERA_RAW_TOOL = 'ps_apply_camera_raw';
|
package/dist/core/tool-groups.js
CHANGED
|
@@ -187,14 +187,20 @@ export const TOOL_GROUPS = {
|
|
|
187
187
|
gimp_add_adjustment: 'adjust',
|
|
188
188
|
gimp_filter: 'filter',
|
|
189
189
|
gimp_add_effect: 'filter',
|
|
190
|
-
gimp_create_mask: 'masks',
|
|
191
190
|
gimp_checkpoint: 'document',
|
|
192
191
|
gimp_layer: 'layers',
|
|
193
192
|
gimp_bake: 'layers',
|
|
193
|
+
gimp_transform_layer: 'layers',
|
|
194
194
|
gimp_create_document: 'document',
|
|
195
195
|
gimp_place_image: 'layers',
|
|
196
196
|
gimp_canvas: 'document',
|
|
197
|
+
gimp_text: 'layers',
|
|
197
198
|
gimp_convert_image_mode: 'document',
|
|
199
|
+
gimp_select: 'select',
|
|
200
|
+
gimp_modify_selection: 'select',
|
|
201
|
+
gimp_layer_mask: 'masks',
|
|
202
|
+
gimp_get_selection_preview: 'verify',
|
|
203
|
+
gimp_match_layer: 'layers',
|
|
198
204
|
};
|
|
199
205
|
export function groupOf(toolName) {
|
|
200
206
|
const group = TOOL_GROUPS[toolName];
|
package/dist/core/tool-tiers.js
CHANGED
|
@@ -102,17 +102,23 @@ export const TOOL_TIERS = {
|
|
|
102
102
|
gimp_crop_document: 'community',
|
|
103
103
|
gimp_resize_image: 'community',
|
|
104
104
|
gimp_transform_canvas: 'community',
|
|
105
|
-
gimp_create_mask: 'community',
|
|
106
105
|
gimp_get_preview: 'community',
|
|
107
106
|
gimp_get_histogram: 'community',
|
|
108
107
|
gimp_compare: 'community',
|
|
109
108
|
gimp_checkpoint: 'community',
|
|
110
109
|
gimp_layer: 'community',
|
|
111
110
|
gimp_bake: 'community',
|
|
111
|
+
gimp_transform_layer: 'community',
|
|
112
112
|
gimp_create_document: 'community',
|
|
113
113
|
gimp_place_image: 'community',
|
|
114
114
|
gimp_canvas: 'community',
|
|
115
|
+
gimp_text: 'community',
|
|
115
116
|
gimp_convert_image_mode: 'community',
|
|
117
|
+
gimp_select: 'community',
|
|
118
|
+
gimp_modify_selection: 'dev',
|
|
119
|
+
gimp_layer_mask: 'community',
|
|
120
|
+
gimp_get_selection_preview: 'community',
|
|
121
|
+
gimp_match_layer: 'dev',
|
|
116
122
|
};
|
|
117
123
|
export function tierOf(toolName) {
|
|
118
124
|
const tier = TOOL_TIERS[toolName];
|
package/dist/install-channel.js
CHANGED
|
@@ -30,6 +30,8 @@ export function resolveInstallChannel(env = process.env, edition = EDITION, argv
|
|
|
30
30
|
return 'dev';
|
|
31
31
|
if (env.EDITMAMEI_INSTALL_CHANNEL === 'mcpb')
|
|
32
32
|
return 'mcpb';
|
|
33
|
+
if (env.EDITMAMEI_INSTALL_CHANNEL === 'plugin')
|
|
34
|
+
return 'plugin';
|
|
33
35
|
const trimmedArgv1 = argv1?.trim();
|
|
34
36
|
if (!trimmedArgv1)
|
|
35
37
|
return 'unknown';
|
|
@@ -8,11 +8,14 @@ import { createGimpAdjustmentTools } from '../../tools/gimp-adjustment-tools.js'
|
|
|
8
8
|
import { createGimpEffectTools } from '../../tools/gimp-effect-tools.js';
|
|
9
9
|
import { createGimpFilterTools } from '../../tools/gimp-filter-tools.js';
|
|
10
10
|
import { createGimpGeometryTools } from '../../tools/gimp-geometry-tools.js';
|
|
11
|
-
import { createGimpMaskTools } from '../../tools/gimp-mask-tools.js';
|
|
12
11
|
import { createGimpVerifyTools } from '../../tools/gimp-verify-tools.js';
|
|
13
12
|
import { createGimpCheckpointTools } from '../../tools/gimp-checkpoint-tools.js';
|
|
14
13
|
import { createGimpLayerTools } from '../../tools/gimp-layer-tools.js';
|
|
14
|
+
import { createGimpTransformLayerTools } from '../../tools/gimp-transform-layer-tools.js';
|
|
15
15
|
import { createGimpComposeTools } from '../../tools/gimp-compose-tools.js';
|
|
16
|
+
import { createGimpTextTools } from '../../tools/gimp-text-tools.js';
|
|
17
|
+
import { createGimpSelectionTools } from '../../tools/gimp-selection-tools.js';
|
|
18
|
+
import { createGimpMatchTools } from '../../tools/gimp-match-tools.js';
|
|
16
19
|
export const gimpFactories = [
|
|
17
20
|
createGimpCoreTools,
|
|
18
21
|
createGimpDocumentTools,
|
|
@@ -21,11 +24,14 @@ export const gimpFactories = [
|
|
|
21
24
|
createGimpEffectTools,
|
|
22
25
|
createGimpFilterTools,
|
|
23
26
|
createGimpGeometryTools,
|
|
24
|
-
createGimpMaskTools,
|
|
25
27
|
createGimpVerifyTools,
|
|
26
28
|
createGimpCheckpointTools,
|
|
27
29
|
createGimpLayerTools,
|
|
30
|
+
createGimpTransformLayerTools,
|
|
28
31
|
createGimpComposeTools,
|
|
32
|
+
createGimpTextTools,
|
|
33
|
+
createGimpSelectionTools,
|
|
34
|
+
createGimpMatchTools,
|
|
29
35
|
];
|
|
30
36
|
export const gimpModule = {
|
|
31
37
|
manifest: { id: 'gimp', name: 'Editmamei GIMP Tools', abi: KERNEL_ABI },
|
|
Binary file
|
package/dist/telemetry/client.js
CHANGED
|
@@ -7,7 +7,7 @@ import { READ_ONLY_TOOLS, KEPT_WORK_TOOLS, nodeMajor, archToken, osMajor, boundM
|
|
|
7
7
|
import { buildClientConnected, buildDiagnosticEvent, buildModuleStatus, buildSessionStart, buildSessionSummary, buildUsageEvent, dayBucket, normalizeDayBucket, isContentSafe, PS_VERSION_UNKNOWN, } from './events.js';
|
|
8
8
|
import { sanitizeMessage, sanitizeSnippet, sanitizeStderrTail } from './sanitize.js';
|
|
9
9
|
import { httpTransport, resolveEndpoint } from './transport.js';
|
|
10
|
-
import { appendOutboxSync,
|
|
10
|
+
import { appendOutboxSync, claimOrphanedSessionStates, claimOutboxForDrain, clearSessionState, writeSessionStateSync, } from './outbox.js';
|
|
11
11
|
const MAX_BATCH_SIZE = 100;
|
|
12
12
|
const MAX_QUEUE_SIZE = 500;
|
|
13
13
|
const DEFAULT_FLUSH_INTERVAL_MS = 5 * 60_000;
|
|
@@ -50,6 +50,8 @@ export class TelemetryClient {
|
|
|
50
50
|
moduleUpdate = 'none';
|
|
51
51
|
installAssets = {};
|
|
52
52
|
lastSessionPersistMs = 0;
|
|
53
|
+
sessionStatePersisted = false;
|
|
54
|
+
startupFlushStarted = false;
|
|
53
55
|
startDayBucket = null;
|
|
54
56
|
constructor(opts) {
|
|
55
57
|
this.settings = opts.settings;
|
|
@@ -59,7 +61,11 @@ export class TelemetryClient {
|
|
|
59
61
|
this.flushIntervalMs = opts.flushIntervalMs ?? DEFAULT_FLUSH_INTERVAL_MS;
|
|
60
62
|
this.maxBatchSize = opts.maxBatchSize ?? MAX_BATCH_SIZE;
|
|
61
63
|
this.active = opts.active ?? (EDITION !== 'dev' && !isTestEnv());
|
|
62
|
-
this.outboxOpts =
|
|
64
|
+
this.outboxOpts = {
|
|
65
|
+
...(opts.outboxDir ? { dir: opts.outboxDir } : {}),
|
|
66
|
+
...(opts.outboxPid !== undefined ? { pid: opts.outboxPid } : {}),
|
|
67
|
+
...(opts.isPidAlive ? { isPidAlive: opts.isPidAlive } : {}),
|
|
68
|
+
};
|
|
63
69
|
this.getModuleStatus = opts.getModuleStatus ?? (() => null);
|
|
64
70
|
this.dims = {
|
|
65
71
|
install_id: this.settings.telemetry.install_id,
|
|
@@ -200,6 +206,7 @@ export class TelemetryClient {
|
|
|
200
206
|
...this.summaryFields(),
|
|
201
207
|
};
|
|
202
208
|
writeSessionStateSync(state, this.outboxOpts);
|
|
209
|
+
this.sessionStatePersisted = true;
|
|
203
210
|
}
|
|
204
211
|
onPsVersionResolved() {
|
|
205
212
|
if (!this.active || !this.settings.telemetry.usage || this.toolCallCount === 0)
|
|
@@ -297,22 +304,27 @@ export class TelemetryClient {
|
|
|
297
304
|
clearSessionState(this.outboxOpts);
|
|
298
305
|
}
|
|
299
306
|
async flushOutboxOnStartup() {
|
|
300
|
-
if (!this.active)
|
|
307
|
+
if (!this.active || this.startupFlushStarted)
|
|
301
308
|
return;
|
|
309
|
+
this.startupFlushStarted = true;
|
|
302
310
|
try {
|
|
303
|
-
const
|
|
304
|
-
|
|
305
|
-
|
|
311
|
+
const orphans = claimOrphanedSessionStates(this.outboxOpts, {
|
|
312
|
+
includeOwn: !this.sessionStatePersisted,
|
|
313
|
+
});
|
|
314
|
+
if (this.settings.telemetry.usage) {
|
|
315
|
+
const summaries = orphans.states.filter((s) => s.tool_call_count > 0).map(summaryFromState);
|
|
316
|
+
this.droppedOutbox += appendOutboxSync(summaries, this.outboxOpts);
|
|
306
317
|
}
|
|
307
|
-
|
|
308
|
-
const
|
|
318
|
+
orphans.release();
|
|
319
|
+
const claimed = claimOutboxForDrain(this.outboxOpts);
|
|
320
|
+
const { events: pending, discarded } = claimed;
|
|
309
321
|
this.droppedOutbox += discarded;
|
|
310
322
|
if (pending.length === 0) {
|
|
311
|
-
|
|
323
|
+
claimed.release([]);
|
|
312
324
|
return;
|
|
313
325
|
}
|
|
314
326
|
if (!this.settings.telemetry.usage) {
|
|
315
|
-
|
|
327
|
+
claimed.release([]);
|
|
316
328
|
return;
|
|
317
329
|
}
|
|
318
330
|
const sendable = pending.filter(isContentSafe);
|
|
@@ -330,12 +342,7 @@ export class TelemetryClient {
|
|
|
330
342
|
}
|
|
331
343
|
settled = i + batch.length;
|
|
332
344
|
}
|
|
333
|
-
|
|
334
|
-
clearOutbox(this.outboxOpts);
|
|
335
|
-
}
|
|
336
|
-
else {
|
|
337
|
-
rewriteOutbox(sendable.slice(settled), this.outboxOpts);
|
|
338
|
-
}
|
|
345
|
+
this.droppedOutbox += claimed.release(sendable.slice(settled));
|
|
339
346
|
}
|
|
340
347
|
catch (err) {
|
|
341
348
|
this.logger.debug(`startup outbox flush error: ${errMsg(err)}`);
|
package/dist/telemetry/outbox.js
CHANGED
|
@@ -1,21 +1,86 @@
|
|
|
1
1
|
import { homedir } from 'node:os';
|
|
2
2
|
import { join, dirname } from 'node:path';
|
|
3
|
-
import { existsSync, mkdirSync, readFileSync, writeFileSync, renameSync, appendFileSync, statSync, rmSync, } from 'node:fs';
|
|
3
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync, renameSync, appendFileSync, statSync, rmSync, } from 'node:fs';
|
|
4
4
|
import { Logger } from '../utils/logger.js';
|
|
5
5
|
const logger = new Logger('TelemetryOutbox');
|
|
6
6
|
const DIRNAME = '.editmamei';
|
|
7
7
|
const OUTBOX_FILENAME = 'telemetry-outbox.ndjson';
|
|
8
|
-
const
|
|
8
|
+
const LEGACY_SESSION_STATE_FILENAME = 'telemetry-session.json';
|
|
9
|
+
const SESSION_STATE_RE = /^telemetry-session\.(\d+)\.json$/;
|
|
10
|
+
const RECOVER_RE = /^\.session-recover\.(\d+)\.(telemetry-session(?:\.\d+)?\.json)$/;
|
|
11
|
+
const CLAIM_RE = /^telemetry-outbox\.(?:draining|compacting)\.(\d+)\.(\d+)\.ndjson$/;
|
|
9
12
|
export const MAX_OUTBOX_EVENTS = 5_000;
|
|
10
13
|
export const MAX_OUTBOX_BYTES = 2_000_000;
|
|
14
|
+
export function isPidAlive(pid) {
|
|
15
|
+
try {
|
|
16
|
+
process.kill(pid, 0);
|
|
17
|
+
return true;
|
|
18
|
+
}
|
|
19
|
+
catch (err) {
|
|
20
|
+
return err.code === 'EPERM';
|
|
21
|
+
}
|
|
22
|
+
}
|
|
11
23
|
function baseDir(opts) {
|
|
12
24
|
return opts.dir ?? join(homedir(), DIRNAME);
|
|
13
25
|
}
|
|
14
26
|
export function outboxPath(opts = {}) {
|
|
15
27
|
return join(baseDir(opts), OUTBOX_FILENAME);
|
|
16
28
|
}
|
|
29
|
+
function ownPid(opts) {
|
|
30
|
+
return opts.pid ?? process.pid;
|
|
31
|
+
}
|
|
17
32
|
export function sessionStatePath(opts = {}) {
|
|
18
|
-
return join(baseDir(opts),
|
|
33
|
+
return join(baseDir(opts), `telemetry-session.${ownPid(opts)}.json`);
|
|
34
|
+
}
|
|
35
|
+
function listDir(dir) {
|
|
36
|
+
try {
|
|
37
|
+
return readdirSync(dir);
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
return [];
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
function claim(from, to) {
|
|
44
|
+
try {
|
|
45
|
+
renameSync(from, to);
|
|
46
|
+
return to;
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
function freeClaimName(dir, kind, pid) {
|
|
53
|
+
for (let seq = 0;; seq++) {
|
|
54
|
+
const path = join(dir, `telemetry-outbox.${kind}.${pid}.${seq}.ndjson`);
|
|
55
|
+
if (!existsSync(path))
|
|
56
|
+
return path;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
function readClaim(path) {
|
|
60
|
+
const buf = readFileSync(path);
|
|
61
|
+
const bytes = buf.lastIndexOf(0x0a) + 1;
|
|
62
|
+
return { ...parseOutbox(buf.subarray(0, bytes).toString('utf8')), bytes };
|
|
63
|
+
}
|
|
64
|
+
function readClaimTail(path, bytes) {
|
|
65
|
+
const buf = readFileSync(path);
|
|
66
|
+
if (buf.length <= bytes)
|
|
67
|
+
return { events: [], discarded: 0 };
|
|
68
|
+
return parseOutbox(buf.subarray(bytes).toString('utf8'));
|
|
69
|
+
}
|
|
70
|
+
function removeClaim(path, what) {
|
|
71
|
+
try {
|
|
72
|
+
rmSync(path, { force: true });
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
catch (err) {
|
|
76
|
+
logger.debug(`${what} delete failed: ${errMsg(err)}`);
|
|
77
|
+
}
|
|
78
|
+
try {
|
|
79
|
+
writeFileSync(path, '');
|
|
80
|
+
}
|
|
81
|
+
catch (err) {
|
|
82
|
+
logger.debug(`${what} truncate failed: ${errMsg(err)}`);
|
|
83
|
+
}
|
|
19
84
|
}
|
|
20
85
|
function ensureDir(path) {
|
|
21
86
|
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
@@ -44,31 +109,100 @@ export function readOutboxWithDiscards(opts = {}) {
|
|
|
44
109
|
if (!existsSync(path))
|
|
45
110
|
return { events: [], discarded: 0 };
|
|
46
111
|
try {
|
|
47
|
-
|
|
48
|
-
const events = [];
|
|
49
|
-
let discarded = 0;
|
|
50
|
-
for (const line of raw.split('\n')) {
|
|
51
|
-
const trimmed = line.trim();
|
|
52
|
-
if (trimmed.length === 0)
|
|
53
|
-
continue;
|
|
54
|
-
try {
|
|
55
|
-
events.push(JSON.parse(trimmed));
|
|
56
|
-
}
|
|
57
|
-
catch {
|
|
58
|
-
discarded += 1;
|
|
59
|
-
}
|
|
60
|
-
}
|
|
61
|
-
if (events.length > MAX_OUTBOX_EVENTS) {
|
|
62
|
-
const over = events.length - MAX_OUTBOX_EVENTS;
|
|
63
|
-
return { events: events.slice(over), discarded: discarded + over };
|
|
64
|
-
}
|
|
65
|
-
return { events, discarded };
|
|
112
|
+
return boundOutbox(parseOutbox(readFileSync(path, 'utf8')));
|
|
66
113
|
}
|
|
67
114
|
catch (err) {
|
|
68
115
|
logger.debug(`outbox read failed: ${errMsg(err)}`);
|
|
69
116
|
return { events: [], discarded: 0 };
|
|
70
117
|
}
|
|
71
118
|
}
|
|
119
|
+
function parseOutbox(raw) {
|
|
120
|
+
const events = [];
|
|
121
|
+
let discarded = 0;
|
|
122
|
+
for (const line of raw.split('\n')) {
|
|
123
|
+
const trimmed = line.trim();
|
|
124
|
+
if (trimmed.length === 0)
|
|
125
|
+
continue;
|
|
126
|
+
try {
|
|
127
|
+
events.push(JSON.parse(trimmed));
|
|
128
|
+
}
|
|
129
|
+
catch {
|
|
130
|
+
discarded += 1;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return { events, discarded };
|
|
134
|
+
}
|
|
135
|
+
function boundOutbox({ events, discarded }) {
|
|
136
|
+
if (events.length <= MAX_OUTBOX_EVENTS)
|
|
137
|
+
return { events, discarded };
|
|
138
|
+
const over = events.length - MAX_OUTBOX_EVENTS;
|
|
139
|
+
return { events: events.slice(over), discarded: discarded + over };
|
|
140
|
+
}
|
|
141
|
+
export function claimOutboxForDrain(opts = {}) {
|
|
142
|
+
const dir = baseDir(opts);
|
|
143
|
+
const me = ownPid(opts);
|
|
144
|
+
const alive = opts.isPidAlive ?? isPidAlive;
|
|
145
|
+
const claimed = [];
|
|
146
|
+
const nextName = () => freeClaimName(dir, 'draining', me);
|
|
147
|
+
const abandoned = listDir(dir)
|
|
148
|
+
.flatMap((name) => {
|
|
149
|
+
const m = CLAIM_RE.exec(name);
|
|
150
|
+
return m ? [{ name, pid: Number(m[1]), seq: Number(m[2]) }] : [];
|
|
151
|
+
})
|
|
152
|
+
.filter((c) => c.pid === me || !alive(c.pid))
|
|
153
|
+
.sort((a, b) => a.pid - b.pid || a.seq - b.seq || a.name.localeCompare(b.name));
|
|
154
|
+
for (const c of abandoned) {
|
|
155
|
+
const from = join(dir, c.name);
|
|
156
|
+
const got = c.pid === me ? from : claim(from, nextName());
|
|
157
|
+
if (got)
|
|
158
|
+
claimed.push(got);
|
|
159
|
+
}
|
|
160
|
+
const live = claim(outboxPath(opts), nextName());
|
|
161
|
+
if (live)
|
|
162
|
+
claimed.push(live);
|
|
163
|
+
const consumed = [];
|
|
164
|
+
let read = { events: [], discarded: 0 };
|
|
165
|
+
for (const path of claimed) {
|
|
166
|
+
try {
|
|
167
|
+
const part = readClaim(path);
|
|
168
|
+
read = {
|
|
169
|
+
events: read.events.concat(part.events),
|
|
170
|
+
discarded: read.discarded + part.discarded,
|
|
171
|
+
};
|
|
172
|
+
consumed.push({ path, bytes: part.bytes });
|
|
173
|
+
}
|
|
174
|
+
catch (err) {
|
|
175
|
+
logger.debug(`claimed outbox read failed: ${errMsg(err)}`);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
const { events, discarded } = boundOutbox(read);
|
|
179
|
+
let released = false;
|
|
180
|
+
return {
|
|
181
|
+
events,
|
|
182
|
+
discarded,
|
|
183
|
+
release(undelivered) {
|
|
184
|
+
if (released)
|
|
185
|
+
return 0;
|
|
186
|
+
released = true;
|
|
187
|
+
const late = [];
|
|
188
|
+
let lost = 0;
|
|
189
|
+
for (const { path, bytes } of consumed) {
|
|
190
|
+
try {
|
|
191
|
+
const tail = readClaimTail(path, bytes);
|
|
192
|
+
late.push(...tail.events);
|
|
193
|
+
lost += tail.discarded;
|
|
194
|
+
}
|
|
195
|
+
catch (err) {
|
|
196
|
+
logger.debug(`claimed outbox re-read failed: ${errMsg(err)}`);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
lost += appendOutboxSync(undelivered.concat(late), opts);
|
|
200
|
+
for (const { path } of consumed)
|
|
201
|
+
removeClaim(path, 'claimed outbox');
|
|
202
|
+
return lost;
|
|
203
|
+
},
|
|
204
|
+
};
|
|
205
|
+
}
|
|
72
206
|
export function readOutbox(opts = {}) {
|
|
73
207
|
return readOutboxWithDiscards(opts).events;
|
|
74
208
|
}
|
|
@@ -88,7 +222,7 @@ export function rewriteOutbox(events, opts = {}) {
|
|
|
88
222
|
const path = outboxPath(opts);
|
|
89
223
|
try {
|
|
90
224
|
ensureDir(path);
|
|
91
|
-
const tmp = join(dirname(path), `.outbox.${
|
|
225
|
+
const tmp = join(dirname(path), `.outbox.${ownPid(opts)}.tmp`);
|
|
92
226
|
writeFileSync(tmp, events.map((e) => JSON.stringify(e)).join('\n') + '\n', {
|
|
93
227
|
encoding: 'utf8',
|
|
94
228
|
mode: 0o600,
|
|
@@ -100,7 +234,18 @@ export function rewriteOutbox(events, opts = {}) {
|
|
|
100
234
|
}
|
|
101
235
|
}
|
|
102
236
|
function compactOutbox(opts = {}) {
|
|
103
|
-
const
|
|
237
|
+
const path = claim(outboxPath(opts), freeClaimName(baseDir(opts), 'compacting', ownPid(opts)));
|
|
238
|
+
if (!path)
|
|
239
|
+
return 0;
|
|
240
|
+
let read;
|
|
241
|
+
try {
|
|
242
|
+
read = readClaim(path);
|
|
243
|
+
}
|
|
244
|
+
catch (err) {
|
|
245
|
+
logger.debug(`compaction read failed, leaving the claim for recovery: ${errMsg(err)}`);
|
|
246
|
+
return 0;
|
|
247
|
+
}
|
|
248
|
+
const { events, discarded } = boundOutbox(read);
|
|
104
249
|
const target = MAX_OUTBOX_BYTES / 2;
|
|
105
250
|
let bytes = 0;
|
|
106
251
|
let firstKept = Math.max(events.length - 1, 0);
|
|
@@ -111,14 +256,34 @@ function compactOutbox(opts = {}) {
|
|
|
111
256
|
firstKept = i;
|
|
112
257
|
}
|
|
113
258
|
const kept = events.slice(firstKept);
|
|
114
|
-
|
|
115
|
-
|
|
259
|
+
let late = { events: [], discarded: 0 };
|
|
260
|
+
try {
|
|
261
|
+
late = readClaimTail(path, read.bytes);
|
|
262
|
+
}
|
|
263
|
+
catch (err) {
|
|
264
|
+
logger.debug(`compaction re-read failed: ${errMsg(err)}`);
|
|
265
|
+
}
|
|
266
|
+
const back = kept.concat(late.events);
|
|
267
|
+
if (back.length > 0) {
|
|
268
|
+
try {
|
|
269
|
+
appendFileSync(outboxPath(opts), back.map((e) => JSON.stringify(e)).join('\n') + '\n', {
|
|
270
|
+
encoding: 'utf8',
|
|
271
|
+
mode: 0o600,
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
catch (err) {
|
|
275
|
+
logger.debug(`compaction write-back failed, leaving the claim for recovery: ${errMsg(err)}`);
|
|
276
|
+
return 0;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
removeClaim(path, 'compaction claim');
|
|
280
|
+
return discarded + (events.length - kept.length) + late.discarded;
|
|
116
281
|
}
|
|
117
282
|
export function writeSessionStateSync(state, opts = {}) {
|
|
118
283
|
const path = sessionStatePath(opts);
|
|
119
284
|
try {
|
|
120
285
|
ensureDir(path);
|
|
121
|
-
const tmp = join(dirname(path), `.session.${
|
|
286
|
+
const tmp = join(dirname(path), `.session.${ownPid(opts)}.tmp`);
|
|
122
287
|
writeFileSync(tmp, JSON.stringify(state), { encoding: 'utf8', mode: 0o600 });
|
|
123
288
|
renameSync(tmp, path);
|
|
124
289
|
}
|
|
@@ -127,7 +292,62 @@ export function writeSessionStateSync(state, opts = {}) {
|
|
|
127
292
|
}
|
|
128
293
|
}
|
|
129
294
|
export function readSessionState(opts = {}) {
|
|
130
|
-
|
|
295
|
+
return readSessionStateAt(sessionStatePath(opts));
|
|
296
|
+
}
|
|
297
|
+
export function claimOrphanedSessionStates(opts = {}, { includeOwn }) {
|
|
298
|
+
const dir = baseDir(opts);
|
|
299
|
+
const me = ownPid(opts);
|
|
300
|
+
const alive = opts.isPidAlive ?? isPidAlive;
|
|
301
|
+
const takeable = (pid) => (pid === me ? includeOwn : !alive(pid));
|
|
302
|
+
const claimed = [];
|
|
303
|
+
for (const name of listDir(dir)) {
|
|
304
|
+
let original;
|
|
305
|
+
const r = RECOVER_RE.exec(name);
|
|
306
|
+
if (r) {
|
|
307
|
+
const pid = Number(r[1]);
|
|
308
|
+
if (!takeable(pid))
|
|
309
|
+
continue;
|
|
310
|
+
if (pid === me) {
|
|
311
|
+
claimed.push(join(dir, name));
|
|
312
|
+
continue;
|
|
313
|
+
}
|
|
314
|
+
original = r[2];
|
|
315
|
+
}
|
|
316
|
+
else if (name === LEGACY_SESSION_STATE_FILENAME) {
|
|
317
|
+
original = name;
|
|
318
|
+
}
|
|
319
|
+
else {
|
|
320
|
+
const m = SESSION_STATE_RE.exec(name);
|
|
321
|
+
if (!m || !takeable(Number(m[1])))
|
|
322
|
+
continue;
|
|
323
|
+
original = name;
|
|
324
|
+
}
|
|
325
|
+
const target = join(dir, `.session-recover.${me}.${original}`);
|
|
326
|
+
if (existsSync(target))
|
|
327
|
+
continue;
|
|
328
|
+
const got = claim(join(dir, name), target);
|
|
329
|
+
if (got)
|
|
330
|
+
claimed.push(got);
|
|
331
|
+
}
|
|
332
|
+
const states = [];
|
|
333
|
+
for (const path of claimed) {
|
|
334
|
+
const state = readSessionStateAt(path);
|
|
335
|
+
if (state)
|
|
336
|
+
states.push(state);
|
|
337
|
+
}
|
|
338
|
+
let released = false;
|
|
339
|
+
return {
|
|
340
|
+
states,
|
|
341
|
+
release() {
|
|
342
|
+
if (released)
|
|
343
|
+
return;
|
|
344
|
+
released = true;
|
|
345
|
+
for (const path of claimed)
|
|
346
|
+
removeClaim(path, 'session-state recover');
|
|
347
|
+
},
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
function readSessionStateAt(path) {
|
|
131
351
|
if (!existsSync(path))
|
|
132
352
|
return null;
|
|
133
353
|
try {
|
|
@@ -23,8 +23,22 @@ function homedirPrefixes() {
|
|
|
23
23
|
return [...set];
|
|
24
24
|
}
|
|
25
25
|
const SINGLE_SEG_POSIX = /(^|\s)\/[^\s"'/]+(?=$|\s)/;
|
|
26
|
+
const WORD = String.raw `[^\s"'\\/:]+`;
|
|
27
|
+
const DIR = String.raw `${WORD}(?: ${WORD})*`;
|
|
28
|
+
const TILDE_WORD = String.raw `[^\s"'\\/]+`;
|
|
29
|
+
const TILDE_DIR = String.raw `${TILDE_WORD}(?: ${TILDE_WORD})*`;
|
|
30
|
+
const POSIX_WORD = String.raw `[^\s"'/]+`;
|
|
31
|
+
const POSIX_DIR = String.raw `${POSIX_WORD}(?: ${POSIX_WORD})*`;
|
|
32
|
+
const PATH_TOKEN = new RegExp([
|
|
33
|
+
String.raw `[A-Za-z]:[\\/](?:${DIR}[\\/])*[^\s"']*`,
|
|
34
|
+
String.raw `\\\\(?:${DIR}[\\/])*[^\s"']+`,
|
|
35
|
+
String.raw `file:\/\/[^\s"']*`,
|
|
36
|
+
String.raw `(?<![\w~])~(?:[\\/]${TILDE_DIR}(?=[\\/]))*[\\/]${TILDE_WORD}[\\/]?`,
|
|
37
|
+
String.raw `\/${POSIX_WORD}(?:\/${POSIX_DIR}(?=\/))*\/${POSIX_WORD}\/?`,
|
|
38
|
+
String.raw `(?:^|\s)\/[^\s"'/]+(?=$|\s)`,
|
|
39
|
+
].join('|'), 'gi');
|
|
26
40
|
function basenamePaths(s) {
|
|
27
|
-
return s.replace(
|
|
41
|
+
return s.replace(PATH_TOKEN, (m) => {
|
|
28
42
|
const single = SINGLE_SEG_POSIX.exec(m);
|
|
29
43
|
if (single)
|
|
30
44
|
return `${single[1]}/…`;
|