agent-procedures 0.3.0 → 0.3.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/README.md +15 -2
- package/dist/runtime.js +488 -115
- package/lib/engine.js +54 -45
- package/lib/groomer.js +108 -72
- package/lib/harnesses/claude.js +3 -1
- package/lib/init.js +30 -22
- package/lib/main.js +3 -1
- package/lib/recall.js +108 -0
- package/lib/sanitize.js +181 -0
- package/lib/store.js +178 -5
- package/package.json +1 -1
package/lib/sanitize.js
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Provider signatures below are ported from the Gitleaks default config:
|
|
3
|
+
* https://github.com/gitleaks/gitleaks/blob/master/config/gitleaks.toml
|
|
4
|
+
* Revision: b58d3f102cf3a2c84cb7f923d05c25c9b1aed84b
|
|
5
|
+
*
|
|
6
|
+
* MIT License
|
|
7
|
+
* Copyright (c) 2019 Zachary Rice
|
|
8
|
+
*
|
|
9
|
+
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
10
|
+
* of this software and associated documentation files (the "Software"), to deal
|
|
11
|
+
* in the Software without restriction, including without limitation the rights
|
|
12
|
+
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
13
|
+
* copies of the Software, and to permit persons to whom the Software is
|
|
14
|
+
* furnished to do so, subject to the following conditions:
|
|
15
|
+
*
|
|
16
|
+
* The above copyright notice and this permission notice shall be included in
|
|
17
|
+
* all copies or substantial portions of the Software.
|
|
18
|
+
*
|
|
19
|
+
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
20
|
+
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
21
|
+
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
22
|
+
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
23
|
+
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
24
|
+
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
25
|
+
* SOFTWARE.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
// Gitleaks uses Go's inline (?i) flag. These ports use JavaScript's i flag.
|
|
29
|
+
// Patterns are compiled once when the runtime loads.
|
|
30
|
+
const PROVIDER_RULES = [
|
|
31
|
+
["anthropic-api-key", /\b(sk-ant-api03-[a-zA-Z0-9_-]{93}AA)(?:[\x60'"\s;]|\\[nr]|$)/],
|
|
32
|
+
["aws-access-token", /\b((?:A3T[A-Z0-9]|AKIA|ASIA|ABIA|ACCA)[A-Z2-7]{16})\b/],
|
|
33
|
+
["cloudflare-api-key", /[\w.-]{0,50}?(?:cloudflare)(?:[ \t\w.-]{0,20})[\s'"]{0,3}(?:=|>|:{1,3}=|\|\||:|=>|\?=|,)[\x60'"\s=]{0,5}([a-z0-9_-]{40})(?:[\x60'"\s;]|\\[nr]|$)/i],
|
|
34
|
+
["datadog-access-token", /[\w.-]{0,50}?(?:datadog)(?:[ \t\w.-]{0,20})[\s'"]{0,3}(?:=|>|:{1,3}=|\|\||:|=>|\?=|,)[\x60'"\s=]{0,5}([a-z0-9]{40})(?:[\x60'"\s;]|\\[nr]|$)/i],
|
|
35
|
+
["digitalocean-access-token", /\b(doo_v1_[a-f0-9]{64})(?:[\x60'"\s;]|\\[nr]|$)/],
|
|
36
|
+
["discord-api-token", /[\w.-]{0,50}?(?:discord)(?:[ \t\w.-]{0,20})[\s'"]{0,3}(?:=|>|:{1,3}=|\|\||:|=>|\?=|,)[\x60'"\s=]{0,5}([a-f0-9]{64})(?:[\x60'"\s;]|\\[nr]|$)/i],
|
|
37
|
+
["dropbox-short-lived-api-token", /[\w.-]{0,50}?(?:dropbox)(?:[ \t\w.-]{0,20})[\s'"]{0,3}(?:=|>|:{1,3}=|\|\||:|=>|\?=|,)[\x60'"\s=]{0,5}(sl\.[a-z0-9\-=_]{135})(?:[\x60'"\s;]|\\[nr]|$)/i],
|
|
38
|
+
["gcp-api-key", /\b(AIza[\w-]{35})(?:[\x60'"\s;]|\\[nr]|$)/],
|
|
39
|
+
["github-pat", /ghp_[0-9a-zA-Z]{36}/],
|
|
40
|
+
["gitlab-pat", /glpat-[\w-]{20}/],
|
|
41
|
+
["heroku-api-key-v2", /\b((HRKU-AA[0-9a-zA-Z_-]{58}))(?:[\x60'"\s;]|\\[nr]|$)/],
|
|
42
|
+
["mailgun-private-api-token", /[\w.-]{0,50}?(?:mailgun)(?:[ \t\w.-]{0,20})[\s'"]{0,3}(?:=|>|:{1,3}=|\|\||:|=>|\?=|,)[\x60'"\s=]{0,5}(key-[a-f0-9]{32})(?:[\x60'"\s;]|\\[nr]|$)/i],
|
|
43
|
+
["npm-access-token", /\b(npm_[a-z0-9]{36})(?:[\x60'"\s;]|\\[nr]|$)/i],
|
|
44
|
+
["openai-api-key", /\b(sk-(?:proj|svcacct|admin)-(?:[A-Za-z0-9_-]{74}|[A-Za-z0-9_-]{58})T3BlbkFJ(?:[A-Za-z0-9_-]{74}|[A-Za-z0-9_-]{58})\b|sk-[a-zA-Z0-9]{20}T3BlbkFJ[a-zA-Z0-9]{20})(?:[\x60'"\s;]|\\[nr]|$)/],
|
|
45
|
+
["pypi-upload-token", /pypi-AgEIcHlwaS5vcmc[\w-]{50,1000}/],
|
|
46
|
+
["sendgrid-api-token", /\b(SG\.[a-z0-9=_\-.]{66})(?:[\x60'"\s;]|\\[nr]|$)/i],
|
|
47
|
+
["shopify-access-token", /shpat_[a-fA-F0-9]{32}/],
|
|
48
|
+
["slack-bot-token", /xoxb-[0-9]{10,13}-[0-9]{10,13}[a-zA-Z0-9-]*/],
|
|
49
|
+
["stripe-access-token", /\b((?:sk|rk)_(?:test|live|prod)_[a-zA-Z0-9]{10,99})(?:[\x60'"\s;]|\\[nr]|$)/],
|
|
50
|
+
["twilio-api-key", /SK[0-9a-fA-F]{32}/],
|
|
51
|
+
["private-key", /-----BEGIN[ A-Z0-9_-]{0,100}PRIVATE KEY(?: BLOCK)?-----[\s\S-]{64,}?KEY(?: BLOCK)?-----/i],
|
|
52
|
+
["jwt", /\b(ey[a-zA-Z0-9]{17,}\.ey[a-zA-Z0-9/\\_-]{17,}\.(?:[a-zA-Z0-9/\\_-]{10,}={0,2})?)(?:[\x60'"\s;]|\\[nr]|$)/],
|
|
53
|
+
];
|
|
54
|
+
|
|
55
|
+
const SENSITIVE_NAME =
|
|
56
|
+
/(?:^|_)(?:API_?)?(?:KEY|TOKEN|SECRET|PASS|PASSWORD|AUTH|CREDENTIALS?|DATABASE_URL|DB_URL|CONNECTION_STRING|DSN)(?:_|$)/i;
|
|
57
|
+
const ASSIGNMENT =
|
|
58
|
+
/\b(export\s+)?([A-Za-z_][A-Za-z0-9_]*)=(?:"([^"]*)"|'([^']*)'|([^\s;&|"']+))(\s*)/g;
|
|
59
|
+
const CREDENTIAL_URL = /^[a-z][a-z0-9+.-]*:\/\/[^:\s/@]+:[^@\s/]+@/i;
|
|
60
|
+
|
|
61
|
+
function result(text, requiresEnv = [], unsafe = false) {
|
|
62
|
+
return { text, requiresEnv: [...new Set(requiresEnv)], unsafe };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function replaceDetectedSecrets(input) {
|
|
66
|
+
let text = input;
|
|
67
|
+
let unsafe = false;
|
|
68
|
+
|
|
69
|
+
for (const [id, regex] of PROVIDER_RULES) {
|
|
70
|
+
let match;
|
|
71
|
+
while ((match = regex.exec(text))) {
|
|
72
|
+
const secret = match[1] || match[0];
|
|
73
|
+
const offset = match[0].indexOf(secret);
|
|
74
|
+
const start = match.index + offset;
|
|
75
|
+
text = text.slice(0, start) + `[secret:${id}]` + text.slice(start + secret.length);
|
|
76
|
+
unsafe = true;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return { text, unsafe };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function sanitizeText(input = "") {
|
|
84
|
+
const detected = replaceDetectedSecrets(String(input));
|
|
85
|
+
return result(detected.text, [], detected.unsafe);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function sanitizeCommand(input = "") {
|
|
89
|
+
let text = String(input);
|
|
90
|
+
const requiresEnv = [];
|
|
91
|
+
|
|
92
|
+
let removedAssignment = false;
|
|
93
|
+
text = text.replace(ASSIGNMENT, (match, exported, name, double, single, bare, trailing, offset, source) => {
|
|
94
|
+
const value = double ?? single ?? bare ?? "";
|
|
95
|
+
const valueIsSecret =
|
|
96
|
+
SENSITIVE_NAME.test(name) ||
|
|
97
|
+
CREDENTIAL_URL.test(value) ||
|
|
98
|
+
PROVIDER_RULES.some(([, regex]) => regex.test(value));
|
|
99
|
+
if (!valueIsSecret) return match;
|
|
100
|
+
|
|
101
|
+
requiresEnv.push(name);
|
|
102
|
+
const before = source.slice(0, offset);
|
|
103
|
+
const isCommandAssignment = exported || !before.trim() || /(?:&&|\|\||;)\s*$/.test(before);
|
|
104
|
+
if (isCommandAssignment) {
|
|
105
|
+
removedAssignment = true;
|
|
106
|
+
return "";
|
|
107
|
+
}
|
|
108
|
+
// Keep the caller's quoting shape: a bare value may already sit inside
|
|
109
|
+
// quotes, so adding our own would unbalance them.
|
|
110
|
+
const placeholder = bare !== undefined ? `\${${name}}` : `"\${${name}}"`;
|
|
111
|
+
return `${name}=${placeholder}${trailing}`;
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
text = text.replace(
|
|
115
|
+
/'(\bAuthorization\s*:\s*Bearer\s+)([^']+)'/gi,
|
|
116
|
+
(_match, prefix) => {
|
|
117
|
+
requiresEnv.push("AUTH_TOKEN");
|
|
118
|
+
return `"${prefix}\${AUTH_TOKEN}"`;
|
|
119
|
+
},
|
|
120
|
+
);
|
|
121
|
+
text = text.replace(
|
|
122
|
+
/'(\bAuthorization\s*:\s*Basic\s+)([^']+)'/gi,
|
|
123
|
+
(_match, prefix) => {
|
|
124
|
+
requiresEnv.push("BASIC_AUTH");
|
|
125
|
+
return `"${prefix}\${BASIC_AUTH}"`;
|
|
126
|
+
},
|
|
127
|
+
);
|
|
128
|
+
text = text.replace(
|
|
129
|
+
/'([a-z][a-z0-9+.-]*:\/\/[^:\s/@]+:)([^@']+)(@[^']+)'/gi,
|
|
130
|
+
(_match, prefix, _password, suffix) => {
|
|
131
|
+
requiresEnv.push("PASSWORD");
|
|
132
|
+
return `"${prefix}\${PASSWORD}${suffix}"`;
|
|
133
|
+
},
|
|
134
|
+
);
|
|
135
|
+
|
|
136
|
+
text = text.replace(
|
|
137
|
+
/(\bAuthorization\s*:\s*Bearer\s+)([^"'\s]+)/gi,
|
|
138
|
+
(_match, prefix) => {
|
|
139
|
+
requiresEnv.push("AUTH_TOKEN");
|
|
140
|
+
return `${prefix}\${AUTH_TOKEN}`;
|
|
141
|
+
},
|
|
142
|
+
);
|
|
143
|
+
text = text.replace(
|
|
144
|
+
/(\bAuthorization\s*:\s*Basic\s+)([^"'\s]+)/gi,
|
|
145
|
+
(_match, prefix) => {
|
|
146
|
+
requiresEnv.push("BASIC_AUTH");
|
|
147
|
+
return `${prefix}\${BASIC_AUTH}`;
|
|
148
|
+
},
|
|
149
|
+
);
|
|
150
|
+
text = text.replace(
|
|
151
|
+
/([a-z][a-z0-9+.-]*:\/\/[^:\s/@]+:)([^@\s/]+)(@)/gi,
|
|
152
|
+
(_match, prefix, _password, suffix) => {
|
|
153
|
+
requiresEnv.push("PASSWORD");
|
|
154
|
+
return `${prefix}\${PASSWORD}${suffix}`;
|
|
155
|
+
},
|
|
156
|
+
);
|
|
157
|
+
text = text.replace(
|
|
158
|
+
/(--(?:password|passwd|token|secret|api-key|key)(?:=|\s+))(["']?)([^"'\s;&|]+)\2/gi,
|
|
159
|
+
(_match, prefix, quote) => {
|
|
160
|
+
const name = prefix
|
|
161
|
+
.match(/^--([a-z-]+)/i)[1]
|
|
162
|
+
.replace(/-/g, "_")
|
|
163
|
+
.toUpperCase();
|
|
164
|
+
requiresEnv.push(name);
|
|
165
|
+
const safeQuote = quote === "'" ? '"' : quote;
|
|
166
|
+
return `${prefix}${safeQuote}\${${name}}${safeQuote}`;
|
|
167
|
+
},
|
|
168
|
+
);
|
|
169
|
+
|
|
170
|
+
if (removedAssignment) {
|
|
171
|
+
// Removing an assignment can leave dangling or doubled separators.
|
|
172
|
+
text = text
|
|
173
|
+
.replace(/^\s*(?:&&|\|\||;)\s*/, "")
|
|
174
|
+
.replace(/\s*(?:&&|\|\||;)\s*$/, "")
|
|
175
|
+
.replace(/(?:&&|\|\||;)\s*(?=(?:&&|\|\||;))/g, "")
|
|
176
|
+
.trim();
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const detected = replaceDetectedSecrets(text);
|
|
180
|
+
return result(detected.text, requiresEnv, detected.unsafe);
|
|
181
|
+
}
|
package/lib/store.js
CHANGED
|
@@ -2,18 +2,73 @@ import fs from "node:fs";
|
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
|
|
4
4
|
// Disk layout under .reflex/. Harness-free.
|
|
5
|
+
// Hot path appends pending.jsonl. Stable memory is procedures.jsonl.
|
|
6
|
+
// Groomer renames pending → processing, then merges into procedures.
|
|
7
|
+
|
|
8
|
+
export const GROOM_LOCK_STALE_MS = 15 * 60 * 1000;
|
|
5
9
|
|
|
6
10
|
export function paths(reflexDir) {
|
|
7
11
|
return {
|
|
8
12
|
reflexDir,
|
|
9
13
|
tracesDir: path.join(reflexDir, "traces"),
|
|
10
14
|
proceduresFile: path.join(reflexDir, "procedures.jsonl"),
|
|
15
|
+
pendingFile: path.join(reflexDir, "pending.jsonl"),
|
|
16
|
+
processingFile: path.join(reflexDir, "processing.jsonl"),
|
|
17
|
+
groomerLockFile: path.join(reflexDir, "groomer.lock"),
|
|
11
18
|
};
|
|
12
19
|
}
|
|
13
20
|
|
|
14
21
|
function readJsonl(file) {
|
|
15
22
|
if (!fs.existsSync(file)) return [];
|
|
16
|
-
return fs
|
|
23
|
+
return fs
|
|
24
|
+
.readFileSync(file, "utf8")
|
|
25
|
+
.split("\n")
|
|
26
|
+
.filter(Boolean)
|
|
27
|
+
.map((l) => {
|
|
28
|
+
try {
|
|
29
|
+
return JSON.parse(l);
|
|
30
|
+
} catch {
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
})
|
|
34
|
+
.filter(Boolean);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function sleepSync(ms) {
|
|
38
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function renameWithRetry(from, to, attempts = 8) {
|
|
42
|
+
for (let i = 0; i < attempts; i++) {
|
|
43
|
+
try {
|
|
44
|
+
fs.renameSync(from, to);
|
|
45
|
+
return;
|
|
46
|
+
} catch (e) {
|
|
47
|
+
const retryable = e.code === "EBUSY" || e.code === "EPERM" || e.code === "EACCES";
|
|
48
|
+
if (!retryable || i === attempts - 1) throw e;
|
|
49
|
+
sleepSync(15 * (i + 1));
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function unlinkWithRetry(file, attempts = 8) {
|
|
55
|
+
for (let i = 0; i < attempts; i++) {
|
|
56
|
+
try {
|
|
57
|
+
if (fs.existsSync(file)) fs.unlinkSync(file);
|
|
58
|
+
return;
|
|
59
|
+
} catch (e) {
|
|
60
|
+
const retryable = e.code === "EBUSY" || e.code === "EPERM" || e.code === "EACCES";
|
|
61
|
+
if (!retryable || i === attempts - 1) throw e;
|
|
62
|
+
sleepSync(15 * (i + 1));
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function writeJsonlAtomic(file, nodes) {
|
|
68
|
+
const content = nodes.length ? nodes.map((n) => JSON.stringify(n)).join("\n") + "\n" : "";
|
|
69
|
+
const tmp = file + ".tmp";
|
|
70
|
+
fs.writeFileSync(tmp, content, "utf8");
|
|
71
|
+
renameWithRetry(tmp, file);
|
|
17
72
|
}
|
|
18
73
|
|
|
19
74
|
export function traceFile(reflexDir, sessionId, promptId) {
|
|
@@ -39,15 +94,133 @@ export function readTrace(reflexDir, sessionId, promptId) {
|
|
|
39
94
|
export function deleteTrace(reflexDir, sessionId, promptId) {
|
|
40
95
|
try {
|
|
41
96
|
fs.unlinkSync(traceFile(reflexDir, sessionId, promptId));
|
|
42
|
-
} catch
|
|
97
|
+
} catch {
|
|
98
|
+
/* missing is fine */
|
|
99
|
+
}
|
|
43
100
|
}
|
|
44
101
|
|
|
45
102
|
export function readProcedures(reflexDir) {
|
|
46
103
|
return readJsonl(paths(reflexDir).proceduresFile);
|
|
47
104
|
}
|
|
48
105
|
|
|
49
|
-
export function
|
|
50
|
-
|
|
51
|
-
|
|
106
|
+
export function readPending(reflexDir) {
|
|
107
|
+
return readJsonl(paths(reflexDir).pendingFile);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function readProcessing(reflexDir) {
|
|
111
|
+
return readJsonl(paths(reflexDir).processingFile);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Stable + ingest buffer + any in-flight batch. Used by recall. */
|
|
115
|
+
export function readAllProcedures(reflexDir) {
|
|
116
|
+
return [
|
|
117
|
+
...readProcedures(reflexDir),
|
|
118
|
+
...readPending(reflexDir),
|
|
119
|
+
...readProcessing(reflexDir),
|
|
120
|
+
];
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function countIngest(reflexDir) {
|
|
124
|
+
return readPending(reflexDir).length + readProcessing(reflexDir).length;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Hot path only. Never writes procedures.jsonl. */
|
|
128
|
+
export function appendPending(reflexDir, node) {
|
|
129
|
+
const file = paths(reflexDir).pendingFile;
|
|
130
|
+
fs.mkdirSync(reflexDir, { recursive: true });
|
|
52
131
|
fs.appendFileSync(file, JSON.stringify(node) + "\n", "utf8");
|
|
53
132
|
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Move legacy pending_review rows out of procedures.jsonl into pending.jsonl.
|
|
136
|
+
* Safe to call repeatedly.
|
|
137
|
+
*/
|
|
138
|
+
export function migrateLegacyPending(reflexDir) {
|
|
139
|
+
const p = paths(reflexDir);
|
|
140
|
+
if (!fs.existsSync(p.proceduresFile)) return { moved: 0 };
|
|
141
|
+
const nodes = readJsonl(p.proceduresFile);
|
|
142
|
+
const pending = nodes.filter((n) => n.status === "pending_review");
|
|
143
|
+
if (pending.length === 0) return { moved: 0 };
|
|
144
|
+
const stable = nodes.filter((n) => n.status !== "pending_review");
|
|
145
|
+
for (const node of pending) appendPending(reflexDir, node);
|
|
146
|
+
writeJsonlAtomic(p.proceduresFile, stable);
|
|
147
|
+
return { moved: pending.length };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function pidAlive(pid) {
|
|
151
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
152
|
+
try {
|
|
153
|
+
process.kill(pid, 0);
|
|
154
|
+
return true;
|
|
155
|
+
} catch {
|
|
156
|
+
return false;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function readLock(lockFile) {
|
|
161
|
+
try {
|
|
162
|
+
return JSON.parse(fs.readFileSync(lockFile, "utf8"));
|
|
163
|
+
} catch {
|
|
164
|
+
return null;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export function isGroomerLocked(reflexDir, now = Date.now()) {
|
|
169
|
+
const lockFile = paths(reflexDir).groomerLockFile;
|
|
170
|
+
if (!fs.existsSync(lockFile)) return false;
|
|
171
|
+
const lock = readLock(lockFile);
|
|
172
|
+
if (!lock) return false;
|
|
173
|
+
const age = typeof lock.startedAt === "number" ? now - lock.startedAt : Infinity;
|
|
174
|
+
if (age >= GROOM_LOCK_STALE_MS) return false;
|
|
175
|
+
return pidAlive(lock.pid);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** Exclusive create. Returns true if this process owns the lock. */
|
|
179
|
+
export function tryAcquireGroomerLock(reflexDir, now = Date.now()) {
|
|
180
|
+
const lockFile = paths(reflexDir).groomerLockFile;
|
|
181
|
+
fs.mkdirSync(reflexDir, { recursive: true });
|
|
182
|
+
|
|
183
|
+
if (fs.existsSync(lockFile)) {
|
|
184
|
+
if (isGroomerLocked(reflexDir, now)) return false;
|
|
185
|
+
try {
|
|
186
|
+
fs.unlinkSync(lockFile);
|
|
187
|
+
} catch {
|
|
188
|
+
return false;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
try {
|
|
193
|
+
const fd = fs.openSync(lockFile, "wx");
|
|
194
|
+
fs.writeFileSync(fd, JSON.stringify({ pid: process.pid, startedAt: now }) + "\n", "utf8");
|
|
195
|
+
fs.closeSync(fd);
|
|
196
|
+
return true;
|
|
197
|
+
} catch (e) {
|
|
198
|
+
if (e.code === "EEXIST") return false;
|
|
199
|
+
throw e;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export function releaseGroomerLock(reflexDir) {
|
|
204
|
+
const lockFile = paths(reflexDir).groomerLockFile;
|
|
205
|
+
try {
|
|
206
|
+
const lock = readLock(lockFile);
|
|
207
|
+
if (lock && lock.pid !== process.pid) return;
|
|
208
|
+
fs.unlinkSync(lockFile);
|
|
209
|
+
} catch {
|
|
210
|
+
/* missing is fine */
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** Claim current pending batch for the groomer. Returns processing path or null. */
|
|
215
|
+
export function claimPendingBatch(reflexDir) {
|
|
216
|
+
const { pendingFile, processingFile } = paths(reflexDir);
|
|
217
|
+
if (fs.existsSync(processingFile)) return processingFile;
|
|
218
|
+
if (!fs.existsSync(pendingFile)) return null;
|
|
219
|
+
const stat = fs.statSync(pendingFile);
|
|
220
|
+
if (stat.size === 0) {
|
|
221
|
+
unlinkWithRetry(pendingFile);
|
|
222
|
+
return null;
|
|
223
|
+
}
|
|
224
|
+
renameWithRetry(pendingFile, processingFile);
|
|
225
|
+
return processingFile;
|
|
226
|
+
}
|