@logicseek/hello-cc 0.1.9 → 1.0.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/CHANGELOG.md +59 -0
- package/README.md +18 -3
- package/README.zh-CN.md +17 -3
- package/bin/hcc.mjs +3 -5656
- package/docs/README.md +8 -0
- package/docs/README.zh-CN.md +6 -0
- package/docs/architecture.md +29 -18
- package/docs/architecture.zh-CN.md +20 -10
- package/docs/commands.md +13 -4
- package/docs/commands.zh-CN.md +13 -4
- package/docs/current-state-review-2026-08.md +341 -0
- package/docs/defect-review-2026-07.md +382 -0
- package/docs/guide.md +39 -3
- package/docs/guide.zh-CN.md +34 -2
- package/docs/superpowers/plans/2026-08-03-runtime-safety-plan.md +294 -0
- package/docs/superpowers/plans/2026-08-03-v1-data-identity-plan.md +400 -0
- package/docs/superpowers/plans/2026-08-03-v1-release-verification-plan.md +284 -0
- package/docs/superpowers/plans/2026-08-03-web-api-hardening-plan.md +303 -0
- package/docs/superpowers/plans/2026-08-04-runtime-buffer-gc-two-phase.md +71 -0
- package/docs/superpowers/specs/2026-08-03-runtime-safety-design.md +117 -0
- package/docs/superpowers/specs/2026-08-03-v1-data-identity-design.md +120 -0
- package/docs/superpowers/specs/2026-08-03-v1-release-verification-design.md +117 -0
- package/docs/superpowers/specs/2026-08-03-web-api-hardening-design.md +104 -0
- package/lib/cli/app.mjs +642 -0
- package/lib/cli/commands/coordination.mjs +247 -0
- package/lib/cli/commands/doctor.mjs +81 -0
- package/lib/cli/commands/gc.mjs +852 -0
- package/lib/cli/commands/hook.mjs +205 -0
- package/lib/cli/commands/install.mjs +294 -0
- package/lib/cli/commands/lifecycle.mjs +101 -0
- package/lib/cli/commands/lock.mjs +197 -0
- package/lib/cli/commands/misc.mjs +61 -0
- package/lib/cli/commands/msg.mjs +152 -0
- package/lib/cli/commands/peer.mjs +233 -0
- package/lib/cli/commands/query.mjs +96 -0
- package/lib/cli/commands/run.mjs +463 -0
- package/lib/cli/commands/task.mjs +405 -0
- package/lib/cli/commands/team.mjs +131 -0
- package/lib/cli/commands/tmux.mjs +838 -0
- package/lib/cli/commands/up.mjs +135 -0
- package/lib/cli-args.mjs +16 -2
- package/lib/coordination-state.mjs +112 -51
- package/lib/core/coordination/automation.mjs +5 -8
- package/lib/core/coordination/clock-safety.mjs +323 -0
- package/lib/core/coordination/event-retention.mjs +17 -0
- package/lib/core/coordination/gc-plan.mjs +614 -0
- package/lib/core/coordination/lease-renewal.mjs +102 -0
- package/lib/core/coordination/lock-evidence.mjs +77 -0
- package/lib/core/coordination/optimistic-evidence.mjs +26 -0
- package/lib/core/coordination/tasks.mjs +156 -36
- package/lib/core/peers/evidence-runtime.mjs +312 -0
- package/lib/core/peers/evidence.mjs +103 -0
- package/lib/core/peers/hook-owner.mjs +32 -0
- package/lib/core/peers/liveness.mjs +44 -4
- package/lib/core/peers/peer-helpers.mjs +121 -0
- package/lib/core/peers/session.mjs +8 -2
- package/lib/core/peers/tmux-safety.mjs +290 -0
- package/lib/core/sessions/launch.mjs +13 -1
- package/lib/db/connection.mjs +104 -0
- package/lib/db/events.mjs +34 -0
- package/lib/db/migration-backup.mjs +225 -0
- package/lib/db/schema.mjs +102 -5
- package/lib/db/stores/peers.mjs +34 -0
- package/lib/integrations/peers/identity.mjs +62 -16
- package/lib/integrations/shims/script.mjs +111 -9
- package/lib/process/identity.mjs +192 -0
- package/lib/process/pty-lifecycle.mjs +149 -0
- package/lib/runtime/buffer-directory-lease.mjs +47 -0
- package/lib/runtime/buffer-evidence.mjs +336 -0
- package/lib/runtime/buffer-gc-protocol.mjs +167 -0
- package/lib/runtime/buffer-gc.mjs +270 -0
- package/lib/runtime/client.mjs +60 -7
- package/lib/runtime/fatal-shutdown.mjs +54 -0
- package/lib/runtime/project-path.mjs +139 -0
- package/lib/runtime/projects.mjs +320 -40
- package/lib/runtime/state.mjs +227 -27
- package/lib/shared/cleanup-error.mjs +54 -0
- package/lib/shared/clock-grace.mjs +106 -0
- package/lib/shared/errors.mjs +9 -0
- package/lib/shared/file-lock.mjs +370 -0
- package/lib/shared/json-file.mjs +6 -2
- package/lib/shared/redact.mjs +60 -0
- package/lib/shared/socket-lock-worker.mjs +201 -0
- package/lib/terminal/tmux.mjs +78 -4
- package/lib/tmux.mjs +4 -0
- package/lib/ui/help.mjs +53 -15
- package/lib/web/api-version.mjs +42 -0
- package/lib/web/auto-attach.mjs +185 -0
- package/lib/web/buffer-gc-runtime.mjs +175 -0
- package/lib/web/cookie-auth.mjs +119 -0
- package/lib/web/external-sessions.mjs +254 -0
- package/lib/web/http-routes.mjs +542 -0
- package/lib/web/http.mjs +135 -6
- package/lib/web/liveness-reaper.mjs +145 -0
- package/lib/web/peer-actions.mjs +132 -23
- package/lib/web/project-contexts.mjs +178 -0
- package/lib/web/pty-sessions.mjs +203 -0
- package/lib/web/runtime-main.mjs +714 -0
- package/lib/web/runtime.mjs +91 -36
- package/lib/web/session-serialize.mjs +0 -0
- package/lib/web/startup.mjs +333 -0
- package/lib/web/tls.mjs +365 -0
- package/lib/web/tmux-sessions.mjs +1361 -0
- package/lib/web/tmux-stream.mjs +166 -0
- package/lib/web/ui-template.mjs +152 -20
- package/package.json +5 -2
- package/scripts/check-free-identifiers.mjs +290 -0
- package/scripts/regression.mjs +5397 -263
package/lib/runtime/projects.mjs
CHANGED
|
@@ -1,76 +1,356 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { projectDbPath, projectRegistryPath } from './paths.mjs';
|
|
4
|
+
import { withFileLock } from '../shared/file-lock.mjs';
|
|
5
|
+
import { writeJsonSafe } from '../shared/json-file.mjs';
|
|
6
|
+
|
|
7
|
+
const ROOT_EXISTS = Symbol('projectRootExists');
|
|
8
|
+
const ROOT_WAS_CANONICAL = Symbol('projectRootWasCanonical');
|
|
9
|
+
const RAW_ROOT = Symbol('projectRawRoot');
|
|
10
|
+
const TIMESTAMP_VALID = Symbol('projectTimestampValid');
|
|
4
11
|
|
|
5
12
|
function registryTimestamp(nowFn = () => Math.floor(Date.now() / 1000)) {
|
|
6
13
|
return nowFn();
|
|
7
14
|
}
|
|
8
15
|
|
|
9
|
-
function
|
|
16
|
+
function sameFilesystemObject(left, right) {
|
|
17
|
+
return left?.dev === right?.dev && left?.ino === right?.ino;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function missingPath(error) {
|
|
21
|
+
return error?.code === 'ENOENT' || error?.code === 'ENOTDIR';
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function invalidProjectRoot(message, cause = null) {
|
|
25
|
+
const error = new Error(message, cause ? { cause } : undefined);
|
|
26
|
+
error.code = 'ERR_INVALID_PROJECT_ROOT';
|
|
27
|
+
return error;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function invalidProjectDb(message, cause = null) {
|
|
31
|
+
const error = new Error(message, cause ? { cause } : undefined);
|
|
32
|
+
error.code = 'ERR_INVALID_PROJECT_DB';
|
|
33
|
+
return error;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function pathWithin(parent, candidate) {
|
|
37
|
+
const relative = path.relative(parent, candidate);
|
|
38
|
+
return relative === '' || (!relative.startsWith(`..${path.sep}`) &&
|
|
39
|
+
relative !== '..' && !path.isAbsolute(relative));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function normalizedTimestamp(value) {
|
|
43
|
+
if (typeof value === 'number' && Number.isSafeInteger(value) && value >= 0) {
|
|
44
|
+
return { value, valid: true };
|
|
45
|
+
}
|
|
46
|
+
if (typeof value === 'string' && /^(0|[1-9]\d*)$/.test(value.trim())) {
|
|
47
|
+
const parsed = Number(value.trim());
|
|
48
|
+
if (Number.isSafeInteger(parsed)) return { value: parsed, valid: true };
|
|
49
|
+
}
|
|
50
|
+
return { value: 0, valid: false };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function canonicalProjectRoot(value, { requireExisting = false } = {}) {
|
|
54
|
+
const resolved = path.resolve(value);
|
|
55
|
+
let before;
|
|
56
|
+
try {
|
|
57
|
+
before = fs.statSync(resolved);
|
|
58
|
+
} catch (error) {
|
|
59
|
+
if (missingPath(error) && !requireExisting) {
|
|
60
|
+
return { root: resolved, exists: false };
|
|
61
|
+
}
|
|
62
|
+
if (requireExisting) {
|
|
63
|
+
throw invalidProjectRoot(`project root is not an existing directory: ${resolved}`, error);
|
|
64
|
+
}
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
if (!before.isDirectory()) {
|
|
68
|
+
if (requireExisting) throw invalidProjectRoot(`project root is not a directory: ${resolved}`);
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
try {
|
|
73
|
+
const canonical = fs.realpathSync.native(resolved);
|
|
74
|
+
const canonicalStat = fs.statSync(canonical);
|
|
75
|
+
const after = fs.statSync(resolved);
|
|
76
|
+
const confirmed = fs.realpathSync.native(resolved);
|
|
77
|
+
if (!canonicalStat.isDirectory() || !after.isDirectory() ||
|
|
78
|
+
canonical !== confirmed ||
|
|
79
|
+
!sameFilesystemObject(before, canonicalStat) ||
|
|
80
|
+
!sameFilesystemObject(before, after)) {
|
|
81
|
+
throw invalidProjectRoot(`project root changed during canonicalization: ${resolved}`);
|
|
82
|
+
}
|
|
83
|
+
return { root: canonical, exists: true };
|
|
84
|
+
} catch (error) {
|
|
85
|
+
if (requireExisting) {
|
|
86
|
+
if (error?.code === 'ERR_INVALID_PROJECT_ROOT') throw error;
|
|
87
|
+
throw invalidProjectRoot(`project root changed during canonicalization: ${resolved}`, error);
|
|
88
|
+
}
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function canonicalProjectDb(value, rawRoot, rootInfo, { strict = false } = {}) {
|
|
94
|
+
function reject(message, cause = null) {
|
|
95
|
+
if (strict) throw invalidProjectDb(message, cause);
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const rawDb = path.resolve(value);
|
|
100
|
+
const relativeToRawRoot = path.relative(rawRoot, rawDb);
|
|
101
|
+
const db = pathWithin(rawRoot, rawDb)
|
|
102
|
+
? path.resolve(rootInfo.root, relativeToRawRoot)
|
|
103
|
+
: rawDb;
|
|
104
|
+
|
|
105
|
+
if (!rootInfo.exists) {
|
|
106
|
+
return pathWithin(rootInfo.root, db)
|
|
107
|
+
? db
|
|
108
|
+
: reject(`project DB is outside the project root: ${rawDb}`);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
let ancestor = db;
|
|
112
|
+
let before;
|
|
113
|
+
while (true) {
|
|
114
|
+
try {
|
|
115
|
+
before = fs.statSync(ancestor);
|
|
116
|
+
break;
|
|
117
|
+
} catch (error) {
|
|
118
|
+
if (!missingPath(error)) {
|
|
119
|
+
return reject(`project DB path cannot be resolved safely: ${rawDb}`, error);
|
|
120
|
+
}
|
|
121
|
+
const parent = path.dirname(ancestor);
|
|
122
|
+
if (parent === ancestor) {
|
|
123
|
+
return reject(`project DB path has no existing parent: ${rawDb}`, error);
|
|
124
|
+
}
|
|
125
|
+
ancestor = parent;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const dbExists = ancestor === db;
|
|
130
|
+
if ((dbExists && before.isDirectory()) || (!dbExists && !before.isDirectory())) {
|
|
131
|
+
return reject(`project DB path is not a file under an existing directory: ${rawDb}`);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
try {
|
|
135
|
+
const canonicalAncestor = fs.realpathSync.native(ancestor);
|
|
136
|
+
const canonicalStat = fs.statSync(canonicalAncestor);
|
|
137
|
+
const after = fs.statSync(ancestor);
|
|
138
|
+
const confirmed = fs.realpathSync.native(ancestor);
|
|
139
|
+
if (canonicalAncestor !== confirmed ||
|
|
140
|
+
!sameFilesystemObject(before, canonicalStat) ||
|
|
141
|
+
!sameFilesystemObject(before, after) ||
|
|
142
|
+
(dbExists ? canonicalStat.isDirectory() : !canonicalStat.isDirectory())) {
|
|
143
|
+
return reject(`project DB path changed during canonicalization: ${rawDb}`);
|
|
144
|
+
}
|
|
145
|
+
const canonicalDb = dbExists
|
|
146
|
+
? canonicalAncestor
|
|
147
|
+
: path.resolve(canonicalAncestor, path.relative(ancestor, db));
|
|
148
|
+
if (!pathWithin(rootInfo.root, canonicalDb)) {
|
|
149
|
+
return reject(`project DB is outside the project root: ${rawDb}`);
|
|
150
|
+
}
|
|
151
|
+
return canonicalDb;
|
|
152
|
+
} catch (error) {
|
|
153
|
+
if (error?.code === 'ERR_INVALID_PROJECT_DB') throw error;
|
|
154
|
+
return reject(`project DB path changed during canonicalization: ${rawDb}`, error);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function normalizeProjectEntry(project, options = {}) {
|
|
10
159
|
if (!project?.root) return null;
|
|
11
|
-
const
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
160
|
+
const rawRoot = project[RAW_ROOT] || path.resolve(project.root);
|
|
161
|
+
const rootInfo = canonicalProjectRoot(project.root, options);
|
|
162
|
+
if (!rootInfo) return null;
|
|
163
|
+
const db = canonicalProjectDb(
|
|
164
|
+
project.db || projectDbPath(rawRoot),
|
|
165
|
+
rawRoot,
|
|
166
|
+
rootInfo,
|
|
167
|
+
{ strict: options.requireExisting === true }
|
|
168
|
+
);
|
|
169
|
+
if (!db) return null;
|
|
170
|
+
if (rootInfo.exists) {
|
|
171
|
+
const confirmedRoot = canonicalProjectRoot(project.root, options);
|
|
172
|
+
if (!confirmedRoot || !confirmedRoot.exists || confirmedRoot.root !== rootInfo.root) {
|
|
173
|
+
if (options.requireExisting) {
|
|
174
|
+
throw invalidProjectRoot(`project root changed during DB canonicalization: ${rawRoot}`);
|
|
175
|
+
}
|
|
176
|
+
return null;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
const timestamp = project[TIMESTAMP_VALID] === undefined
|
|
180
|
+
? normalizedTimestamp(project.last_seen_at)
|
|
181
|
+
: { value: project.last_seen_at, valid: project[TIMESTAMP_VALID] };
|
|
182
|
+
const row = {
|
|
183
|
+
root: rootInfo.root,
|
|
184
|
+
db,
|
|
185
|
+
name: String(project.name || path.basename(rootInfo.root) || rootInfo.root),
|
|
186
|
+
last_seen_at: timestamp.value
|
|
17
187
|
};
|
|
188
|
+
Object.defineProperty(row, ROOT_EXISTS, { value: rootInfo.exists });
|
|
189
|
+
Object.defineProperty(row, ROOT_WAS_CANONICAL, {
|
|
190
|
+
value: project[ROOT_WAS_CANONICAL] ?? rawRoot === rootInfo.root
|
|
191
|
+
});
|
|
192
|
+
Object.defineProperty(row, RAW_ROOT, { value: rawRoot });
|
|
193
|
+
Object.defineProperty(row, TIMESTAMP_VALID, { value: timestamp.valid });
|
|
194
|
+
return row;
|
|
18
195
|
}
|
|
19
196
|
|
|
20
|
-
function
|
|
21
|
-
if (
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
197
|
+
function preferLexicallySmaller(left, right) {
|
|
198
|
+
if (left === right) return 0;
|
|
199
|
+
return left < right ? 1 : -1;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function compareProjectPreference(left, right) {
|
|
203
|
+
if (left[TIMESTAMP_VALID] !== right[TIMESTAMP_VALID]) {
|
|
204
|
+
return left[TIMESTAMP_VALID] ? 1 : -1;
|
|
205
|
+
}
|
|
206
|
+
if (left.last_seen_at !== right.last_seen_at) {
|
|
207
|
+
return left.last_seen_at > right.last_seen_at ? 1 : -1;
|
|
26
208
|
}
|
|
209
|
+
if (left[ROOT_WAS_CANONICAL] !== right[ROOT_WAS_CANONICAL]) {
|
|
210
|
+
return left[ROOT_WAS_CANONICAL] ? 1 : -1;
|
|
211
|
+
}
|
|
212
|
+
return preferLexicallySmaller(left.db, right.db) ||
|
|
213
|
+
preferLexicallySmaller(left.name, right.name) ||
|
|
214
|
+
preferLexicallySmaller(left[RAW_ROOT], right[RAW_ROOT]);
|
|
27
215
|
}
|
|
28
216
|
|
|
29
|
-
|
|
217
|
+
function normalizedProjectRows(projects, requiredRoots = new Set()) {
|
|
218
|
+
const unique = new Map();
|
|
219
|
+
for (const project of projects) {
|
|
220
|
+
const resolvedRoot = project?.root ? path.resolve(project.root) : null;
|
|
221
|
+
const row = normalizeProjectEntry(project, {
|
|
222
|
+
requireExisting: resolvedRoot !== null && requiredRoots.has(resolvedRoot)
|
|
223
|
+
});
|
|
224
|
+
if (!row) continue;
|
|
225
|
+
const current = unique.get(row.root);
|
|
226
|
+
if (!current || compareProjectPreference(row, current) > 0) unique.set(row.root, row);
|
|
227
|
+
}
|
|
228
|
+
return [...unique.values()].sort((a, b) => (b.last_seen_at || 0) - (a.last_seen_at || 0));
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function persistProjectRegistry(file, projects, options = {}) {
|
|
232
|
+
const rows = normalizedProjectRows(projects, options.requiredRoots);
|
|
233
|
+
writeJsonSafe(file, { projects: rows }, { mode: 0o600 });
|
|
234
|
+
return rows;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function lexicalProjectRecord(ctx, nowFn) {
|
|
238
|
+
const root = path.resolve(ctx.root);
|
|
30
239
|
return {
|
|
31
|
-
root
|
|
32
|
-
db: ctx.dbPath,
|
|
33
|
-
name: path.basename(
|
|
240
|
+
root,
|
|
241
|
+
db: path.resolve(ctx.dbPath || projectDbPath(root)),
|
|
242
|
+
name: path.basename(root) || root,
|
|
34
243
|
last_seen_at: registryTimestamp(nowFn)
|
|
35
244
|
};
|
|
36
245
|
}
|
|
37
246
|
|
|
38
|
-
export function
|
|
39
|
-
|
|
247
|
+
export function projectRecord(ctx, nowFn) {
|
|
248
|
+
return normalizeProjectEntry(lexicalProjectRecord(ctx, nowFn), { requireExisting: true });
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function readProjectRegistryFrom(file) {
|
|
40
252
|
if (!fs.existsSync(file)) return [];
|
|
41
253
|
try {
|
|
42
254
|
const parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
43
255
|
const rows = Array.isArray(parsed?.projects) ? parsed.projects : [];
|
|
44
|
-
return rows
|
|
45
|
-
.filter((
|
|
46
|
-
.map(normalizeProjectEntry)
|
|
47
|
-
.filter(projectRootExists);
|
|
256
|
+
return normalizedProjectRows(rows.filter((p) => p && typeof p.root === 'string'))
|
|
257
|
+
.filter((project) => project?.[ROOT_EXISTS]);
|
|
48
258
|
} catch {
|
|
49
259
|
return [];
|
|
50
260
|
}
|
|
51
261
|
}
|
|
52
262
|
|
|
263
|
+
export function readProjectRegistry() {
|
|
264
|
+
return readProjectRegistryFrom(projectRegistryPath());
|
|
265
|
+
}
|
|
266
|
+
|
|
53
267
|
export function writeProjectRegistry(projects) {
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
for (const project of projects) {
|
|
58
|
-
const row = normalizeProjectEntry(project);
|
|
59
|
-
if (!row) continue;
|
|
60
|
-
unique.set(row.root, row);
|
|
61
|
-
}
|
|
62
|
-
const rows = [...unique.values()].sort((a, b) => (b.last_seen_at || 0) - (a.last_seen_at || 0));
|
|
63
|
-
fs.writeFileSync(file, JSON.stringify({ projects: rows }, null, 2), { mode: 0o600 });
|
|
64
|
-
fs.chmodSync(file, 0o600);
|
|
65
|
-
return rows;
|
|
268
|
+
// Atomic temp+rename write so concurrent readers never see a half-written
|
|
269
|
+
// registry (avoids the interleaved read-modify-write clobber in bg-06).
|
|
270
|
+
return persistProjectRegistry(projectRegistryPath(), projects);
|
|
66
271
|
}
|
|
67
272
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
273
|
+
const REGISTRY_WRITE_THROTTLE_SEC = 60;
|
|
274
|
+
const ACTIVITY_LOCK_THROTTLE_MS = 60_000;
|
|
275
|
+
const ACTIVITY_FAILURE_BACKOFF_MS = 250;
|
|
276
|
+
const ACTIVITY_CACHE_LIMIT = 1024;
|
|
277
|
+
const recentActivityLocks = new Map();
|
|
278
|
+
|
|
279
|
+
function updateProjectRegistry(candidate, lockOptions = {}) {
|
|
280
|
+
const registryPath = projectRegistryPath();
|
|
281
|
+
return withFileLock(registryPath, (lockedRegistryPath) => {
|
|
282
|
+
const existing = readProjectRegistryFrom(lockedRegistryPath);
|
|
283
|
+
// Web requests touch this path frequently. Suppress only an identical
|
|
284
|
+
// root+DB refresh; changing the DB binding must be persisted immediately.
|
|
285
|
+
const row = existing.find((project) => project.root === candidate.root);
|
|
286
|
+
// v1-05: a negative delta (future timestamp / clock step-back) must bypass
|
|
287
|
+
// the throttle so the registry is refreshed immediately — the original
|
|
288
|
+
// ageSeconds >= 0 guard already handled this correctly.
|
|
289
|
+
const ageSeconds = row ? registryTimestamp() - (row.last_seen_at || 0) : null;
|
|
290
|
+
if (row && row.db === candidate.db &&
|
|
291
|
+
ageSeconds >= 0 && ageSeconds < REGISTRY_WRITE_THROTTLE_SEC) {
|
|
292
|
+
return existing;
|
|
293
|
+
}
|
|
294
|
+
const rows = existing.filter((project) => project.root !== candidate.root);
|
|
295
|
+
rows.unshift(candidate);
|
|
296
|
+
return persistProjectRegistry(
|
|
297
|
+
lockedRegistryPath,
|
|
298
|
+
rows,
|
|
299
|
+
{ requiredRoots: new Set([candidate.root]) }
|
|
300
|
+
);
|
|
301
|
+
}, lockOptions);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
export function registerProject(ctx, lockOptions = {}) {
|
|
305
|
+
const candidate = projectRecord(ctx);
|
|
306
|
+
if (!candidate) throw new TypeError('project root is required');
|
|
307
|
+
// xx-01: the throttle check lives inside the lock (canonical-domain safe, see
|
|
308
|
+
// test/file-lock.test.mjs 'HOME is retargeted'). The request hot path is kept
|
|
309
|
+
// off the lock by rememberProject skipping projects it already resolved.
|
|
310
|
+
return updateProjectRegistry(candidate, lockOptions);
|
|
72
311
|
}
|
|
73
312
|
|
|
74
313
|
export function registerProjectActivity(ctx) {
|
|
75
|
-
try {
|
|
314
|
+
try {
|
|
315
|
+
const wallTimestamp = Date.now();
|
|
316
|
+
const candidate = projectRecord(ctx, () => Math.floor(wallTimestamp / 1000));
|
|
317
|
+
if (!candidate) return;
|
|
318
|
+
const key = candidate.root;
|
|
319
|
+
const now = Number(process.hrtime.bigint() / 1_000_000n);
|
|
320
|
+
const previous = recentActivityLocks.get(key);
|
|
321
|
+
const previousWindow = previous?.succeeded
|
|
322
|
+
? ACTIVITY_LOCK_THROTTLE_MS
|
|
323
|
+
: ACTIVITY_FAILURE_BACKOFF_MS;
|
|
324
|
+
if (previous?.db === candidate.db &&
|
|
325
|
+
wallTimestamp >= previous.wallTimestamp &&
|
|
326
|
+
now - previous.timestamp < previousWindow) return;
|
|
327
|
+
|
|
328
|
+
for (const [cachedKey, cached] of recentActivityLocks) {
|
|
329
|
+
const window = cached.succeeded
|
|
330
|
+
? ACTIVITY_LOCK_THROTTLE_MS
|
|
331
|
+
: ACTIVITY_FAILURE_BACKOFF_MS;
|
|
332
|
+
if (now - cached.timestamp >= window) recentActivityLocks.delete(cachedKey);
|
|
333
|
+
}
|
|
334
|
+
if (recentActivityLocks.size >= ACTIVITY_CACHE_LIMIT) {
|
|
335
|
+
recentActivityLocks.delete(recentActivityLocks.keys().next().value);
|
|
336
|
+
}
|
|
337
|
+
try {
|
|
338
|
+
updateProjectRegistry(candidate, { nonblocking: true });
|
|
339
|
+
recentActivityLocks.set(key, {
|
|
340
|
+
db: candidate.db,
|
|
341
|
+
wallTimestamp,
|
|
342
|
+
timestamp: now,
|
|
343
|
+
succeeded: true
|
|
344
|
+
});
|
|
345
|
+
} catch {
|
|
346
|
+
// Avoid hammering the lock endpoint on every HTTP request while ensuring one
|
|
347
|
+
// busy attempt cannot suppress the first registry write for a minute.
|
|
348
|
+
recentActivityLocks.set(key, {
|
|
349
|
+
db: candidate.db,
|
|
350
|
+
wallTimestamp,
|
|
351
|
+
timestamp: now,
|
|
352
|
+
succeeded: false
|
|
353
|
+
});
|
|
354
|
+
}
|
|
355
|
+
} catch {}
|
|
76
356
|
}
|