@teammy/cli 0.0.4 → 0.0.6
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 +27 -12
- package/bin/teammy.js +8 -1
- package/dist/main.js +2348 -1352
- package/package.json +1 -1
package/dist/main.js
CHANGED
|
@@ -1,14 +1,15 @@
|
|
|
1
|
-
// apps/client/
|
|
2
|
-
import {
|
|
1
|
+
// apps/client/main.ts
|
|
2
|
+
import { parseArgs as parseArgs4 } from "node:util";
|
|
3
|
+
import { resolve as resolve8 } from "node:path";
|
|
3
4
|
|
|
4
|
-
// packages/protocol/
|
|
5
|
+
// packages/protocol/project.ts
|
|
5
6
|
import { z as z3 } from "zod";
|
|
6
7
|
|
|
7
8
|
// packages/protocol/identity.ts
|
|
8
9
|
import { z } from "zod";
|
|
9
10
|
var NameSchema = z.string().regex(/^[a-z][a-z0-9_-]{0,63}$/);
|
|
10
11
|
var DeviceIdSchema = z.uuid();
|
|
11
|
-
var BindRequestSchema = z.object({ deviceId: DeviceIdSchema }).strict();
|
|
12
|
+
var BindRequestSchema = z.object({ deviceId: DeviceIdSchema, projectId: NameSchema.optional() }).strict();
|
|
12
13
|
var BindingSchema = z.object({
|
|
13
14
|
workspaceId: NameSchema,
|
|
14
15
|
memberId: NameSchema,
|
|
@@ -82,187 +83,198 @@ var FileTreeSchema = z2.object({
|
|
|
82
83
|
});
|
|
83
84
|
var FileSelectionSchema = z2.object({ revision: RevisionSchema, file: FileEntrySchema });
|
|
84
85
|
|
|
85
|
-
// packages/protocol/devices.ts
|
|
86
|
-
var CredentialSchema = z3.string().regex(/^tmy_[a-f0-9]{64}$/);
|
|
87
|
-
var EnrollmentSchema = z3.object({
|
|
88
|
-
operationId: z3.uuid(),
|
|
89
|
-
memberId: NameSchema,
|
|
90
|
-
deviceId: DeviceIdSchema,
|
|
91
|
-
credential: CredentialSchema
|
|
92
|
-
}).strict();
|
|
93
|
-
var EffectivePolicySchema = z3.object({
|
|
94
|
-
checkIntervalSeconds: z3.number().min(1).max(86400),
|
|
95
|
-
fileSettleSeconds: z3.number().min(0).max(86400)
|
|
96
|
-
}).strict();
|
|
97
|
-
var SyncSummarySchema = z3.object({
|
|
98
|
-
checkedAt: z3.string(),
|
|
99
|
-
published: z3.boolean(),
|
|
100
|
-
appliedFiles: z3.number().int().nonnegative(),
|
|
101
|
-
pendingFiles: z3.number().int().nonnegative(),
|
|
102
|
-
sessionSources: z3.number().int().nonnegative(),
|
|
103
|
-
errors: z3.array(z3.string().max(1e3)).max(20)
|
|
104
|
-
}).strict();
|
|
105
|
-
var DeviceStatusSchema = z3.object({
|
|
106
|
-
clientVersion: z3.string().max(64),
|
|
107
|
-
receivedRevision: RevisionSchema.nullable(),
|
|
108
|
-
appliedRevision: RevisionSchema.nullable(),
|
|
109
|
-
effectivePolicy: EffectivePolicySchema,
|
|
110
|
-
overrides: z3.array(z3.enum(["checkIntervalSeconds", "fileSettleSeconds"])).max(2),
|
|
111
|
-
configError: z3.string().max(2e3).nullable(),
|
|
112
|
-
reportError: z3.string().max(2e3).nullable(),
|
|
113
|
-
lastSync: SyncSummarySchema.nullable()
|
|
114
|
-
}).strict();
|
|
115
|
-
var WebDevicesSchema = z3.object({
|
|
116
|
-
devices: z3.array(
|
|
117
|
-
z3.object({
|
|
118
|
-
memberId: NameSchema,
|
|
119
|
-
bound: z3.boolean(),
|
|
120
|
-
lastSeenAt: z3.string().nullable(),
|
|
121
|
-
status: DeviceStatusSchema.nullable()
|
|
122
|
-
})
|
|
123
|
-
)
|
|
124
|
-
});
|
|
125
|
-
|
|
126
86
|
// packages/protocol/project.ts
|
|
127
|
-
|
|
128
|
-
var ServerUrlSchema = z4.string().url().max(2048).refine((value) => {
|
|
87
|
+
var ServerUrlSchema = z3.string().url().max(2048).refine((value) => {
|
|
129
88
|
const url = new URL(value);
|
|
130
89
|
return ["http:", "https:"].includes(url.protocol) && !url.username && !url.password && !url.search && !url.hash;
|
|
131
90
|
}, "Use an HTTP(S) server URL without credentials, query or fragment.");
|
|
132
|
-
var SyncPolicySchema =
|
|
133
|
-
checkIntervalSeconds:
|
|
134
|
-
fileSettleSeconds:
|
|
91
|
+
var SyncPolicySchema = z3.object({
|
|
92
|
+
checkIntervalSeconds: z3.number().int().min(1).max(86400),
|
|
93
|
+
fileSettleSeconds: z3.number().int().min(0).max(86400)
|
|
135
94
|
}).strict();
|
|
136
95
|
var DEFAULT_POLICY = { checkIntervalSeconds: 60, fileSettleSeconds: 60 };
|
|
137
|
-
var ProjectConfigSchema =
|
|
138
|
-
schemaVersion:
|
|
96
|
+
var ProjectConfigSchema = z3.object({
|
|
97
|
+
schemaVersion: z3.literal(1),
|
|
139
98
|
projectId: NameSchema,
|
|
140
|
-
projectName:
|
|
141
|
-
description:
|
|
99
|
+
projectName: z3.string().trim().min(1).max(120),
|
|
100
|
+
description: z3.string().max(2e3),
|
|
142
101
|
publicServerUrl: ServerUrlSchema,
|
|
143
102
|
configRevision: RevisionSchema,
|
|
144
|
-
scopeProfile:
|
|
103
|
+
scopeProfile: z3.literal("workspace-and-sessions-v1"),
|
|
145
104
|
sync: SyncPolicySchema
|
|
146
105
|
}).strict();
|
|
147
|
-
var SetupSchema =
|
|
148
|
-
operationId:
|
|
149
|
-
projectName:
|
|
150
|
-
description:
|
|
106
|
+
var SetupSchema = z3.object({
|
|
107
|
+
operationId: z3.uuid(),
|
|
108
|
+
projectName: z3.string().trim().min(1).max(120),
|
|
109
|
+
description: z3.string().max(2e3).default(""),
|
|
151
110
|
publicServerUrl: ServerUrlSchema,
|
|
152
111
|
adminId: NameSchema,
|
|
153
|
-
members:
|
|
154
|
-
existingWorkspaceId: NameSchema.optional(),
|
|
112
|
+
members: z3.array(NameSchema).max(100).default([]),
|
|
155
113
|
sync: SyncPolicySchema.default(DEFAULT_POLICY)
|
|
156
114
|
}).strict();
|
|
157
|
-
var SetupStatusSchema =
|
|
158
|
-
initialized:
|
|
159
|
-
existingWorkspaces: z4.array(NameSchema)
|
|
115
|
+
var SetupStatusSchema = z3.object({
|
|
116
|
+
initialized: z3.boolean()
|
|
160
117
|
});
|
|
161
118
|
var ConfigUpdateSchema = ProjectConfigSchema.pick({
|
|
162
119
|
projectName: true,
|
|
163
|
-
description: true
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
}).extend({ expectedRevision: RevisionSchema }).strict();
|
|
167
|
-
var MemberSchema = z4.object({
|
|
120
|
+
description: true
|
|
121
|
+
}).extend({ expectedRevision: RevisionSchema.optional() }).strict();
|
|
122
|
+
var MemberSchema = z3.object({
|
|
168
123
|
id: NameSchema,
|
|
169
|
-
displayName:
|
|
170
|
-
|
|
171
|
-
|
|
124
|
+
displayName: z3.string().max(120),
|
|
125
|
+
enabled: z3.boolean(),
|
|
126
|
+
accountEnabled: z3.boolean(),
|
|
172
127
|
revision: RevisionSchema,
|
|
173
|
-
bound:
|
|
128
|
+
bound: z3.boolean(),
|
|
174
129
|
fileRevision: RevisionSchema,
|
|
175
|
-
lastPublishedAt:
|
|
130
|
+
lastPublishedAt: z3.string().nullable()
|
|
176
131
|
});
|
|
177
|
-
var
|
|
178
|
-
workspaceId: NameSchema,
|
|
132
|
+
var WebIdentitySchema = z3.object({
|
|
179
133
|
memberId: NameSchema,
|
|
180
|
-
displayName:
|
|
181
|
-
|
|
134
|
+
displayName: z3.string(),
|
|
135
|
+
siteRole: z3.enum(["admin", "member"])
|
|
136
|
+
});
|
|
137
|
+
var WebSessionSchema = WebIdentitySchema.extend({
|
|
138
|
+
workspaceId: NameSchema
|
|
139
|
+
});
|
|
140
|
+
var MemberCreateSchema = z3.object({
|
|
141
|
+
id: NameSchema
|
|
142
|
+
}).strict();
|
|
143
|
+
var MemberUpdateSchema = z3.object({
|
|
144
|
+
expectedRevision: RevisionSchema,
|
|
145
|
+
displayName: z3.string().trim().max(120).optional(),
|
|
146
|
+
enabled: z3.boolean().optional()
|
|
147
|
+
}).strict();
|
|
148
|
+
var ProjectCreateSchema = z3.object({
|
|
149
|
+
operationId: z3.uuid(),
|
|
150
|
+
projectName: z3.string().trim().min(1).max(120),
|
|
151
|
+
description: z3.string().max(2e3).default("")
|
|
152
|
+
}).strict();
|
|
153
|
+
var ProjectSummarySchema = z3.object({
|
|
154
|
+
projectId: NameSchema,
|
|
155
|
+
projectName: z3.string(),
|
|
156
|
+
description: z3.string()
|
|
157
|
+
});
|
|
158
|
+
var ProjectsSchema = z3.object({ projects: z3.array(ProjectSummarySchema) });
|
|
159
|
+
var SiteConfigSchema = z3.object({
|
|
160
|
+
sync: SyncPolicySchema,
|
|
161
|
+
publicServerUrl: ServerUrlSchema,
|
|
162
|
+
revision: RevisionSchema
|
|
163
|
+
});
|
|
164
|
+
var SiteConfigUpdateSchema = z3.object({
|
|
165
|
+
publicServerUrl: ServerUrlSchema.optional(),
|
|
166
|
+
sync: SyncPolicySchema.optional(),
|
|
167
|
+
expectedRevision: RevisionSchema
|
|
168
|
+
}).strict().refine(
|
|
169
|
+
(v) => v.publicServerUrl !== void 0 || v.sync !== void 0,
|
|
170
|
+
"Provide a setting to update."
|
|
171
|
+
);
|
|
172
|
+
var UserSchema = z3.object({
|
|
173
|
+
id: NameSchema,
|
|
174
|
+
displayName: z3.string(),
|
|
175
|
+
siteRole: z3.enum(["admin", "member"]),
|
|
176
|
+
enabled: z3.boolean(),
|
|
177
|
+
revision: RevisionSchema
|
|
182
178
|
});
|
|
183
|
-
var
|
|
184
|
-
|
|
179
|
+
var UserCreateSchema = z3.object({
|
|
180
|
+
id: NameSchema,
|
|
181
|
+
displayName: z3.string().trim().max(120).default(""),
|
|
182
|
+
siteRole: z3.enum(["admin", "member"]).default("member")
|
|
183
|
+
}).strict();
|
|
184
|
+
var UserUpdateSchema = z3.object({
|
|
185
185
|
expectedRevision: RevisionSchema,
|
|
186
|
-
displayName:
|
|
187
|
-
enabled:
|
|
186
|
+
displayName: z3.string().trim().max(120).optional(),
|
|
187
|
+
enabled: z3.boolean().optional(),
|
|
188
|
+
siteRole: z3.enum(["admin", "member"]).optional()
|
|
188
189
|
}).strict();
|
|
189
190
|
|
|
190
191
|
// packages/protocol/sessions.ts
|
|
191
|
-
import { z as
|
|
192
|
-
var AgentSchema =
|
|
193
|
-
var SessionSnapshotSchema =
|
|
194
|
-
formatVersion:
|
|
195
|
-
adapterVersion:
|
|
192
|
+
import { z as z4 } from "zod";
|
|
193
|
+
var AgentSchema = z4.enum(["claude-code", "codex", "cursor", "pi", "dsh"]);
|
|
194
|
+
var SessionSnapshotSchema = z4.object({
|
|
195
|
+
formatVersion: z4.literal(1),
|
|
196
|
+
adapterVersion: z4.literal(1),
|
|
196
197
|
agent: AgentSchema,
|
|
197
|
-
nativeId:
|
|
198
|
-
cwd:
|
|
199
|
-
messages:
|
|
200
|
-
|
|
201
|
-
id:
|
|
202
|
-
role:
|
|
203
|
-
text:
|
|
204
|
-
source:
|
|
198
|
+
nativeId: z4.string().min(1).max(512),
|
|
199
|
+
cwd: z4.string().min(1).max(4096),
|
|
200
|
+
messages: z4.array(
|
|
201
|
+
z4.object({
|
|
202
|
+
id: z4.string().min(1).max(1024),
|
|
203
|
+
role: z4.enum(["user", "assistant"]),
|
|
204
|
+
text: z4.string().min(1).max(2 * 1024 * 1024),
|
|
205
|
+
source: z4.string().min(1).max(1024)
|
|
205
206
|
}).strict()
|
|
206
207
|
).max(5e4)
|
|
207
208
|
}).strict().refine(
|
|
208
209
|
(s) => new Set(s.messages.map((m) => m.id)).size === s.messages.length,
|
|
209
210
|
"Duplicate message IDs."
|
|
210
211
|
);
|
|
211
|
-
var SessionCommitSchema =
|
|
212
|
-
operationId:
|
|
212
|
+
var SessionCommitSchema = z4.object({
|
|
213
|
+
operationId: z4.uuid(),
|
|
213
214
|
sessionKey: HashSchema,
|
|
214
215
|
parentRevision: RevisionSchema,
|
|
215
216
|
blob: BlobRefSchema
|
|
216
217
|
}).strict();
|
|
217
|
-
var SessionInfoSchema =
|
|
218
|
+
var SessionInfoSchema = z4.object({
|
|
218
219
|
owner: NameSchema,
|
|
219
220
|
sessionKey: HashSchema,
|
|
220
221
|
agent: AgentSchema,
|
|
221
|
-
nativeId:
|
|
222
|
-
cwd:
|
|
223
|
-
title:
|
|
222
|
+
nativeId: z4.string(),
|
|
223
|
+
cwd: z4.string(),
|
|
224
|
+
title: z4.string(),
|
|
224
225
|
revision: RevisionSchema,
|
|
225
|
-
updatedAt:
|
|
226
|
-
messageCount:
|
|
226
|
+
updatedAt: z4.string(),
|
|
227
|
+
messageCount: z4.number().int().nonnegative(),
|
|
227
228
|
blob: BlobRefSchema
|
|
228
229
|
});
|
|
229
|
-
var SessionListSchema =
|
|
230
|
-
sessions:
|
|
230
|
+
var SessionListSchema = z4.object({
|
|
231
|
+
sessions: z4.array(SessionInfoSchema),
|
|
231
232
|
nextCursor: RevisionSchema.nullable()
|
|
232
233
|
});
|
|
233
|
-
var
|
|
234
|
-
|
|
235
|
-
|
|
234
|
+
var SessionStatsSchema = z4.object({
|
|
235
|
+
totalSessions: z4.number().int().nonnegative(),
|
|
236
|
+
totalMessages: z4.number().int().nonnegative(),
|
|
237
|
+
memberCount: z4.number().int().nonnegative(),
|
|
238
|
+
updatedAt: z4.string().nullable()
|
|
239
|
+
});
|
|
240
|
+
var SessionScopeSchema = z4.object({
|
|
241
|
+
owner: NameSchema.optional(),
|
|
242
|
+
sessionKey: HashSchema.optional()
|
|
243
|
+
}).strict().refine((v) => !v.sessionKey || !!v.owner, "A session scope requires an owner.");
|
|
244
|
+
var SessionSearchSchema = z4.object({
|
|
245
|
+
matches: z4.array(
|
|
246
|
+
z4.object({
|
|
236
247
|
owner: NameSchema,
|
|
237
248
|
sessionKey: HashSchema,
|
|
238
249
|
revision: RevisionSchema,
|
|
239
|
-
messageId:
|
|
240
|
-
role:
|
|
241
|
-
snippet:
|
|
250
|
+
messageId: z4.string(),
|
|
251
|
+
role: z4.enum(["user", "assistant"]),
|
|
252
|
+
snippet: z4.string(),
|
|
253
|
+
title: z4.string()
|
|
242
254
|
})
|
|
243
255
|
),
|
|
244
256
|
nextCursor: RevisionSchema.nullable()
|
|
245
257
|
});
|
|
246
258
|
|
|
247
259
|
// packages/transport/hub-client.ts
|
|
248
|
-
import { z as
|
|
260
|
+
import { z as z6 } from "zod";
|
|
249
261
|
import { createHash } from "node:crypto";
|
|
250
262
|
|
|
251
263
|
// packages/protocol/index.ts
|
|
252
|
-
import { z as
|
|
264
|
+
import { z as z5 } from "zod";
|
|
253
265
|
var PROTOCOL_VERSION = 1;
|
|
254
|
-
var HealthSchema =
|
|
255
|
-
var ServerInfoSchema =
|
|
256
|
-
name:
|
|
257
|
-
version:
|
|
258
|
-
protocolVersion:
|
|
259
|
-
capabilities:
|
|
266
|
+
var HealthSchema = z5.object({ status: z5.literal("ok") });
|
|
267
|
+
var ServerInfoSchema = z5.object({
|
|
268
|
+
name: z5.literal("teammy"),
|
|
269
|
+
version: z5.string().min(1),
|
|
270
|
+
protocolVersion: z5.literal(PROTOCOL_VERSION),
|
|
271
|
+
capabilities: z5.array(z5.string())
|
|
260
272
|
});
|
|
261
|
-
var ErrorResponseSchema =
|
|
262
|
-
error:
|
|
263
|
-
code:
|
|
264
|
-
message:
|
|
265
|
-
requestId:
|
|
273
|
+
var ErrorResponseSchema = z5.object({
|
|
274
|
+
error: z5.object({
|
|
275
|
+
code: z5.string(),
|
|
276
|
+
message: z5.string(),
|
|
277
|
+
requestId: z5.string()
|
|
266
278
|
})
|
|
267
279
|
});
|
|
268
280
|
|
|
@@ -306,8 +318,11 @@ var HubClient = class {
|
|
|
306
318
|
get url() {
|
|
307
319
|
return this.baseUrl.toString();
|
|
308
320
|
}
|
|
309
|
-
bind(deviceId) {
|
|
310
|
-
return this.request("POST", "api/v1/bind", BindingSchema, {
|
|
321
|
+
bind(deviceId, projectId) {
|
|
322
|
+
return this.request("POST", "api/v1/bind", BindingSchema, {
|
|
323
|
+
deviceId,
|
|
324
|
+
...projectId ? { projectId } : {}
|
|
325
|
+
});
|
|
311
326
|
}
|
|
312
327
|
members() {
|
|
313
328
|
return this.request("GET", "api/v1/members", MembersSchema);
|
|
@@ -325,18 +340,18 @@ var HubClient = class {
|
|
|
325
340
|
"GET",
|
|
326
341
|
`api/v1/blobs/${HashSchema.parse(hash2)}`
|
|
327
342
|
);
|
|
328
|
-
if (!response.ok) return this.decode(response, signal,
|
|
343
|
+
if (!response.ok) return this.decode(response, signal, z6.never());
|
|
329
344
|
const reader = response.body?.getReader();
|
|
330
345
|
if (!reader) throw new Error("Server returned no content body.");
|
|
331
346
|
const parts = [];
|
|
332
347
|
let size = 0;
|
|
333
348
|
try {
|
|
334
349
|
for (; ; ) {
|
|
335
|
-
const
|
|
336
|
-
if (
|
|
337
|
-
size +=
|
|
350
|
+
const result2 = await reader.read();
|
|
351
|
+
if (result2.done) break;
|
|
352
|
+
size += result2.value.byteLength;
|
|
338
353
|
if (size > MAX_BLOB_BYTES) throw new Error("Downloaded content exceeds the size limit.");
|
|
339
|
-
parts.push(
|
|
354
|
+
parts.push(result2.value);
|
|
340
355
|
}
|
|
341
356
|
} finally {
|
|
342
357
|
await reader.cancel();
|
|
@@ -370,7 +385,7 @@ var HubClient = class {
|
|
|
370
385
|
return this.request(
|
|
371
386
|
"PUT",
|
|
372
387
|
"api/v1/devices/self/status",
|
|
373
|
-
|
|
388
|
+
z6.object({ ok: z6.literal(true) }),
|
|
374
389
|
status
|
|
375
390
|
);
|
|
376
391
|
}
|
|
@@ -414,6 +429,8 @@ var HubClient = class {
|
|
|
414
429
|
return this.decode(response, signal, schema);
|
|
415
430
|
}
|
|
416
431
|
async fetchResponse(method, path, requestBody) {
|
|
432
|
+
if (this.credentials?.workspaceId && path.startsWith("api/v1/") && !["api/v1/bind", "api/v1/server"].includes(path))
|
|
433
|
+
path = `api/v1/projects/${encodeURIComponent(this.credentials.workspaceId)}/${path.slice("api/v1/".length)}`;
|
|
417
434
|
const timeout = AbortSignal.timeout(this.timeoutMs);
|
|
418
435
|
const signal = this.abortSignal ? AbortSignal.any([timeout, this.abortSignal]) : timeout;
|
|
419
436
|
const binary = requestBody instanceof Uint8Array;
|
|
@@ -464,64 +481,107 @@ var HubClient = class {
|
|
|
464
481
|
}
|
|
465
482
|
};
|
|
466
483
|
|
|
467
|
-
// apps/client/
|
|
468
|
-
import {
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
484
|
+
// apps/client/sessions/command.ts
|
|
485
|
+
import { createHash as createHash6 } from "node:crypto";
|
|
486
|
+
import { realpath as realpath4 } from "node:fs/promises";
|
|
487
|
+
import { homedir } from "node:os";
|
|
488
|
+
import { dirname as dirname4, join as join6, resolve as resolve3 } from "node:path";
|
|
489
|
+
import { parseArgs } from "node:util";
|
|
490
|
+
|
|
491
|
+
// apps/client/runtime/state.ts
|
|
492
|
+
var runtimeMigration = `
|
|
493
|
+
ALTER TABLE file_index ADD COLUMN seen_local INTEGER NOT NULL DEFAULT 0;
|
|
494
|
+
UPDATE file_index SET seen_local=1 WHERE signature IS NOT NULL OR hash IS NOT NULL;
|
|
495
|
+
CREATE TABLE client_runtime(root TEXT PRIMARY KEY REFERENCES connections(root), enabled INTEGER NOT NULL DEFAULT 0, status_json TEXT);
|
|
496
|
+
CREATE TABLE client_preparations(root TEXT PRIMARY KEY REFERENCES connections(root), identity TEXT NOT NULL, input_json TEXT NOT NULL, complete INTEGER NOT NULL DEFAULT 0);
|
|
472
497
|
`;
|
|
473
|
-
var
|
|
474
|
-
constructor(state
|
|
498
|
+
var RuntimeState = class {
|
|
499
|
+
constructor(state) {
|
|
475
500
|
this.state = state;
|
|
476
|
-
this.root = root;
|
|
477
501
|
}
|
|
478
502
|
state;
|
|
479
|
-
root
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
throw new Error("Another enrollment is pending for this root; use the same member/server.");
|
|
487
|
-
return request2;
|
|
488
|
-
}
|
|
489
|
-
const request = {
|
|
490
|
-
operationId: randomUUID(),
|
|
491
|
-
memberId,
|
|
492
|
-
deviceId,
|
|
493
|
-
credential: `tmy_${randomBytes(32).toString("hex")}`
|
|
494
|
-
};
|
|
503
|
+
enabled(root) {
|
|
504
|
+
return Boolean(
|
|
505
|
+
this.state.db.prepare("SELECT enabled FROM client_runtime WHERE root=?").get(root)?.enabled
|
|
506
|
+
);
|
|
507
|
+
}
|
|
508
|
+
enable(root, enabled) {
|
|
509
|
+
if (!this.state.binding(root)) throw new Error("Connect this workspace first.");
|
|
495
510
|
this.state.db.prepare(
|
|
496
|
-
"INSERT INTO
|
|
497
|
-
).run(
|
|
498
|
-
return request;
|
|
511
|
+
"INSERT INTO client_runtime(root,enabled) VALUES (?,?) ON CONFLICT(root) DO UPDATE SET enabled=excluded.enabled"
|
|
512
|
+
).run(root, Number(enabled));
|
|
499
513
|
}
|
|
500
|
-
|
|
501
|
-
this.state.db.prepare("
|
|
514
|
+
roots() {
|
|
515
|
+
return this.state.db.prepare("SELECT root FROM client_runtime WHERE enabled=1").all().map((r) => String(r.root));
|
|
502
516
|
}
|
|
503
|
-
|
|
504
|
-
const row = this.state.db.prepare("SELECT
|
|
505
|
-
return row ?
|
|
506
|
-
config: ProjectConfigSchema.parse(JSON.parse(String(row.config_json))),
|
|
507
|
-
hash: String(row.descriptor_hash)
|
|
508
|
-
} : void 0;
|
|
517
|
+
status(root) {
|
|
518
|
+
const row = this.state.db.prepare("SELECT status_json FROM client_runtime WHERE root=?").get(root);
|
|
519
|
+
return row?.status_json ? JSON.parse(String(row.status_json)) : null;
|
|
509
520
|
}
|
|
510
|
-
save(
|
|
521
|
+
save(root, status) {
|
|
511
522
|
this.state.db.prepare(
|
|
512
|
-
"INSERT INTO
|
|
513
|
-
).run(
|
|
523
|
+
"INSERT INTO client_runtime(root,status_json) VALUES (?,?) ON CONFLICT(root) DO UPDATE SET status_json=excluded.status_json"
|
|
524
|
+
).run(root, JSON.stringify(status));
|
|
514
525
|
}
|
|
515
526
|
};
|
|
516
527
|
|
|
517
|
-
// apps/client/
|
|
518
|
-
import {
|
|
528
|
+
// apps/client/state.ts
|
|
529
|
+
import { randomUUID as randomUUID3 } from "node:crypto";
|
|
530
|
+
import { join as join4, relative as relative2, isAbsolute, sep as sep2 } from "node:path";
|
|
531
|
+
import { z as z8 } from "zod";
|
|
532
|
+
|
|
533
|
+
// packages/state/sqlite.ts
|
|
534
|
+
import { chmodSync, mkdirSync } from "node:fs";
|
|
535
|
+
import { dirname } from "node:path";
|
|
536
|
+
import { DatabaseSync } from "node:sqlite";
|
|
537
|
+
function transaction(db, action) {
|
|
538
|
+
db.exec("BEGIN IMMEDIATE");
|
|
539
|
+
try {
|
|
540
|
+
const result2 = action();
|
|
541
|
+
db.exec("COMMIT");
|
|
542
|
+
return result2;
|
|
543
|
+
} catch (error) {
|
|
544
|
+
db.exec("ROLLBACK");
|
|
545
|
+
throw error;
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
function openDatabase(path, migrations, busyTimeoutMs = 5e3) {
|
|
549
|
+
if (!Number.isSafeInteger(busyTimeoutMs) || busyTimeoutMs < 0)
|
|
550
|
+
throw new Error("Invalid SQLite busy timeout.");
|
|
551
|
+
mkdirSync(dirname(path), { recursive: true, mode: 448 });
|
|
552
|
+
const db = new DatabaseSync(path);
|
|
553
|
+
try {
|
|
554
|
+
chmodSync(path, 384);
|
|
555
|
+
db.exec(
|
|
556
|
+
`PRAGMA busy_timeout = ${busyTimeoutMs}; PRAGMA foreign_keys = ON; PRAGMA journal_mode = WAL; PRAGMA synchronous = FULL;`
|
|
557
|
+
);
|
|
558
|
+
transaction(db, () => {
|
|
559
|
+
const version = Number(db.prepare("PRAGMA user_version").get().user_version);
|
|
560
|
+
if (version > migrations.length)
|
|
561
|
+
throw new Error("Database was created by a newer Teammy version.");
|
|
562
|
+
for (let index = version; index < migrations.length; index++) {
|
|
563
|
+
db.exec(migrations[index]);
|
|
564
|
+
db.exec(`PRAGMA user_version = ${index + 1}`);
|
|
565
|
+
}
|
|
566
|
+
});
|
|
567
|
+
return db;
|
|
568
|
+
} catch (error) {
|
|
569
|
+
db.close();
|
|
570
|
+
throw error;
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
// apps/client/workspace.ts
|
|
575
|
+
import { execFile } from "node:child_process";
|
|
576
|
+
import { promisify } from "node:util";
|
|
577
|
+
import { lstat as lstat2, mkdir as mkdir2, realpath as realpath2, rmdir as rmdir2 } from "node:fs/promises";
|
|
578
|
+
import { basename, dirname as dirname3, join as join2, resolve } from "node:path";
|
|
519
579
|
|
|
520
580
|
// apps/client/files/local-fs.ts
|
|
521
|
-
import { createHash as createHash2, randomUUID
|
|
581
|
+
import { createHash as createHash2, randomUUID } from "node:crypto";
|
|
522
582
|
import { constants } from "node:fs";
|
|
523
583
|
import { lstat, mkdir, open, readdir, realpath, rename, rmdir, unlink } from "node:fs/promises";
|
|
524
|
-
import { dirname, join, relative, sep } from "node:path";
|
|
584
|
+
import { dirname as dirname2, join, relative, sep } from "node:path";
|
|
525
585
|
import { setImmediate } from "node:timers/promises";
|
|
526
586
|
function missing(error) {
|
|
527
587
|
return error instanceof Error && "code" in error && ["ENOENT", "ENOTDIR"].includes(String(error.code));
|
|
@@ -651,7 +711,7 @@ async function syncDirectory(directory) {
|
|
|
651
711
|
}
|
|
652
712
|
async function replaceFile(root, path, bytes, expected) {
|
|
653
713
|
const target = await safePath(root, path, true);
|
|
654
|
-
const temporary = join(
|
|
714
|
+
const temporary = join(dirname2(target), `.teammy-${randomUUID()}.tmp`);
|
|
655
715
|
const file = await open(temporary, "wx", 384);
|
|
656
716
|
try {
|
|
657
717
|
try {
|
|
@@ -668,11 +728,11 @@ async function replaceFile(root, path, bytes, expected) {
|
|
|
668
728
|
throw new Error(`Local file changed before replacement: ${path}`);
|
|
669
729
|
await safePath(root, path);
|
|
670
730
|
await rename(temporary, target);
|
|
671
|
-
let directory =
|
|
731
|
+
let directory = dirname2(target);
|
|
672
732
|
for (; ; ) {
|
|
673
733
|
await syncDirectory(directory);
|
|
674
|
-
if (directory ===
|
|
675
|
-
directory =
|
|
734
|
+
if (directory === dirname2(root)) break;
|
|
735
|
+
directory = dirname2(directory);
|
|
676
736
|
}
|
|
677
737
|
} finally {
|
|
678
738
|
await unlink(temporary).catch((error) => {
|
|
@@ -687,65 +747,351 @@ async function removeFile(root, path, expected) {
|
|
|
687
747
|
throw new Error(`Local file changed before deletion: ${path}`);
|
|
688
748
|
const target = await safePath(root, path);
|
|
689
749
|
await unlink(target);
|
|
690
|
-
await syncDirectory(
|
|
691
|
-
let parent =
|
|
750
|
+
await syncDirectory(dirname2(target));
|
|
751
|
+
let parent = dirname2(target);
|
|
692
752
|
while (parent !== root) {
|
|
693
753
|
try {
|
|
694
754
|
await rmdir(parent);
|
|
695
|
-
await syncDirectory(
|
|
755
|
+
await syncDirectory(dirname2(parent));
|
|
696
756
|
} catch {
|
|
697
757
|
break;
|
|
698
758
|
}
|
|
699
|
-
parent =
|
|
759
|
+
parent = dirname2(parent);
|
|
700
760
|
}
|
|
701
761
|
}
|
|
702
762
|
|
|
703
|
-
// apps/client/
|
|
704
|
-
var
|
|
705
|
-
async function
|
|
706
|
-
const
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
const
|
|
711
|
-
if (
|
|
712
|
-
throw new Error("
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
763
|
+
// apps/client/workspace.ts
|
|
764
|
+
var exec = promisify(execFile);
|
|
765
|
+
async function workspacePath(input) {
|
|
766
|
+
const path = resolve(input);
|
|
767
|
+
return join2(await realpath2(dirname3(path)), basename(path));
|
|
768
|
+
}
|
|
769
|
+
async function directoryIdentity(root) {
|
|
770
|
+
const stat2 = await lstat2(root, { bigint: true });
|
|
771
|
+
if (!stat2.isDirectory() || stat2.isSymbolicLink())
|
|
772
|
+
throw new Error("Workspace must be an ordinary directory.");
|
|
773
|
+
return `${stat2.dev}:${stat2.ino}`;
|
|
774
|
+
}
|
|
775
|
+
async function verifyWorkspace(state, root) {
|
|
776
|
+
const identity = await directoryIdentity(root);
|
|
777
|
+
const prior = state.db.prepare("SELECT identity FROM client_preparations WHERE root=?").get(root);
|
|
778
|
+
if (prior && prior.identity !== identity)
|
|
779
|
+
throw new Error(
|
|
780
|
+
"Workspace directory was replaced. Restore the original directory before syncing."
|
|
781
|
+
);
|
|
782
|
+
}
|
|
783
|
+
async function ignorePlan(root) {
|
|
784
|
+
const parent = dirname3(root), name2 = basename(root);
|
|
785
|
+
if (/[\r\n]/.test(name2)) throw new Error("Workspace name cannot contain a newline.");
|
|
786
|
+
let repo = false;
|
|
787
|
+
for (let p = parent; ; p = dirname3(p)) {
|
|
788
|
+
try {
|
|
789
|
+
await lstat2(join2(p, ".git"));
|
|
790
|
+
repo = true;
|
|
791
|
+
break;
|
|
792
|
+
} catch (e) {
|
|
793
|
+
if (!missing(e)) throw e;
|
|
794
|
+
}
|
|
795
|
+
if (p === dirname3(p)) break;
|
|
796
|
+
}
|
|
797
|
+
if (repo) {
|
|
798
|
+
let tracked;
|
|
799
|
+
try {
|
|
800
|
+
tracked = (await exec("git", ["-C", parent, "--literal-pathspecs", "ls-files", "-z", "--", name2], {
|
|
801
|
+
maxBuffer: 1024 * 1024
|
|
802
|
+
})).stdout;
|
|
803
|
+
} catch {
|
|
804
|
+
throw new Error("Cannot inspect the Git index. Check Git access before connecting.");
|
|
805
|
+
}
|
|
806
|
+
if (tracked)
|
|
723
807
|
throw new Error(
|
|
724
|
-
"
|
|
808
|
+
"Git already tracks this workspace path. Remove it from the Git index yourself before connecting."
|
|
725
809
|
);
|
|
726
|
-
|
|
727
|
-
|
|
810
|
+
try {
|
|
811
|
+
await exec("git", ["-C", parent, "check-ignore", "--no-index", "-q", "--", name2 + "/"]);
|
|
728
812
|
return;
|
|
813
|
+
} catch (e) {
|
|
814
|
+
if (e.code !== 1) throw new Error("Cannot check Git ignore rules.");
|
|
729
815
|
}
|
|
730
816
|
}
|
|
731
|
-
await
|
|
732
|
-
|
|
817
|
+
const entry = await metadata(parent, ".gitignore");
|
|
818
|
+
if (entry?.directory) throw new Error(".gitignore is a directory.");
|
|
819
|
+
const original = entry ? await readStable(parent, ".gitignore") : null;
|
|
820
|
+
const content = original ? new TextDecoder("utf-8", { fatal: true }).decode(original.bytes) : "";
|
|
821
|
+
const rule = "/" + name2.replace(/[\\*?[\]#! ]/g, "\\$&") + "/";
|
|
822
|
+
if (content.split(/\r?\n/).includes(rule)) return;
|
|
823
|
+
const newline = content.includes("\r\n") ? "\r\n" : "\n";
|
|
824
|
+
return {
|
|
825
|
+
parent,
|
|
826
|
+
bytes: Buffer.from(
|
|
827
|
+
content + (content && !content.endsWith("\n") ? newline : "") + rule + newline
|
|
828
|
+
),
|
|
829
|
+
signature: original?.signature ?? null
|
|
830
|
+
};
|
|
831
|
+
}
|
|
832
|
+
async function prepareWorkspace(state, root, input, reconnect = false) {
|
|
833
|
+
state.assertRootAvailable(root);
|
|
834
|
+
const prior = state.db.prepare("SELECT * FROM client_preparations WHERE root=?").get(root);
|
|
835
|
+
let present = false;
|
|
836
|
+
try {
|
|
837
|
+
await lstat2(root);
|
|
838
|
+
present = true;
|
|
839
|
+
} catch (e) {
|
|
840
|
+
if (!missing(e)) throw e;
|
|
841
|
+
}
|
|
842
|
+
if (present) {
|
|
843
|
+
if (reconnect && state.binding(root)) {
|
|
844
|
+
await verifyWorkspace(state, root);
|
|
845
|
+
return;
|
|
846
|
+
}
|
|
847
|
+
if (prior && !prior.complete && prior.input_json === JSON.stringify(input)) {
|
|
848
|
+
await verifyWorkspace(state, root);
|
|
849
|
+
const ignore2 = await ignorePlan(root);
|
|
850
|
+
if (ignore2) await replaceFile(ignore2.parent, ".gitignore", ignore2.bytes, ignore2.signature);
|
|
851
|
+
return;
|
|
852
|
+
}
|
|
853
|
+
throw new Error(
|
|
854
|
+
'Workspace path already exists. Choose another directory; for a connected workspace use "teammy sync on".'
|
|
855
|
+
);
|
|
856
|
+
}
|
|
857
|
+
if (state.binding(root) || prior)
|
|
858
|
+
throw new Error("Connected workspace is missing. Restore its directory before syncing.");
|
|
859
|
+
const ignore = await ignorePlan(root);
|
|
860
|
+
await mkdir2(root);
|
|
861
|
+
try {
|
|
862
|
+
const identity = await directoryIdentity(root);
|
|
863
|
+
state.deviceFor(root);
|
|
864
|
+
state.db.prepare("INSERT INTO client_preparations(root,identity,input_json) VALUES (?,?,?)").run(root, identity, JSON.stringify(input));
|
|
865
|
+
} catch (e) {
|
|
866
|
+
await rmdir2(root).catch(() => {
|
|
867
|
+
});
|
|
868
|
+
throw e;
|
|
869
|
+
}
|
|
870
|
+
if (ignore) await replaceFile(ignore.parent, ".gitignore", ignore.bytes, ignore.signature);
|
|
871
|
+
}
|
|
872
|
+
function completePreparation(state, root) {
|
|
873
|
+
state.db.prepare("UPDATE client_preparations SET complete=1 WHERE root=?").run(root);
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
// apps/client/runtime/log.ts
|
|
877
|
+
import { appendFileSync, mkdirSync as mkdirSync2, renameSync, statSync, unlinkSync, readFileSync } from "node:fs";
|
|
878
|
+
import { join as join3 } from "node:path";
|
|
879
|
+
import { createHash as createHash3 } from "node:crypto";
|
|
880
|
+
function rootKey(root) {
|
|
881
|
+
return createHash3("sha256").update(root).digest("hex").slice(0, 16);
|
|
882
|
+
}
|
|
883
|
+
function redact(value) {
|
|
884
|
+
return value.replace(/tmy_[a-f0-9]+/gi, "[credential]").replace(/Bearer\s+\S+/gi, "Bearer [redacted]").replace(/(https?:\/\/)[^\s/]+@/gi, "$1[redacted]@").replace(/[\x00-\x1f\x7f]/g, " ").slice(0, 1500);
|
|
733
885
|
}
|
|
886
|
+
function diagnostic(error) {
|
|
887
|
+
if (!(error instanceof Error)) return "Operation failed.";
|
|
888
|
+
const fields2 = error;
|
|
889
|
+
return redact([error.message, fields2.code, fields2.requestId].filter(Boolean).join(" \xB7 "));
|
|
890
|
+
}
|
|
891
|
+
var ClientLog = class {
|
|
892
|
+
constructor(directory, limit = 1024 * 1024) {
|
|
893
|
+
this.limit = limit;
|
|
894
|
+
const logs = join3(directory, "logs");
|
|
895
|
+
mkdirSync2(logs, { recursive: true, mode: 448 });
|
|
896
|
+
this.path = join3(logs, "client.jsonl");
|
|
897
|
+
}
|
|
898
|
+
limit;
|
|
899
|
+
path;
|
|
900
|
+
write(event, root, detail) {
|
|
901
|
+
try {
|
|
902
|
+
let size = 0;
|
|
903
|
+
try {
|
|
904
|
+
size = statSync(this.path).size;
|
|
905
|
+
} catch {
|
|
906
|
+
}
|
|
907
|
+
if (size >= this.limit) {
|
|
908
|
+
try {
|
|
909
|
+
unlinkSync(this.path + ".2");
|
|
910
|
+
} catch {
|
|
911
|
+
}
|
|
912
|
+
try {
|
|
913
|
+
renameSync(this.path + ".1", this.path + ".2");
|
|
914
|
+
} catch {
|
|
915
|
+
}
|
|
916
|
+
renameSync(this.path, this.path + ".1");
|
|
917
|
+
}
|
|
918
|
+
appendFileSync(
|
|
919
|
+
this.path,
|
|
920
|
+
JSON.stringify({
|
|
921
|
+
at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
922
|
+
event,
|
|
923
|
+
root: root ? rootKey(root) : void 0,
|
|
924
|
+
detail: detail ? redact(detail) : void 0
|
|
925
|
+
}) + "\n",
|
|
926
|
+
{ mode: 384 }
|
|
927
|
+
);
|
|
928
|
+
} catch {
|
|
929
|
+
}
|
|
930
|
+
}
|
|
931
|
+
tail(root, count = 20) {
|
|
932
|
+
const lines = [];
|
|
933
|
+
for (const suffix of [".2", ".1", ""])
|
|
934
|
+
try {
|
|
935
|
+
for (const line of readFileSync(this.path + suffix, "utf8").split("\n")) {
|
|
936
|
+
if (!line) continue;
|
|
937
|
+
try {
|
|
938
|
+
const item = JSON.parse(line);
|
|
939
|
+
if (!root || item.root === rootKey(root) || !item.root) lines.push(line);
|
|
940
|
+
} catch {
|
|
941
|
+
}
|
|
942
|
+
}
|
|
943
|
+
} catch {
|
|
944
|
+
}
|
|
945
|
+
return lines.slice(-count).map((line) => JSON.parse(line));
|
|
946
|
+
}
|
|
947
|
+
};
|
|
734
948
|
|
|
735
949
|
// apps/client/control.ts
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
950
|
+
import { setTimeout as setTimeout2 } from "node:timers/promises";
|
|
951
|
+
|
|
952
|
+
// packages/protocol/devices.ts
|
|
953
|
+
import { z as z7 } from "zod";
|
|
954
|
+
var CredentialSchema = z7.string().regex(/^tmy_[a-f0-9]{64}$/);
|
|
955
|
+
var EnrollmentSchema = z7.object({
|
|
956
|
+
operationId: z7.uuid(),
|
|
957
|
+
projectId: NameSchema,
|
|
958
|
+
memberId: NameSchema,
|
|
959
|
+
deviceId: DeviceIdSchema,
|
|
960
|
+
credential: CredentialSchema
|
|
961
|
+
}).strict();
|
|
962
|
+
var EffectivePolicySchema = z7.object({
|
|
963
|
+
checkIntervalSeconds: z7.number().min(1).max(86400),
|
|
964
|
+
fileSettleSeconds: z7.number().min(0).max(86400)
|
|
965
|
+
}).strict();
|
|
966
|
+
var SyncSummarySchema = z7.object({
|
|
967
|
+
checkedAt: z7.string(),
|
|
968
|
+
published: z7.boolean(),
|
|
969
|
+
appliedFiles: z7.number().int().nonnegative(),
|
|
970
|
+
pendingFiles: z7.number().int().nonnegative(),
|
|
971
|
+
sessionSources: z7.number().int().nonnegative(),
|
|
972
|
+
errors: z7.array(z7.string().max(1e3)).max(20)
|
|
973
|
+
}).strict();
|
|
974
|
+
var DeviceStatusSchema = z7.object({
|
|
975
|
+
clientVersion: z7.string().max(64),
|
|
976
|
+
receivedRevision: RevisionSchema.nullable(),
|
|
977
|
+
appliedRevision: RevisionSchema.nullable(),
|
|
978
|
+
effectivePolicy: EffectivePolicySchema,
|
|
979
|
+
overrides: z7.array(z7.enum(["checkIntervalSeconds", "fileSettleSeconds"])).max(2),
|
|
980
|
+
configError: z7.string().max(2e3).nullable(),
|
|
981
|
+
reportError: z7.string().max(2e3).nullable(),
|
|
982
|
+
lastSync: SyncSummarySchema.nullable()
|
|
983
|
+
}).strict();
|
|
984
|
+
var WebDevicesSchema = z7.object({
|
|
985
|
+
devices: z7.array(
|
|
986
|
+
z7.object({
|
|
987
|
+
memberId: NameSchema,
|
|
988
|
+
bound: z7.boolean(),
|
|
989
|
+
lastSeenAt: z7.string().nullable(),
|
|
990
|
+
status: DeviceStatusSchema.nullable()
|
|
991
|
+
})
|
|
992
|
+
)
|
|
993
|
+
});
|
|
994
|
+
|
|
995
|
+
// apps/client/project-state.ts
|
|
996
|
+
import { randomBytes, randomUUID as randomUUID2 } from "node:crypto";
|
|
997
|
+
var projectClientMigration = String.raw`
|
|
998
|
+
CREATE TABLE client_enrollments(root TEXT PRIMARY KEY REFERENCES connections(root),server_url TEXT NOT NULL,request_json TEXT NOT NULL);
|
|
999
|
+
CREATE TABLE client_project_config(root TEXT PRIMARY KEY REFERENCES connections(root),config_json TEXT NOT NULL,descriptor_hash TEXT NOT NULL);
|
|
1000
|
+
`;
|
|
1001
|
+
var ClientProjectState = class {
|
|
1002
|
+
constructor(state, root) {
|
|
739
1003
|
this.state = state;
|
|
740
1004
|
this.root = root;
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
this.
|
|
747
|
-
|
|
748
|
-
|
|
1005
|
+
}
|
|
1006
|
+
state;
|
|
1007
|
+
root;
|
|
1008
|
+
enrollment(url, memberId, projectId, reconnect = false) {
|
|
1009
|
+
const deviceId = this.state.deviceFor(this.root);
|
|
1010
|
+
const row = this.state.db.prepare("SELECT * FROM client_enrollments WHERE root=?").get(this.root);
|
|
1011
|
+
if (row && !reconnect) {
|
|
1012
|
+
const request2 = EnrollmentSchema.parse(JSON.parse(String(row.request_json)));
|
|
1013
|
+
if (row.server_url !== url || request2.memberId !== memberId || request2.projectId !== projectId)
|
|
1014
|
+
throw new Error(
|
|
1015
|
+
"Another enrollment is pending for this root; use the same member/server/project."
|
|
1016
|
+
);
|
|
1017
|
+
return request2;
|
|
1018
|
+
}
|
|
1019
|
+
const request = {
|
|
1020
|
+
operationId: randomUUID2(),
|
|
1021
|
+
memberId,
|
|
1022
|
+
projectId,
|
|
1023
|
+
deviceId,
|
|
1024
|
+
credential: `tmy_${randomBytes(32).toString("hex")}`
|
|
1025
|
+
};
|
|
1026
|
+
this.state.db.prepare(
|
|
1027
|
+
"INSERT INTO client_enrollments VALUES (?,?,?) ON CONFLICT(root) DO UPDATE SET server_url=excluded.server_url,request_json=excluded.request_json"
|
|
1028
|
+
).run(this.root, url, JSON.stringify(request));
|
|
1029
|
+
return request;
|
|
1030
|
+
}
|
|
1031
|
+
completeEnrollment() {
|
|
1032
|
+
this.state.db.prepare("DELETE FROM client_enrollments WHERE root=?").run(this.root);
|
|
1033
|
+
}
|
|
1034
|
+
cached() {
|
|
1035
|
+
const row = this.state.db.prepare("SELECT * FROM client_project_config WHERE root=?").get(this.root);
|
|
1036
|
+
return row ? {
|
|
1037
|
+
config: ProjectConfigSchema.parse(JSON.parse(String(row.config_json))),
|
|
1038
|
+
hash: String(row.descriptor_hash)
|
|
1039
|
+
} : void 0;
|
|
1040
|
+
}
|
|
1041
|
+
save(config, hash2) {
|
|
1042
|
+
this.state.db.prepare(
|
|
1043
|
+
"INSERT INTO client_project_config VALUES (?,?,?) ON CONFLICT(root) DO UPDATE SET config_json=excluded.config_json,descriptor_hash=excluded.descriptor_hash"
|
|
1044
|
+
).run(this.root, JSON.stringify(config), hash2);
|
|
1045
|
+
}
|
|
1046
|
+
};
|
|
1047
|
+
|
|
1048
|
+
// apps/client/project-file.ts
|
|
1049
|
+
import { createHash as createHash4 } from "node:crypto";
|
|
1050
|
+
var name = ".teammy.json";
|
|
1051
|
+
async function applyProjectFile(local, input) {
|
|
1052
|
+
const config = ProjectConfigSchema.parse(input);
|
|
1053
|
+
const binding = local.state.binding(local.root);
|
|
1054
|
+
if (!binding || binding.workspaceId !== config.projectId)
|
|
1055
|
+
throw new Error("Project configuration does not match the local binding.");
|
|
1056
|
+
const cached = local.cached();
|
|
1057
|
+
if (cached && config.configRevision < cached.config.configRevision)
|
|
1058
|
+
throw new Error("Server project configuration moved backwards.");
|
|
1059
|
+
const bytes = Buffer.from(JSON.stringify(config, null, 2) + "\n");
|
|
1060
|
+
const hash2 = createHash4("sha256").update(bytes).digest("hex");
|
|
1061
|
+
const entry = await metadata(local.root, name);
|
|
1062
|
+
let signature2 = null;
|
|
1063
|
+
if (entry) {
|
|
1064
|
+
if (entry.directory)
|
|
1065
|
+
throw new Error("Project configuration path is a directory; preserving it.");
|
|
1066
|
+
const existing = await readStable(local.root, name);
|
|
1067
|
+
signature2 = existing.signature;
|
|
1068
|
+
if (existing.sha256 !== hash2 && existing.sha256 !== cached?.hash)
|
|
1069
|
+
throw new Error(
|
|
1070
|
+
"Existing .teammy.json is not the expected managed copy. Preserve it elsewhere before retrying."
|
|
1071
|
+
);
|
|
1072
|
+
if (existing.sha256 === hash2) {
|
|
1073
|
+
local.save(config, hash2);
|
|
1074
|
+
return;
|
|
1075
|
+
}
|
|
1076
|
+
}
|
|
1077
|
+
await replaceFile(local.root, name, bytes, signature2);
|
|
1078
|
+
local.save(config, hash2);
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
// apps/client/control.ts
|
|
1082
|
+
var controlClientMigration = "CREATE TABLE client_control(root TEXT PRIMARY KEY REFERENCES connections(root),status_json TEXT NOT NULL);";
|
|
1083
|
+
var ControlPlane = class {
|
|
1084
|
+
constructor(state, root, version, overrides, apply) {
|
|
1085
|
+
this.state = state;
|
|
1086
|
+
this.root = root;
|
|
1087
|
+
this.overrides = overrides;
|
|
1088
|
+
this.apply = apply;
|
|
1089
|
+
const binding = state.binding(root);
|
|
1090
|
+
if (!binding) throw new Error("Connect this root first.");
|
|
1091
|
+
this.client = new HubClient(binding.serverUrl, 1e4, binding, this.stop.signal);
|
|
1092
|
+
this.local = new ClientProjectState(state, root);
|
|
1093
|
+
const row = state.db.prepare("SELECT status_json FROM client_control WHERE root=?").get(root);
|
|
1094
|
+
const prior = row ? DeviceStatusSchema.parse(JSON.parse(String(row.status_json))) : void 0;
|
|
749
1095
|
const cached = this.local.cached();
|
|
750
1096
|
this.status = {
|
|
751
1097
|
clientVersion: version,
|
|
@@ -773,6 +1119,7 @@ var ControlPlane = class {
|
|
|
773
1119
|
enabled = true;
|
|
774
1120
|
stop = new AbortController();
|
|
775
1121
|
running;
|
|
1122
|
+
pending;
|
|
776
1123
|
persist() {
|
|
777
1124
|
this.state.db.prepare(
|
|
778
1125
|
"INSERT INTO client_control VALUES (?,?) ON CONFLICT(root) DO UPDATE SET status_json=excluded.status_json"
|
|
@@ -782,11 +1129,19 @@ var ControlPlane = class {
|
|
|
782
1129
|
this.status.lastSync = summary;
|
|
783
1130
|
this.persist();
|
|
784
1131
|
}
|
|
785
|
-
|
|
1132
|
+
tick() {
|
|
1133
|
+
this.pending ??= this.check().finally(() => {
|
|
1134
|
+
this.pending = void 0;
|
|
1135
|
+
});
|
|
1136
|
+
return this.pending;
|
|
1137
|
+
}
|
|
1138
|
+
async check() {
|
|
786
1139
|
if (this.stop.signal.aborted) return;
|
|
787
1140
|
try {
|
|
1141
|
+
await verifyWorkspace(this.state, this.root);
|
|
788
1142
|
const config = await this.client.projectConfig();
|
|
789
1143
|
this.status.receivedRevision = config.configRevision;
|
|
1144
|
+
await verifyWorkspace(this.state, this.root);
|
|
790
1145
|
await applyProjectFile(this.local, config);
|
|
791
1146
|
const effective = EffectivePolicySchema.parse({ ...config.sync, ...this.overrides });
|
|
792
1147
|
this.apply(effective);
|
|
@@ -798,14 +1153,14 @@ var ControlPlane = class {
|
|
|
798
1153
|
this.enabled = false;
|
|
799
1154
|
return;
|
|
800
1155
|
}
|
|
801
|
-
this.status.configError = (error
|
|
1156
|
+
this.status.configError = diagnostic(error);
|
|
802
1157
|
}
|
|
803
1158
|
this.persist();
|
|
804
1159
|
this.status.reportError = null;
|
|
805
1160
|
try {
|
|
806
1161
|
await this.client.reportDeviceStatus(this.status);
|
|
807
1162
|
} catch (error) {
|
|
808
|
-
this.status.reportError =
|
|
1163
|
+
this.status.reportError = diagnostic(error);
|
|
809
1164
|
}
|
|
810
1165
|
this.persist();
|
|
811
1166
|
}
|
|
@@ -818,7 +1173,7 @@ var ControlPlane = class {
|
|
|
818
1173
|
await this.tick();
|
|
819
1174
|
} catch (error) {
|
|
820
1175
|
if (!this.stop.signal.aborted) {
|
|
821
|
-
this.status.configError =
|
|
1176
|
+
this.status.configError = diagnostic(error);
|
|
822
1177
|
}
|
|
823
1178
|
}
|
|
824
1179
|
}
|
|
@@ -827,6 +1182,7 @@ var ControlPlane = class {
|
|
|
827
1182
|
async close() {
|
|
828
1183
|
this.stop.abort();
|
|
829
1184
|
await this.running;
|
|
1185
|
+
await this.pending;
|
|
830
1186
|
}
|
|
831
1187
|
};
|
|
832
1188
|
function readControlStatus(state, root) {
|
|
@@ -834,143 +1190,6 @@ function readControlStatus(state, root) {
|
|
|
834
1190
|
return row ? DeviceStatusSchema.parse(JSON.parse(String(row.status_json))) : null;
|
|
835
1191
|
}
|
|
836
1192
|
|
|
837
|
-
// apps/client/connection.ts
|
|
838
|
-
import { readFile, mkdir as mkdir2 } from "node:fs/promises";
|
|
839
|
-
import { createInterface } from "node:readline/promises";
|
|
840
|
-
|
|
841
|
-
// apps/client/root-lock.ts
|
|
842
|
-
import { createHash as createHash4 } from "node:crypto";
|
|
843
|
-
import { join as join2 } from "node:path";
|
|
844
|
-
|
|
845
|
-
// packages/state/sqlite.ts
|
|
846
|
-
import { chmodSync, mkdirSync } from "node:fs";
|
|
847
|
-
import { dirname as dirname2 } from "node:path";
|
|
848
|
-
import { DatabaseSync } from "node:sqlite";
|
|
849
|
-
function transaction(db, action) {
|
|
850
|
-
db.exec("BEGIN IMMEDIATE");
|
|
851
|
-
try {
|
|
852
|
-
const result = action();
|
|
853
|
-
db.exec("COMMIT");
|
|
854
|
-
return result;
|
|
855
|
-
} catch (error) {
|
|
856
|
-
db.exec("ROLLBACK");
|
|
857
|
-
throw error;
|
|
858
|
-
}
|
|
859
|
-
}
|
|
860
|
-
function openDatabase(path, migrations) {
|
|
861
|
-
mkdirSync(dirname2(path), { recursive: true, mode: 448 });
|
|
862
|
-
const db = new DatabaseSync(path);
|
|
863
|
-
try {
|
|
864
|
-
chmodSync(path, 384);
|
|
865
|
-
db.exec(
|
|
866
|
-
"PRAGMA busy_timeout = 5000; PRAGMA foreign_keys = ON; PRAGMA journal_mode = WAL; PRAGMA synchronous = FULL;"
|
|
867
|
-
);
|
|
868
|
-
transaction(db, () => {
|
|
869
|
-
const version = Number(db.prepare("PRAGMA user_version").get().user_version);
|
|
870
|
-
if (version > migrations.length)
|
|
871
|
-
throw new Error("Database was created by a newer Teammy version.");
|
|
872
|
-
for (let index = version; index < migrations.length; index++) {
|
|
873
|
-
db.exec(migrations[index]);
|
|
874
|
-
db.exec(`PRAGMA user_version = ${index + 1}`);
|
|
875
|
-
}
|
|
876
|
-
});
|
|
877
|
-
return db;
|
|
878
|
-
} catch (error) {
|
|
879
|
-
db.close();
|
|
880
|
-
throw error;
|
|
881
|
-
}
|
|
882
|
-
}
|
|
883
|
-
|
|
884
|
-
// apps/client/root-lock.ts
|
|
885
|
-
function acquireRootLock(root, stateDirectory) {
|
|
886
|
-
let lock;
|
|
887
|
-
try {
|
|
888
|
-
lock = openDatabase(
|
|
889
|
-
join2(stateDirectory, "locks", `${createHash4("sha256").update(root).digest("hex")}.sqlite3`),
|
|
890
|
-
[]
|
|
891
|
-
);
|
|
892
|
-
lock.exec("PRAGMA busy_timeout=0; BEGIN IMMEDIATE");
|
|
893
|
-
return lock;
|
|
894
|
-
} catch (error) {
|
|
895
|
-
lock?.close();
|
|
896
|
-
if (error instanceof Error && /locked|busy/.test(error.message))
|
|
897
|
-
throw new Error("Another sync or connect process is using this root.");
|
|
898
|
-
throw error;
|
|
899
|
-
}
|
|
900
|
-
}
|
|
901
|
-
|
|
902
|
-
// apps/client/connection.ts
|
|
903
|
-
async function connectProject(state, root, directory, options) {
|
|
904
|
-
const lock = acquireRootLock(root, directory);
|
|
905
|
-
try {
|
|
906
|
-
const transport = new HubClient(options.url);
|
|
907
|
-
const existing = state.binding(root);
|
|
908
|
-
const project = new ClientProjectState(state, root);
|
|
909
|
-
if (existing && existing.serverUrl !== transport.url)
|
|
910
|
-
throw new Error("This root is connected to another server.");
|
|
911
|
-
let member = options.member;
|
|
912
|
-
if (existing && !options.reconnect && !options.tokenFile) {
|
|
913
|
-
if (member && member !== existing.memberId)
|
|
914
|
-
throw new Error("This root belongs to a different member.");
|
|
915
|
-
const client2 = new HubClient(existing.serverUrl, 1e4, existing);
|
|
916
|
-
await client2.members();
|
|
917
|
-
} else if (options.tokenFile) {
|
|
918
|
-
const token = CredentialSchema.parse((await readFile(options.tokenFile, "utf8")).trim());
|
|
919
|
-
const deviceId = state.deviceFor(root);
|
|
920
|
-
const client2 = new HubClient(options.url, 1e4, { token, deviceId });
|
|
921
|
-
const binding2 = await client2.bind(deviceId);
|
|
922
|
-
state.saveBinding(root, client2.url, token, binding2);
|
|
923
|
-
} else {
|
|
924
|
-
member ??= existing?.memberId;
|
|
925
|
-
if (!member && process.stdin.isTTY) {
|
|
926
|
-
const rl = createInterface({ input: process.stdin, output: process.stderr });
|
|
927
|
-
try {
|
|
928
|
-
member = (await rl.question("Member ID: ")).trim();
|
|
929
|
-
} finally {
|
|
930
|
-
rl.close();
|
|
931
|
-
}
|
|
932
|
-
}
|
|
933
|
-
if (!member) throw new Error("--member is required when input is not interactive.");
|
|
934
|
-
NameSchema.parse(member);
|
|
935
|
-
if (existing && existing.memberId !== member)
|
|
936
|
-
throw new Error("This root belongs to a different member.");
|
|
937
|
-
const request = project.enrollment(transport.url, member, Boolean(options.reconnect));
|
|
938
|
-
const binding2 = await transport.enroll(request);
|
|
939
|
-
state.saveBinding(root, transport.url, request.credential, binding2);
|
|
940
|
-
project.completeEnrollment();
|
|
941
|
-
}
|
|
942
|
-
const binding = state.binding(root);
|
|
943
|
-
const client = new HubClient(binding.serverUrl, 1e4, binding);
|
|
944
|
-
const info = await client.serverInfo();
|
|
945
|
-
if (info.capabilities.includes("project-config")) {
|
|
946
|
-
try {
|
|
947
|
-
await applyProjectFile(project, await client.projectConfig());
|
|
948
|
-
} catch (error) {
|
|
949
|
-
if (!(options.tokenFile && error instanceof Error && "code" in error && ["SETUP_REQUIRED", "PROJECT_NOT_FOUND"].includes(String(error.code))))
|
|
950
|
-
throw error;
|
|
951
|
-
}
|
|
952
|
-
}
|
|
953
|
-
const own = await metadata(root, binding.memberId);
|
|
954
|
-
if (own && !own.directory) throw new Error("Member path is not a directory.");
|
|
955
|
-
if (!own) await mkdir2(await safePath(root, binding.memberId));
|
|
956
|
-
const { token: _, ...visible } = binding;
|
|
957
|
-
return { ...visible, configRevision: project.cached()?.config.configRevision ?? null };
|
|
958
|
-
} finally {
|
|
959
|
-
lock.close();
|
|
960
|
-
}
|
|
961
|
-
}
|
|
962
|
-
|
|
963
|
-
// apps/client/main.ts
|
|
964
|
-
import { parseArgs as parseArgs3 } from "node:util";
|
|
965
|
-
import { mkdir as mkdir7, realpath as realpath6 } from "node:fs/promises";
|
|
966
|
-
import { homedir as homedir3 } from "node:os";
|
|
967
|
-
import { join as join11, resolve as resolve6 } from "node:path";
|
|
968
|
-
|
|
969
|
-
// apps/client/state.ts
|
|
970
|
-
import { randomUUID as randomUUID3 } from "node:crypto";
|
|
971
|
-
import { join as join3 } from "node:path";
|
|
972
|
-
import { z as z8 } from "zod";
|
|
973
|
-
|
|
974
1193
|
// apps/client/sessions/state.ts
|
|
975
1194
|
var sessionClientMigration = String.raw`
|
|
976
1195
|
CREATE TABLE session_sources (
|
|
@@ -1109,7 +1328,8 @@ var SyncState = class {
|
|
|
1109
1328
|
generation: Number(row.generation),
|
|
1110
1329
|
observedAt: Number(row.observed_at),
|
|
1111
1330
|
hash: row.hash === null ? null : String(row.hash),
|
|
1112
|
-
verifiedAt: Number(row.verified_at)
|
|
1331
|
+
verifiedAt: Number(row.verified_at),
|
|
1332
|
+
seenLocal: Boolean(row.seen_local)
|
|
1113
1333
|
}));
|
|
1114
1334
|
}
|
|
1115
1335
|
audit(before, limit) {
|
|
@@ -1127,7 +1347,8 @@ var SyncState = class {
|
|
|
1127
1347
|
generation: Number(row.generation),
|
|
1128
1348
|
observedAt: Number(row.observed_at),
|
|
1129
1349
|
hash: row.hash === null ? null : String(row.hash),
|
|
1130
|
-
verifiedAt: Number(row.verified_at)
|
|
1350
|
+
verifiedAt: Number(row.verified_at),
|
|
1351
|
+
seenLocal: Boolean(row.seen_local)
|
|
1131
1352
|
} : void 0;
|
|
1132
1353
|
}
|
|
1133
1354
|
restartWaiting(now) {
|
|
@@ -1146,11 +1367,13 @@ var SyncState = class {
|
|
|
1146
1367
|
const prior = this.get(path);
|
|
1147
1368
|
if (!prior) {
|
|
1148
1369
|
if (signature2 !== null)
|
|
1149
|
-
this.db.prepare(
|
|
1370
|
+
this.db.prepare(
|
|
1371
|
+
"INSERT INTO file_index(root,path,signature,observed_at,seen_local) VALUES (?,?,?,?,1)"
|
|
1372
|
+
).run(this.root, path, signature2, now);
|
|
1150
1373
|
} else if (signature2 !== prior.signature) {
|
|
1151
1374
|
this.db.prepare(
|
|
1152
|
-
"UPDATE file_index SET signature=?, dirty=1, generation=generation+1, observed_at=?, event_pending=0 WHERE root=? AND path=?"
|
|
1153
|
-
).run(signature2, prior.eventPending ? prior.observedAt : now, this.root, path);
|
|
1375
|
+
"UPDATE file_index SET seen_local=MAX(seen_local, ? IS NOT NULL), signature=?, dirty=1, generation=generation+1, observed_at=?, event_pending=0 WHERE root=? AND path=?"
|
|
1376
|
+
).run(signature2, signature2, prior.eventPending ? prior.observedAt : now, this.root, path);
|
|
1154
1377
|
} else if (prior.eventPending) {
|
|
1155
1378
|
this.db.prepare("UPDATE file_index SET event_pending=0 WHERE root=? AND path=?").run(this.root, path);
|
|
1156
1379
|
}
|
|
@@ -1224,7 +1447,7 @@ var LocalBindingSchema = BindingSchema.extend({
|
|
|
1224
1447
|
var ClientState = class {
|
|
1225
1448
|
db;
|
|
1226
1449
|
constructor(directory) {
|
|
1227
|
-
this.db = openDatabase(
|
|
1450
|
+
this.db = openDatabase(join4(directory, "client.sqlite3"), [
|
|
1228
1451
|
String.raw`
|
|
1229
1452
|
CREATE TABLE connections (root TEXT PRIMARY KEY, device_id TEXT NOT NULL, binding_json TEXT);
|
|
1230
1453
|
`,
|
|
@@ -1236,13 +1459,27 @@ var ClientState = class {
|
|
|
1236
1459
|
syncMigration,
|
|
1237
1460
|
sessionClientMigration,
|
|
1238
1461
|
projectClientMigration,
|
|
1239
|
-
controlClientMigration
|
|
1462
|
+
controlClientMigration,
|
|
1463
|
+
runtimeMigration
|
|
1240
1464
|
]);
|
|
1241
1465
|
}
|
|
1242
1466
|
close() {
|
|
1243
1467
|
this.db.close();
|
|
1244
1468
|
}
|
|
1469
|
+
assertRootAvailable(root) {
|
|
1470
|
+
for (const row of this.db.prepare("SELECT root FROM connections").all()) {
|
|
1471
|
+
const other = String(row.root);
|
|
1472
|
+
if (root === other) continue;
|
|
1473
|
+
const contains = (parent, child) => {
|
|
1474
|
+
const path = relative2(parent, child);
|
|
1475
|
+
return path === "" || !isAbsolute(path) && path !== ".." && !path.startsWith(`..${sep2}`);
|
|
1476
|
+
};
|
|
1477
|
+
if (contains(root, other) || contains(other, root))
|
|
1478
|
+
throw new Error("Sync roots must not overlap. Use separate project directories.");
|
|
1479
|
+
}
|
|
1480
|
+
}
|
|
1245
1481
|
deviceFor(root) {
|
|
1482
|
+
this.assertRootAvailable(root);
|
|
1246
1483
|
this.db.prepare("INSERT OR IGNORE INTO connections (root, device_id) VALUES (?, ?)").run(root, randomUUID3());
|
|
1247
1484
|
return String(
|
|
1248
1485
|
this.db.prepare("SELECT device_id FROM connections WHERE root=?").get(root).device_id
|
|
@@ -1253,6 +1490,7 @@ var ClientState = class {
|
|
|
1253
1490
|
return row?.binding_json ? LocalBindingSchema.parse(JSON.parse(String(row.binding_json))) : void 0;
|
|
1254
1491
|
}
|
|
1255
1492
|
saveBinding(root, serverUrl, token, binding) {
|
|
1493
|
+
this.assertRootAvailable(root);
|
|
1256
1494
|
const prior = this.binding(root);
|
|
1257
1495
|
if (prior && (prior.workspaceId !== binding.workspaceId || prior.memberId !== binding.memberId || prior.serverUrl !== serverUrl)) {
|
|
1258
1496
|
throw new Error(
|
|
@@ -1306,10 +1544,10 @@ var ClientState = class {
|
|
|
1306
1544
|
this.db.prepare("INSERT INTO outbox(root, request_json) VALUES (?, ?)").run(root, JSON.stringify(fixed));
|
|
1307
1545
|
});
|
|
1308
1546
|
}
|
|
1309
|
-
acknowledge(root,
|
|
1547
|
+
acknowledge(root, result2) {
|
|
1310
1548
|
transaction(this.db, () => {
|
|
1311
1549
|
const pending = this.pending(root);
|
|
1312
|
-
if (!pending || pending.batch.operationId !==
|
|
1550
|
+
if (!pending || pending.batch.operationId !== result2.operationId || result2.revision < pending.batch.parentRevision || result2.revision > pending.batch.parentRevision + 1)
|
|
1313
1551
|
throw new Error("Unexpected publication acknowledgement.");
|
|
1314
1552
|
for (const change of pending.batch.changes) {
|
|
1315
1553
|
if (change.kind === "delete")
|
|
@@ -1319,7 +1557,7 @@ var ClientState = class {
|
|
|
1319
1557
|
"INSERT INTO owner_files VALUES (?, ?, ?, ?) ON CONFLICT(root, path) DO UPDATE SET hash=excluded.hash, size=excluded.size"
|
|
1320
1558
|
).run(root, change.path, change.sha256, change.size);
|
|
1321
1559
|
}
|
|
1322
|
-
this.db.prepare("UPDATE publications SET revision=? WHERE root=?").run(
|
|
1560
|
+
this.db.prepare("UPDATE publications SET revision=? WHERE root=?").run(result2.revision, root);
|
|
1323
1561
|
this.db.prepare("DELETE FROM outbox WHERE root=?").run(root);
|
|
1324
1562
|
});
|
|
1325
1563
|
}
|
|
@@ -1334,22 +1572,15 @@ var ClientState = class {
|
|
|
1334
1572
|
}
|
|
1335
1573
|
};
|
|
1336
1574
|
|
|
1337
|
-
// apps/client/sessions/command.ts
|
|
1338
|
-
import { createHash as createHash6 } from "node:crypto";
|
|
1339
|
-
import { realpath as realpath3 } from "node:fs/promises";
|
|
1340
|
-
import { homedir } from "node:os";
|
|
1341
|
-
import { dirname as dirname3, join as join5, resolve as resolve2 } from "node:path";
|
|
1342
|
-
import { parseArgs } from "node:util";
|
|
1343
|
-
|
|
1344
1575
|
// apps/client/state-location.ts
|
|
1345
|
-
import { mkdir as mkdir3, realpath as
|
|
1346
|
-
import { isAbsolute, relative as
|
|
1576
|
+
import { mkdir as mkdir3, realpath as realpath3 } from "node:fs/promises";
|
|
1577
|
+
import { isAbsolute as isAbsolute2, relative as relative3, resolve as resolve2, sep as sep3 } from "node:path";
|
|
1347
1578
|
async function stateLocation(root, directory) {
|
|
1348
|
-
await mkdir3(
|
|
1349
|
-
const canonical = await
|
|
1579
|
+
await mkdir3(resolve2(directory), { recursive: true, mode: 448 });
|
|
1580
|
+
const canonical = await realpath3(directory);
|
|
1350
1581
|
const contains = (parent, child) => {
|
|
1351
|
-
const path =
|
|
1352
|
-
return path === "" || !
|
|
1582
|
+
const path = relative3(parent, child);
|
|
1583
|
+
return path === "" || !isAbsolute2(path) && path !== ".." && !path.startsWith(`..${sep3}`);
|
|
1353
1584
|
};
|
|
1354
1585
|
if (contains(root, canonical) || contains(canonical, root))
|
|
1355
1586
|
throw new Error(
|
|
@@ -1361,7 +1592,7 @@ async function stateLocation(root, directory) {
|
|
|
1361
1592
|
// apps/client/sessions/hooks.ts
|
|
1362
1593
|
import { createHash as createHash5, randomUUID as randomUUID4 } from "node:crypto";
|
|
1363
1594
|
import { mkdir as mkdir4, open as open2, opendir, rename as rename2, unlink as unlink2 } from "node:fs/promises";
|
|
1364
|
-
import { isAbsolute as
|
|
1595
|
+
import { isAbsolute as isAbsolute3, join as join5 } from "node:path";
|
|
1365
1596
|
import { z as z9 } from "zod";
|
|
1366
1597
|
|
|
1367
1598
|
// apps/client/sessions/normalize.ts
|
|
@@ -1482,17 +1713,17 @@ function normalizeJsonl(agent, text) {
|
|
|
1482
1713
|
// apps/client/sessions/hooks.ts
|
|
1483
1714
|
var HintSchema = z9.object({
|
|
1484
1715
|
agent: AgentSchema,
|
|
1485
|
-
path: z9.string().min(1).max(4096).refine(
|
|
1486
|
-
projectDir: z9.string().min(1).max(4096).refine(
|
|
1716
|
+
path: z9.string().min(1).max(4096).refine(isAbsolute3),
|
|
1717
|
+
projectDir: z9.string().min(1).max(4096).refine(isAbsolute3)
|
|
1487
1718
|
});
|
|
1488
1719
|
function inbox(directory, root) {
|
|
1489
|
-
return
|
|
1720
|
+
return join5(directory, "session-hints", createHash5("sha256").update(root).digest("hex"));
|
|
1490
1721
|
}
|
|
1491
1722
|
async function recordHint(directory, root, input) {
|
|
1492
1723
|
const hint = HintSchema.parse(input);
|
|
1493
1724
|
const targetDir = inbox(directory, root);
|
|
1494
1725
|
await mkdir4(targetDir, { recursive: true, mode: 448 });
|
|
1495
|
-
const target =
|
|
1726
|
+
const target = join5(
|
|
1496
1727
|
targetDir,
|
|
1497
1728
|
`${createHash5("sha256").update(JSON.stringify(hint)).digest("hex")}.json`
|
|
1498
1729
|
);
|
|
@@ -1518,7 +1749,7 @@ async function consumeHints(directory, root, sessions) {
|
|
|
1518
1749
|
for await (const entry of dir) {
|
|
1519
1750
|
if (!entry.isFile() || !entry.name.endsWith(".json")) continue;
|
|
1520
1751
|
if (count++ >= 50) break;
|
|
1521
|
-
const path =
|
|
1752
|
+
const path = join5(inbox(directory, root), entry.name);
|
|
1522
1753
|
try {
|
|
1523
1754
|
const handle = await open2(path, "r");
|
|
1524
1755
|
let body;
|
|
@@ -1584,8 +1815,8 @@ async function runSessionCommand(args) {
|
|
|
1584
1815
|
const { values } = parseArgs({
|
|
1585
1816
|
args: args.slice(1),
|
|
1586
1817
|
options: {
|
|
1587
|
-
root: { type: "string", default:
|
|
1588
|
-
"state-dir": { type: "string", default:
|
|
1818
|
+
root: { type: "string", default: resolve3("workspaces") },
|
|
1819
|
+
"state-dir": { type: "string", default: join6(homedir(), ".teammy", "client") },
|
|
1589
1820
|
agent: { type: "string" },
|
|
1590
1821
|
file: { type: "string" },
|
|
1591
1822
|
directory: { type: "string" },
|
|
@@ -1604,16 +1835,16 @@ async function runSessionCommand(args) {
|
|
|
1604
1835
|
process.stdout.write(help);
|
|
1605
1836
|
return 0;
|
|
1606
1837
|
}
|
|
1607
|
-
const root = await
|
|
1838
|
+
const root = await realpath4(resolve3(values.root));
|
|
1608
1839
|
const directory = await stateLocation(root, values["state-dir"]);
|
|
1609
|
-
const projectDir = await
|
|
1840
|
+
const projectDir = await realpath4(resolve3(values["project-dir"] ?? dirname4(root)));
|
|
1610
1841
|
if (command === "hook") {
|
|
1611
1842
|
const payload = values.file ? {} : await hookPayload();
|
|
1612
1843
|
const file = values.file ?? payload.transcript_path ?? payload.session_file ?? payload.sessionFile;
|
|
1613
1844
|
if (typeof file === "string" && file)
|
|
1614
1845
|
await recordHint(directory, root, {
|
|
1615
1846
|
agent: AgentSchema.parse(values.agent),
|
|
1616
|
-
path:
|
|
1847
|
+
path: resolve3(file),
|
|
1617
1848
|
projectDir
|
|
1618
1849
|
});
|
|
1619
1850
|
return 0;
|
|
@@ -1627,7 +1858,7 @@ async function runSessionCommand(args) {
|
|
|
1627
1858
|
let output;
|
|
1628
1859
|
if (command === "track" || command === "untrack") {
|
|
1629
1860
|
if (!values.file) throw new Error("--file is required.");
|
|
1630
|
-
const path =
|
|
1861
|
+
const path = resolve3(values.file);
|
|
1631
1862
|
if (command === "track")
|
|
1632
1863
|
sessions.track(
|
|
1633
1864
|
AgentSchema.parse(values.agent),
|
|
@@ -1640,23 +1871,23 @@ async function runSessionCommand(args) {
|
|
|
1640
1871
|
} else if (command === "discover" || command === "undiscover") {
|
|
1641
1872
|
if (command === "undiscover") {
|
|
1642
1873
|
if (!values.directory) throw new Error("--directory is required.");
|
|
1643
|
-
state.db.prepare("DELETE FROM session_discovery WHERE root=? AND directory=?").run(root, await
|
|
1874
|
+
state.db.prepare("DELETE FROM session_discovery WHERE root=? AND directory=?").run(root, await realpath4(resolve3(values.directory)));
|
|
1644
1875
|
} else {
|
|
1645
1876
|
const agent = AgentSchema.parse(values.agent);
|
|
1646
1877
|
const defaults = {
|
|
1647
|
-
"claude-code":
|
|
1648
|
-
codex:
|
|
1649
|
-
cursor:
|
|
1650
|
-
process.env.CURSOR_CONFIG_DIR ??
|
|
1878
|
+
"claude-code": join6(homedir(), ".claude", "projects"),
|
|
1879
|
+
codex: join6(process.env.CODEX_HOME ?? join6(homedir(), ".codex"), "sessions"),
|
|
1880
|
+
cursor: join6(
|
|
1881
|
+
process.env.CURSOR_CONFIG_DIR ?? join6(homedir(), ".cursor"),
|
|
1651
1882
|
"chats",
|
|
1652
1883
|
createHash6("md5").update(projectDir).digest("hex")
|
|
1653
1884
|
),
|
|
1654
|
-
pi:
|
|
1885
|
+
pi: join6(process.env.PI_CODING_AGENT_DIR ?? join6(homedir(), ".pi", "agent"), "sessions"),
|
|
1655
1886
|
dsh: void 0
|
|
1656
1887
|
};
|
|
1657
1888
|
const path = values.directory ?? defaults[agent];
|
|
1658
1889
|
if (!path) throw new Error("DSH requires its configured persistence --directory.");
|
|
1659
|
-
sessions.discover(agent, await
|
|
1890
|
+
sessions.discover(agent, await realpath4(resolve3(path)), projectDir);
|
|
1660
1891
|
}
|
|
1661
1892
|
output = sessions.directories();
|
|
1662
1893
|
} else if (command === "sources")
|
|
@@ -1691,32 +1922,482 @@ async function runSessionCommand(args) {
|
|
|
1691
1922
|
}
|
|
1692
1923
|
|
|
1693
1924
|
// apps/client/sync-command.ts
|
|
1694
|
-
import {
|
|
1695
|
-
import {
|
|
1696
|
-
|
|
1697
|
-
|
|
1925
|
+
import { parseArgs as parseArgs3 } from "node:util";
|
|
1926
|
+
import { setTimeout as setTimeout4 } from "node:timers/promises";
|
|
1927
|
+
|
|
1928
|
+
// apps/client/client-command.ts
|
|
1698
1929
|
import { parseArgs as parseArgs2 } from "node:util";
|
|
1930
|
+
import { join as join10, resolve as resolve5 } from "node:path";
|
|
1931
|
+
import { homedir as homedir3 } from "node:os";
|
|
1932
|
+
import { createInterface as createInterface2 } from "node:readline/promises";
|
|
1699
1933
|
|
|
1700
|
-
// apps/client/
|
|
1701
|
-
import {
|
|
1702
|
-
import {
|
|
1934
|
+
// apps/client/connection.ts
|
|
1935
|
+
import { readFile, mkdir as mkdir5 } from "node:fs/promises";
|
|
1936
|
+
import { createInterface } from "node:readline/promises";
|
|
1703
1937
|
|
|
1704
|
-
//
|
|
1938
|
+
// apps/client/root-lock.ts
|
|
1705
1939
|
import { createHash as createHash7 } from "node:crypto";
|
|
1706
|
-
|
|
1707
|
-
|
|
1940
|
+
import { join as join7 } from "node:path";
|
|
1941
|
+
function acquireRootLock(root, stateDirectory) {
|
|
1942
|
+
let lock;
|
|
1943
|
+
try {
|
|
1944
|
+
lock = openDatabase(
|
|
1945
|
+
join7(stateDirectory, "locks", `${createHash7("sha256").update(root).digest("hex")}.sqlite3`),
|
|
1946
|
+
[],
|
|
1947
|
+
0
|
|
1948
|
+
);
|
|
1949
|
+
lock.exec("PRAGMA busy_timeout=0; BEGIN IMMEDIATE");
|
|
1950
|
+
return lock;
|
|
1951
|
+
} catch (error) {
|
|
1952
|
+
lock?.close();
|
|
1953
|
+
if (error instanceof Error && /locked|busy/.test(error.message))
|
|
1954
|
+
throw new Error("Another sync or connect process is using this root.");
|
|
1955
|
+
throw error;
|
|
1956
|
+
}
|
|
1708
1957
|
}
|
|
1709
1958
|
|
|
1710
|
-
//
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
|
|
1959
|
+
// apps/client/connection.ts
|
|
1960
|
+
async function connectProject(state, root, directory, options) {
|
|
1961
|
+
const lock = acquireRootLock(root, directory);
|
|
1962
|
+
try {
|
|
1963
|
+
state.assertRootAvailable(root);
|
|
1964
|
+
const transport = new HubClient(options.url);
|
|
1965
|
+
const existing = state.binding(root);
|
|
1966
|
+
const project = new ClientProjectState(state, root);
|
|
1967
|
+
if (existing && existing.serverUrl !== transport.url)
|
|
1968
|
+
throw new Error("This root is connected to another server.");
|
|
1969
|
+
if (existing && options.project && options.project !== existing.workspaceId)
|
|
1970
|
+
throw new Error("This root belongs to a different project. Use a different local root.");
|
|
1971
|
+
const info = await transport.serverInfo();
|
|
1972
|
+
if (!info.capabilities.includes("multi-project"))
|
|
1973
|
+
throw new Error(
|
|
1974
|
+
"This client requires a multi-project server. Update the server before connecting."
|
|
1975
|
+
);
|
|
1976
|
+
let member = options.member;
|
|
1977
|
+
if (existing && !options.reconnect && !options.tokenFile) {
|
|
1978
|
+
if (member && member !== existing.memberId)
|
|
1979
|
+
throw new Error("This root belongs to a different member.");
|
|
1980
|
+
const client2 = new HubClient(existing.serverUrl, 1e4, existing);
|
|
1981
|
+
await client2.members();
|
|
1982
|
+
} else if (options.tokenFile) {
|
|
1983
|
+
const token = CredentialSchema.parse((await readFile(options.tokenFile, "utf8")).trim());
|
|
1984
|
+
const deviceId = state.deviceFor(root);
|
|
1985
|
+
const client2 = new HubClient(options.url, 1e4, { token, deviceId });
|
|
1986
|
+
const binding2 = await client2.bind(deviceId, options.project ?? existing?.workspaceId);
|
|
1987
|
+
if (options.project && binding2.workspaceId !== options.project)
|
|
1988
|
+
throw new Error("Token belongs to a different project.");
|
|
1989
|
+
state.saveBinding(root, client2.url, token, binding2);
|
|
1990
|
+
} else {
|
|
1991
|
+
member ??= existing?.memberId;
|
|
1992
|
+
if (!member && process.stdin.isTTY) {
|
|
1993
|
+
const rl = createInterface({ input: process.stdin, output: process.stderr });
|
|
1994
|
+
try {
|
|
1995
|
+
member = (await rl.question("Member ID: ")).trim();
|
|
1996
|
+
} finally {
|
|
1997
|
+
rl.close();
|
|
1998
|
+
}
|
|
1999
|
+
}
|
|
2000
|
+
if (!member) throw new Error("--member is required when input is not interactive.");
|
|
2001
|
+
NameSchema.parse(member);
|
|
2002
|
+
if (existing && existing.memberId !== member)
|
|
2003
|
+
throw new Error("This root belongs to a different member.");
|
|
2004
|
+
const projectId = options.project ?? existing?.workspaceId;
|
|
2005
|
+
if (!projectId)
|
|
2006
|
+
throw new Error(
|
|
2007
|
+
"--project is required for a new connection; copy the command from the project settings."
|
|
2008
|
+
);
|
|
2009
|
+
NameSchema.parse(projectId);
|
|
2010
|
+
const request = project.enrollment(
|
|
2011
|
+
transport.url,
|
|
2012
|
+
member,
|
|
2013
|
+
projectId,
|
|
2014
|
+
Boolean(options.reconnect)
|
|
2015
|
+
);
|
|
2016
|
+
const binding2 = await transport.enroll(request);
|
|
2017
|
+
state.saveBinding(root, transport.url, request.credential, binding2);
|
|
2018
|
+
project.completeEnrollment();
|
|
2019
|
+
}
|
|
2020
|
+
const binding = state.binding(root);
|
|
2021
|
+
const client = new HubClient(binding.serverUrl, 1e4, binding);
|
|
2022
|
+
if (info.capabilities.includes("project-config")) {
|
|
2023
|
+
try {
|
|
2024
|
+
await applyProjectFile(project, await client.projectConfig());
|
|
2025
|
+
} catch (error) {
|
|
2026
|
+
if (!(options.tokenFile && error instanceof Error && "code" in error && ["SETUP_REQUIRED", "PROJECT_NOT_FOUND"].includes(String(error.code))))
|
|
2027
|
+
throw error;
|
|
2028
|
+
}
|
|
2029
|
+
}
|
|
2030
|
+
const own = await metadata(root, binding.memberId);
|
|
2031
|
+
if (own && !own.directory) throw new Error("Member path is not a directory.");
|
|
2032
|
+
if (!own) await mkdir5(await safePath(root, binding.memberId));
|
|
2033
|
+
const { token: _, ...visible } = binding;
|
|
2034
|
+
return { ...visible, configRevision: project.cached()?.config.configRevision ?? null };
|
|
2035
|
+
} finally {
|
|
2036
|
+
lock.close();
|
|
2037
|
+
}
|
|
2038
|
+
}
|
|
2039
|
+
|
|
2040
|
+
// apps/client/runtime/ipc.ts
|
|
2041
|
+
import { createConnection } from "node:net";
|
|
2042
|
+
import { mkdir as mkdir6, lstat as lstat3, chmod } from "node:fs/promises";
|
|
2043
|
+
import { join as join8 } from "node:path";
|
|
2044
|
+
import { z as z10 } from "zod";
|
|
2045
|
+
var RequestSchema = z10.object({
|
|
2046
|
+
method: z10.enum(["ping", "wake", "status", "once"]),
|
|
2047
|
+
root: z10.string().max(4096).optional()
|
|
2048
|
+
}).strict();
|
|
2049
|
+
function socketPath(directory) {
|
|
2050
|
+
return join8(
|
|
2051
|
+
"/tmp",
|
|
2052
|
+
`teammy-${process.getuid?.() ?? "user"}-${rootKey(directory)}`,
|
|
2053
|
+
"control.sock"
|
|
2054
|
+
);
|
|
2055
|
+
}
|
|
2056
|
+
async function prepareSocketDirectory(directory) {
|
|
2057
|
+
const path = join8(socketPath(directory), "..");
|
|
2058
|
+
await mkdir6(path, { recursive: true, mode: 448 });
|
|
2059
|
+
const stat2 = await lstat3(path);
|
|
2060
|
+
if (!stat2.isDirectory() || stat2.isSymbolicLink() || stat2.uid !== process.getuid?.())
|
|
2061
|
+
throw new Error("Unsafe daemon socket directory.");
|
|
2062
|
+
await chmod(path, 448);
|
|
2063
|
+
}
|
|
2064
|
+
async function daemonRequest(directory, input, timeout = 15e3) {
|
|
2065
|
+
RequestSchema.parse(input);
|
|
2066
|
+
return new Promise((resolve9, reject) => {
|
|
2067
|
+
const socket = createConnection(socketPath(directory));
|
|
2068
|
+
socket.setEncoding("utf8");
|
|
2069
|
+
let body = "";
|
|
2070
|
+
let done = false;
|
|
2071
|
+
const finish = (error, result2) => {
|
|
2072
|
+
if (done) return;
|
|
2073
|
+
done = true;
|
|
2074
|
+
socket.destroy();
|
|
2075
|
+
if (error) reject(error);
|
|
2076
|
+
else resolve9(result2);
|
|
2077
|
+
};
|
|
2078
|
+
socket.setTimeout(
|
|
2079
|
+
timeout,
|
|
2080
|
+
() => finish(new Error("Background service did not respond. Run teammy status."))
|
|
2081
|
+
);
|
|
2082
|
+
socket.on("error", (e) => finish(e));
|
|
2083
|
+
socket.on("end", () => {
|
|
2084
|
+
if (!done)
|
|
2085
|
+
finish(
|
|
2086
|
+
Object.assign(new Error("Background service disconnected."), { code: "ECONNRESET" })
|
|
2087
|
+
);
|
|
2088
|
+
});
|
|
2089
|
+
socket.on("connect", () => socket.write(JSON.stringify(input) + "\n"));
|
|
2090
|
+
socket.on("data", (chunk) => {
|
|
2091
|
+
body += chunk.toString();
|
|
2092
|
+
if (body.length > 256 * 1024) return finish(new Error("Oversized daemon response."));
|
|
2093
|
+
if (!body.includes("\n")) return;
|
|
2094
|
+
try {
|
|
2095
|
+
const response = JSON.parse(body.split("\n")[0]);
|
|
2096
|
+
if (response.ok) finish(void 0, response.result);
|
|
2097
|
+
else finish(new Error(response.error ?? "Background operation failed."));
|
|
2098
|
+
} catch (e) {
|
|
2099
|
+
finish(e);
|
|
2100
|
+
}
|
|
2101
|
+
});
|
|
2102
|
+
});
|
|
2103
|
+
}
|
|
2104
|
+
async function runningDaemon(directory) {
|
|
2105
|
+
try {
|
|
2106
|
+
return await daemonRequest(directory, { method: "ping" }, 1e3);
|
|
2107
|
+
} catch (error) {
|
|
2108
|
+
if (["ENOENT", "ECONNREFUSED", "ECONNRESET", "EPIPE"].includes(
|
|
2109
|
+
String(error.code)
|
|
2110
|
+
))
|
|
2111
|
+
return null;
|
|
2112
|
+
throw error;
|
|
2113
|
+
}
|
|
2114
|
+
}
|
|
2115
|
+
|
|
2116
|
+
// apps/client/runtime/service.ts
|
|
2117
|
+
import { execFile as execFile2 } from "node:child_process";
|
|
2118
|
+
import { promisify as promisify2 } from "node:util";
|
|
2119
|
+
import { homedir as homedir2 } from "node:os";
|
|
2120
|
+
import { dirname as dirname5, join as join9, resolve as resolve4 } from "node:path";
|
|
2121
|
+
import { mkdir as mkdir7, readFile as readFile2, writeFile, rename as rename3 } from "node:fs/promises";
|
|
2122
|
+
import { setTimeout as setTimeout3 } from "node:timers/promises";
|
|
2123
|
+
var execute = promisify2(execFile2);
|
|
2124
|
+
var exec2 = (file, args, options = {}) => execute(file, args, { timeout: 45e3, ...options });
|
|
2125
|
+
var xml = (s) => s.replace(
|
|
2126
|
+
/[<>&"']/g,
|
|
2127
|
+
(c) => ({ "<": "<", ">": ">", "&": "&", '"': """, "'": "'" })[c]
|
|
2128
|
+
);
|
|
2129
|
+
var unit = (s) => '"' + s.replace(/%/g, "%%").replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\$/g, () => "$$") + '"';
|
|
2130
|
+
function serviceDefinition(directory, executable, entry, platform = process.platform, userHome = homedir2()) {
|
|
2131
|
+
for (const value of [directory, executable, entry])
|
|
2132
|
+
if (/[\r\n\0]/.test(value)) throw new Error("Invalid service path.");
|
|
2133
|
+
const id = `com.teammy.client.${rootKey(directory)}`;
|
|
2134
|
+
const args = [executable, entry, "_daemon", "--state-dir", directory];
|
|
2135
|
+
if (platform === "darwin")
|
|
2136
|
+
return {
|
|
2137
|
+
id,
|
|
2138
|
+
path: join9(userHome, "Library", "LaunchAgents", id + ".plist"),
|
|
2139
|
+
content: `<?xml version="1.0" encoding="UTF-8"?>
|
|
2140
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
2141
|
+
<plist version="1.0"><dict><key>Label</key><string>${id}</string><key>ProgramArguments</key><array>${args.map((a) => `<string>${xml(a)}</string>`).join("")}</array><key>RunAtLoad</key><true/><key>KeepAlive</key><true/><key>ThrottleInterval</key><integer>30</integer><key>WorkingDirectory</key><string>${xml(userHome)}</string></dict></plist>
|
|
2142
|
+
`
|
|
2143
|
+
};
|
|
2144
|
+
if (platform === "linux")
|
|
2145
|
+
return {
|
|
2146
|
+
id: id + ".service",
|
|
2147
|
+
path: join9(userHome, ".config", "systemd", "user", id + ".service"),
|
|
2148
|
+
content: `[Unit]
|
|
2149
|
+
Description=Teammy background synchronization
|
|
2150
|
+
[Service]
|
|
2151
|
+
Type=simple
|
|
2152
|
+
ExecStart=${args.map(unit).join(" ")}
|
|
2153
|
+
Restart=always
|
|
2154
|
+
RestartSec=30
|
|
2155
|
+
UMask=0077
|
|
2156
|
+
StandardOutput=null
|
|
2157
|
+
StandardError=journal
|
|
2158
|
+
[Install]
|
|
2159
|
+
WantedBy=default.target
|
|
2160
|
+
`
|
|
2161
|
+
};
|
|
2162
|
+
throw new Error(
|
|
2163
|
+
"Automatic background sync currently supports macOS and Linux. Use --no-start with sync --foreground on other systems."
|
|
2164
|
+
);
|
|
2165
|
+
}
|
|
2166
|
+
async function checkServiceSupport() {
|
|
2167
|
+
try {
|
|
2168
|
+
if (process.platform === "darwin")
|
|
2169
|
+
await exec2("launchctl", ["print", `gui/${process.getuid()}`], {
|
|
2170
|
+
maxBuffer: 4 * 1024 * 1024
|
|
2171
|
+
});
|
|
2172
|
+
else if (process.platform === "linux") await exec2("systemctl", ["--user", "show-environment"]);
|
|
2173
|
+
else throw new Error("unsupported");
|
|
2174
|
+
} catch {
|
|
2175
|
+
throw new Error(
|
|
2176
|
+
"A login-session service manager is required (macOS launchd or Linux systemd --user). Use --no-start and sync --foreground where unavailable."
|
|
2177
|
+
);
|
|
2178
|
+
}
|
|
2179
|
+
}
|
|
2180
|
+
async function bootstrapLaunchAgent(path) {
|
|
2181
|
+
const deadline = Date.now() + 1e4;
|
|
2182
|
+
for (; ; ) {
|
|
2183
|
+
try {
|
|
2184
|
+
await exec2("launchctl", ["bootstrap", `gui/${process.getuid()}`, path]);
|
|
2185
|
+
return;
|
|
2186
|
+
} catch (error) {
|
|
2187
|
+
if (error.code !== 5 || Date.now() >= deadline) throw error;
|
|
2188
|
+
await setTimeout3(250);
|
|
2189
|
+
}
|
|
2190
|
+
}
|
|
2191
|
+
}
|
|
2192
|
+
async function ensureDaemon(directory, version) {
|
|
2193
|
+
const definition = serviceDefinition(directory, process.execPath, resolve4(process.argv[1]));
|
|
2194
|
+
let old = "";
|
|
2195
|
+
try {
|
|
2196
|
+
old = await readFile2(definition.path, "utf8");
|
|
2197
|
+
} catch (e) {
|
|
2198
|
+
if (e.code !== "ENOENT") throw e;
|
|
2199
|
+
}
|
|
2200
|
+
const alive = await runningDaemon(directory);
|
|
2201
|
+
if (alive && old === definition.content && alive.version === version) return;
|
|
2202
|
+
await checkServiceSupport();
|
|
2203
|
+
await mkdir7(dirname5(definition.path), { recursive: true });
|
|
2204
|
+
if (old !== definition.content) {
|
|
2205
|
+
const temp = definition.path + `.${process.pid}.tmp`;
|
|
2206
|
+
await writeFile(temp, definition.content, { mode: 384 });
|
|
2207
|
+
await rename3(temp, definition.path);
|
|
2208
|
+
}
|
|
2209
|
+
if (process.platform === "darwin") {
|
|
2210
|
+
const domain = `gui/${process.getuid()}`, target = `${domain}/${definition.id}`;
|
|
2211
|
+
let loaded = false;
|
|
2212
|
+
try {
|
|
2213
|
+
await exec2("launchctl", ["print", target]);
|
|
2214
|
+
loaded = true;
|
|
2215
|
+
} catch {
|
|
2216
|
+
}
|
|
2217
|
+
if (loaded && (old !== definition.content || alive?.version !== version)) {
|
|
2218
|
+
await exec2("launchctl", ["bootout", target]);
|
|
2219
|
+
loaded = false;
|
|
2220
|
+
}
|
|
2221
|
+
if (!loaded) await bootstrapLaunchAgent(definition.path);
|
|
2222
|
+
await exec2("launchctl", ["kickstart", target]);
|
|
2223
|
+
} else {
|
|
2224
|
+
await exec2("systemctl", ["--user", "daemon-reload"]);
|
|
2225
|
+
await exec2("systemctl", ["--user", "enable", definition.id]);
|
|
2226
|
+
await exec2("systemctl", [
|
|
2227
|
+
"--user",
|
|
2228
|
+
old !== definition.content || alive?.version !== version ? "restart" : "start",
|
|
2229
|
+
definition.id
|
|
2230
|
+
]);
|
|
2231
|
+
}
|
|
2232
|
+
const deadline = Date.now() + 15e3;
|
|
2233
|
+
while (Date.now() < deadline) {
|
|
2234
|
+
if ((await runningDaemon(directory))?.version === version) return;
|
|
2235
|
+
await setTimeout3(150);
|
|
2236
|
+
}
|
|
2237
|
+
throw new Error(
|
|
2238
|
+
"Background service did not start. Run teammy logs and check the user service manager."
|
|
2239
|
+
);
|
|
2240
|
+
}
|
|
2241
|
+
async function wakeDaemon(directory, version) {
|
|
2242
|
+
await ensureDaemon(directory, version);
|
|
2243
|
+
await daemonRequest(directory, { method: "wake" });
|
|
2244
|
+
}
|
|
2245
|
+
|
|
2246
|
+
// apps/client/output.ts
|
|
2247
|
+
var styled = () => Boolean(
|
|
2248
|
+
process.stdout.isTTY && process.env.NO_COLOR === void 0 && process.env.TERM !== "dumb"
|
|
2249
|
+
);
|
|
2250
|
+
function bold(value) {
|
|
2251
|
+
const clean = redact(value);
|
|
2252
|
+
return styled() ? `\x1B[1m${clean}\x1B[22m` : clean;
|
|
2253
|
+
}
|
|
2254
|
+
function result(title, detail) {
|
|
2255
|
+
process.stdout.write(`${bold(title)}${detail ? `
|
|
2256
|
+
${redact(detail)}` : ""}
|
|
2257
|
+
`);
|
|
2258
|
+
}
|
|
2259
|
+
function json(value) {
|
|
2260
|
+
process.stdout.write(JSON.stringify(value, null, 2) + "\n");
|
|
2261
|
+
}
|
|
2262
|
+
|
|
2263
|
+
// apps/client/client-command.ts
|
|
2264
|
+
var locationOptions = {
|
|
2265
|
+
root: { type: "string", default: resolve5("workspaces") },
|
|
2266
|
+
"state-dir": { type: "string", default: join10(homedir3(), ".teammy", "client") },
|
|
2267
|
+
json: { type: "boolean" },
|
|
2268
|
+
help: { type: "boolean", short: "h" }
|
|
2269
|
+
};
|
|
2270
|
+
async function runClientCommand(command, args, version) {
|
|
2271
|
+
const { values } = parseArgs2({
|
|
2272
|
+
args,
|
|
2273
|
+
options: {
|
|
2274
|
+
...locationOptions,
|
|
2275
|
+
url: { type: "string" },
|
|
2276
|
+
project: { type: "string" },
|
|
2277
|
+
member: { type: "string" },
|
|
2278
|
+
"token-file": { type: "string" },
|
|
2279
|
+
reconnect: { type: "boolean" },
|
|
2280
|
+
"no-start": { type: "boolean" },
|
|
2281
|
+
all: { type: "boolean" }
|
|
2282
|
+
}
|
|
2283
|
+
});
|
|
2284
|
+
if (values.help) {
|
|
2285
|
+
process.stdout.write(
|
|
2286
|
+
command === "connect" ? "Usage: teammy connect --url URL --project ID --member ID [--root DIR]\n\nCreates ./workspaces, adds Git ignore rules and starts login-session background sync.\nAdvanced: --no-start, --reconnect (stop sync first), --token-file FILE, --state-dir DIR, --json\n" : `Usage: teammy ${command} [--root DIR] [--state-dir DIR] [--json]${command === "logs" ? " [--all]" : ""}
|
|
2287
|
+
`
|
|
2288
|
+
);
|
|
2289
|
+
return 0;
|
|
2290
|
+
}
|
|
2291
|
+
const root = await workspacePath(values.root), directory = await stateLocation(root, values["state-dir"]);
|
|
2292
|
+
const state = new ClientState(directory), log = new ClientLog(directory), runtime = new RuntimeState(state);
|
|
2293
|
+
try {
|
|
2294
|
+
if (command === "logs") {
|
|
2295
|
+
const entries = log.tail(values.all ? void 0 : root);
|
|
2296
|
+
if (values.json) json(entries);
|
|
2297
|
+
else if (!entries.length) result("\u6CA1\u6709\u8BCA\u65AD\u8BB0\u5F55");
|
|
2298
|
+
else
|
|
2299
|
+
for (const item of entries)
|
|
2300
|
+
process.stdout.write(
|
|
2301
|
+
`${item.at} ${item.event}${item.detail ? " " + item.detail : ""}
|
|
2302
|
+
`
|
|
2303
|
+
);
|
|
2304
|
+
return 0;
|
|
2305
|
+
}
|
|
2306
|
+
if (command === "status") {
|
|
2307
|
+
const binding = state.binding(root);
|
|
2308
|
+
if (!binding) throw new Error("This workspace is not connected. Run teammy connect first.");
|
|
2309
|
+
const daemon = await runningDaemon(directory);
|
|
2310
|
+
const current = daemon ? await daemonRequest(directory, { method: "status", root }) : { enabled: runtime.enabled(root), running: false, status: runtime.status(root) };
|
|
2311
|
+
const { token: _, ...publicBinding } = binding;
|
|
2312
|
+
if (values.json)
|
|
2313
|
+
json({
|
|
2314
|
+
...current,
|
|
2315
|
+
binding: publicBinding,
|
|
2316
|
+
control: readControlStatus(state, root),
|
|
2317
|
+
daemon,
|
|
2318
|
+
logPath: log.path
|
|
2319
|
+
});
|
|
2320
|
+
else {
|
|
2321
|
+
const status = current.status;
|
|
2322
|
+
const title = !current.enabled ? "\u540C\u6B65\u5DF2\u505C\u6B62" : !current.running ? "\u540E\u53F0\u540C\u6B65\u672A\u8FD0\u884C" : status?.issue ? "\u540C\u6B65\u7B49\u5F85\u5904\u7406" : status?.pendingDownloads || !status?.lastSuccessAt ? "\u9996\u6B21\u540C\u6B65\u4E2D" : "\u540C\u6B65\u5DF2\u5F00\u542F";
|
|
2323
|
+
result(
|
|
2324
|
+
title,
|
|
2325
|
+
new ClientProjectState(state, root).cached()?.config.projectName ?? binding.workspaceId
|
|
2326
|
+
);
|
|
2327
|
+
if (status?.issue) result("\u5F85\u5904\u7406", status.issue);
|
|
2328
|
+
else if (status?.lastSuccessAt)
|
|
2329
|
+
process.stdout.write(` \u6700\u8FD1\u6210\u529F\uFF1A${status.lastSuccessAt}
|
|
2330
|
+
`);
|
|
2331
|
+
if (status?.pendingFiles || status?.pendingDownloads)
|
|
2332
|
+
process.stdout.write(
|
|
2333
|
+
` \u5F85\u4E0A\u4F20 ${status.pendingFiles} \xB7 \u5F85\u4E0B\u8F7D ${status.pendingDownloads}
|
|
2334
|
+
`
|
|
2335
|
+
);
|
|
2336
|
+
}
|
|
2337
|
+
return 0;
|
|
2338
|
+
}
|
|
2339
|
+
const existing = state.binding(root);
|
|
2340
|
+
const url = values.url ?? (values.reconnect ? existing?.serverUrl : void 0);
|
|
2341
|
+
if (!url) throw new Error("--url is required.");
|
|
2342
|
+
const transport = new HubClient(url);
|
|
2343
|
+
const selectedProject = values.project ?? (values.reconnect ? existing?.workspaceId : void 0);
|
|
2344
|
+
const project = selectedProject === void 0 && values["token-file"] ? void 0 : NameSchema.parse(selectedProject);
|
|
2345
|
+
let member = values.member ?? (values.reconnect ? existing?.memberId : void 0);
|
|
2346
|
+
if (!member && !values["token-file"] && process.stdin.isTTY) {
|
|
2347
|
+
const rl = createInterface2({ input: process.stdin, output: process.stderr });
|
|
2348
|
+
try {
|
|
2349
|
+
member = (await rl.question("Member ID: ")).trim();
|
|
2350
|
+
} finally {
|
|
2351
|
+
rl.close();
|
|
2352
|
+
}
|
|
2353
|
+
}
|
|
2354
|
+
if (!values["token-file"]) member = NameSchema.parse(member);
|
|
2355
|
+
await prepareWorkspace(
|
|
2356
|
+
state,
|
|
2357
|
+
root,
|
|
2358
|
+
{ url: transport.url, project: project ?? "", member: member ?? "" },
|
|
2359
|
+
Boolean(values.reconnect)
|
|
2360
|
+
);
|
|
2361
|
+
if (!values["no-start"]) await checkServiceSupport();
|
|
2362
|
+
const connected = await connectProject(state, root, directory, {
|
|
2363
|
+
url: transport.url,
|
|
2364
|
+
project,
|
|
2365
|
+
member,
|
|
2366
|
+
tokenFile: values["token-file"],
|
|
2367
|
+
reconnect: values.reconnect
|
|
2368
|
+
});
|
|
2369
|
+
completePreparation(state, root);
|
|
2370
|
+
runtime.enable(root, !values["no-start"]);
|
|
2371
|
+
log.write("workspace.connected", root);
|
|
2372
|
+
if (!values["no-start"]) await wakeDaemon(directory, version);
|
|
2373
|
+
if (values.json) json({ ...connected, syncEnabled: !values["no-start"] });
|
|
2374
|
+
else result(values["no-start"] ? "\u9879\u76EE\u5DF2\u8FDE\u63A5" : "\u5DF2\u8FDE\u63A5\uFF0C\u540E\u53F0\u540C\u6B65\u5DF2\u542F\u52A8", root);
|
|
2375
|
+
return 0;
|
|
2376
|
+
} catch (e) {
|
|
2377
|
+
log.write(`${command}.failed`, root, diagnostic(e));
|
|
2378
|
+
throw e;
|
|
2379
|
+
} finally {
|
|
2380
|
+
state.close();
|
|
2381
|
+
}
|
|
2382
|
+
}
|
|
2383
|
+
|
|
2384
|
+
// apps/client/files/synchronizer.ts
|
|
2385
|
+
import { randomUUID as randomUUID6 } from "node:crypto";
|
|
2386
|
+
import { watch } from "node:fs";
|
|
2387
|
+
import { lstat as lstat4, mkdir as mkdir9 } from "node:fs/promises";
|
|
2388
|
+
import { join as join12, sep as sep4 } from "node:path";
|
|
2389
|
+
import { performance } from "node:perf_hooks";
|
|
2390
|
+
|
|
2391
|
+
// packages/storage/local-blob-store.ts
|
|
2392
|
+
import { createHash as createHash8, randomUUID as randomUUID5 } from "node:crypto";
|
|
2393
|
+
import { link, mkdir as mkdir8, open as open3, readFile as readFile3, unlink as unlink3 } from "node:fs/promises";
|
|
2394
|
+
import { dirname as dirname6, join as join11, resolve as resolve6 } from "node:path";
|
|
2395
|
+
function hash(content) {
|
|
2396
|
+
return createHash8("sha256").update(content).digest("hex");
|
|
2397
|
+
}
|
|
2398
|
+
function hasCode(error, code) {
|
|
2399
|
+
return error instanceof Error && "code" in error && error.code === code;
|
|
2400
|
+
}
|
|
1720
2401
|
var BlobIntegrityError = class extends Error {
|
|
1721
2402
|
constructor(sha256) {
|
|
1722
2403
|
super(`Stored content failed integrity verification: ${sha256}`);
|
|
@@ -1728,13 +2409,13 @@ var BlobIntegrityError = class extends Error {
|
|
|
1728
2409
|
var LocalBlobStore = class {
|
|
1729
2410
|
root;
|
|
1730
2411
|
constructor(root) {
|
|
1731
|
-
this.root =
|
|
2412
|
+
this.root = resolve6(root);
|
|
1732
2413
|
}
|
|
1733
2414
|
async put(content) {
|
|
1734
2415
|
const bytes = Buffer.from(content);
|
|
1735
2416
|
const sha256 = hash(bytes);
|
|
1736
2417
|
const target = this.pathFor(sha256);
|
|
1737
|
-
await
|
|
2418
|
+
await mkdir8(dirname6(target), { recursive: true, mode: 448 });
|
|
1738
2419
|
const temporary = `${target}.${randomUUID5()}.tmp`;
|
|
1739
2420
|
const file = await open3(temporary, "wx", 384);
|
|
1740
2421
|
try {
|
|
@@ -1751,7 +2432,7 @@ var LocalBlobStore = class {
|
|
|
1751
2432
|
await this.get(sha256);
|
|
1752
2433
|
}
|
|
1753
2434
|
if (process.platform !== "win32") {
|
|
1754
|
-
let directory =
|
|
2435
|
+
let directory = dirname6(target);
|
|
1755
2436
|
for (; ; ) {
|
|
1756
2437
|
const handle = await open3(directory, "r");
|
|
1757
2438
|
try {
|
|
@@ -1759,8 +2440,8 @@ var LocalBlobStore = class {
|
|
|
1759
2440
|
} finally {
|
|
1760
2441
|
await handle.close();
|
|
1761
2442
|
}
|
|
1762
|
-
if (directory ===
|
|
1763
|
-
directory =
|
|
2443
|
+
if (directory === dirname6(this.root)) break;
|
|
2444
|
+
directory = dirname6(directory);
|
|
1764
2445
|
}
|
|
1765
2446
|
}
|
|
1766
2447
|
} finally {
|
|
@@ -1772,7 +2453,7 @@ var LocalBlobStore = class {
|
|
|
1772
2453
|
const target = this.pathFor(sha256);
|
|
1773
2454
|
let content;
|
|
1774
2455
|
try {
|
|
1775
|
-
content = await
|
|
2456
|
+
content = await readFile3(target);
|
|
1776
2457
|
} catch (error) {
|
|
1777
2458
|
if (hasCode(error, "ENOENT")) return void 0;
|
|
1778
2459
|
throw error;
|
|
@@ -1782,994 +2463,1339 @@ var LocalBlobStore = class {
|
|
|
1782
2463
|
}
|
|
1783
2464
|
pathFor(sha256) {
|
|
1784
2465
|
if (!/^[a-f0-9]{64}$/.test(sha256)) throw new Error("Invalid SHA-256 content identifier.");
|
|
1785
|
-
return
|
|
2466
|
+
return join11(this.root, "sha256", sha256.slice(0, 2), sha256);
|
|
1786
2467
|
}
|
|
1787
2468
|
};
|
|
1788
2469
|
|
|
1789
|
-
// apps/client/
|
|
1790
|
-
|
|
1791
|
-
|
|
1792
|
-
|
|
1793
|
-
|
|
1794
|
-
|
|
1795
|
-
|
|
1796
|
-
|
|
1797
|
-
|
|
1798
|
-
|
|
1799
|
-
|
|
1800
|
-
|
|
1801
|
-
|
|
1802
|
-
|
|
1803
|
-
|
|
1804
|
-
|
|
1805
|
-
|
|
1806
|
-
|
|
1807
|
-
|
|
1808
|
-
|
|
2470
|
+
// apps/client/publication.ts
|
|
2471
|
+
var PublicationQueue = class {
|
|
2472
|
+
constructor(state, blobs, transport) {
|
|
2473
|
+
this.state = state;
|
|
2474
|
+
this.blobs = blobs;
|
|
2475
|
+
this.transport = transport;
|
|
2476
|
+
}
|
|
2477
|
+
state;
|
|
2478
|
+
blobs;
|
|
2479
|
+
transport;
|
|
2480
|
+
async flush(root, now = Date.now()) {
|
|
2481
|
+
const pending = this.state.pending(root);
|
|
2482
|
+
if (!pending || pending.retryAt > now) return false;
|
|
2483
|
+
try {
|
|
2484
|
+
const hashes = [
|
|
2485
|
+
...new Set(
|
|
2486
|
+
pending.batch.changes.flatMap(
|
|
2487
|
+
(change) => change.kind === "upsert" ? [change.sha256] : []
|
|
2488
|
+
)
|
|
2489
|
+
)
|
|
2490
|
+
];
|
|
2491
|
+
const { missing: missing2 } = await this.transport.missingBlobs(hashes);
|
|
2492
|
+
for (const hash2 of missing2) {
|
|
2493
|
+
if (!hashes.includes(hash2)) throw new Error("Server requested unrelated content.");
|
|
2494
|
+
const content = await this.blobs.get(hash2);
|
|
2495
|
+
if (!content) throw new Error("A queued content snapshot is missing from local storage.");
|
|
2496
|
+
const result2 = await this.transport.uploadBlob(hash2, content);
|
|
2497
|
+
if (result2.sha256 !== hash2 || result2.size !== content.byteLength)
|
|
2498
|
+
throw new Error("Server acknowledged different content.");
|
|
2499
|
+
}
|
|
2500
|
+
this.state.acknowledge(root, await this.transport.commitFiles(pending.batch));
|
|
2501
|
+
return true;
|
|
2502
|
+
} catch (error) {
|
|
2503
|
+
const blocked = error instanceof HubRequestError && error.status >= 400 && error.status < 500 && ![408, 429].includes(error.status);
|
|
2504
|
+
this.state.postpone(
|
|
2505
|
+
root,
|
|
2506
|
+
pending.batch.operationId,
|
|
2507
|
+
error instanceof Error ? error.message : "Publication failed.",
|
|
2508
|
+
now,
|
|
2509
|
+
blocked
|
|
2510
|
+
);
|
|
2511
|
+
throw error;
|
|
1809
2512
|
}
|
|
1810
|
-
throw new Error("Invalid Cursor protobuf integer.");
|
|
1811
|
-
};
|
|
1812
|
-
const result = /* @__PURE__ */ new Map();
|
|
1813
|
-
while (offset < data.length) {
|
|
1814
|
-
const tag = varint();
|
|
1815
|
-
const field = Number(tag >> 3n);
|
|
1816
|
-
if (field < 1 || field > 536870911) throw new Error("Invalid Cursor protobuf field.");
|
|
1817
|
-
const wire = Number(tag & 7n);
|
|
1818
|
-
if (wire === 0) varint();
|
|
1819
|
-
else if (wire === 1) offset += 8;
|
|
1820
|
-
else if (wire === 5) offset += 4;
|
|
1821
|
-
else if (wire === 2) {
|
|
1822
|
-
const length = Number(varint());
|
|
1823
|
-
if (!Number.isSafeInteger(length) || length > data.length - offset)
|
|
1824
|
-
throw new Error("Truncated Cursor protobuf value.");
|
|
1825
|
-
const values = result.get(field) ?? [];
|
|
1826
|
-
values.push(data.subarray(offset, offset + length));
|
|
1827
|
-
result.set(field, values);
|
|
1828
|
-
offset += length;
|
|
1829
|
-
} else throw new Error("Unsupported Cursor protobuf wire type.");
|
|
1830
|
-
if (offset > data.length) throw new Error("Truncated Cursor protobuf value.");
|
|
1831
2513
|
}
|
|
1832
|
-
|
|
1833
|
-
|
|
1834
|
-
|
|
1835
|
-
|
|
1836
|
-
|
|
1837
|
-
|
|
1838
|
-
|
|
1839
|
-
|
|
1840
|
-
const
|
|
1841
|
-
|
|
1842
|
-
|
|
1843
|
-
|
|
1844
|
-
|
|
1845
|
-
|
|
1846
|
-
|
|
1847
|
-
|
|
1848
|
-
const
|
|
1849
|
-
|
|
1850
|
-
|
|
1851
|
-
|
|
1852
|
-
|
|
1853
|
-
|
|
1854
|
-
|
|
1855
|
-
|
|
1856
|
-
|
|
1857
|
-
|
|
2514
|
+
};
|
|
2515
|
+
|
|
2516
|
+
// apps/client/files/synchronizer.ts
|
|
2517
|
+
var FileSynchronizer = class {
|
|
2518
|
+
constructor(state, root, stateDirectory, options = {}) {
|
|
2519
|
+
this.state = state;
|
|
2520
|
+
this.root = root;
|
|
2521
|
+
this.options = options;
|
|
2522
|
+
const binding = state.binding(root);
|
|
2523
|
+
if (!binding) throw new Error("Connect this root before synchronizing.");
|
|
2524
|
+
this.binding = binding;
|
|
2525
|
+
this.client = new HubClient(binding.serverUrl, 3e4, binding, this.abort.signal);
|
|
2526
|
+
this.blobs = new LocalBlobStore(join12(stateDirectory, "blobs"));
|
|
2527
|
+
this.queue = new PublicationQueue(state, this.blobs, this.client);
|
|
2528
|
+
this.index = new SyncState(state, root);
|
|
2529
|
+
this.ownRoot = join12(root, binding.memberId);
|
|
2530
|
+
const started = performance.now();
|
|
2531
|
+
const epoch = Date.now();
|
|
2532
|
+
this.clock = options.clock ?? (() => epoch + performance.now() - started);
|
|
2533
|
+
this.settleMs = options.settleMs ?? 6e4;
|
|
2534
|
+
this.index.restartWaiting(this.clock());
|
|
2535
|
+
}
|
|
2536
|
+
state;
|
|
2537
|
+
root;
|
|
2538
|
+
options;
|
|
2539
|
+
index;
|
|
2540
|
+
binding;
|
|
2541
|
+
client;
|
|
2542
|
+
blobs;
|
|
2543
|
+
queue;
|
|
2544
|
+
ownRoot;
|
|
2545
|
+
clock;
|
|
2546
|
+
settleMs;
|
|
2547
|
+
watcher;
|
|
2548
|
+
watchIssues = [];
|
|
2549
|
+
lastScan = -Infinity;
|
|
2550
|
+
busy = false;
|
|
2551
|
+
stopped = false;
|
|
2552
|
+
abort = new AbortController();
|
|
2553
|
+
async checkCapabilities() {
|
|
2554
|
+
const info = await this.client.serverInfo();
|
|
2555
|
+
if (!["identity", "blobs", "file-revisions"].every(
|
|
2556
|
+
(capability) => info.capabilities.includes(capability)
|
|
2557
|
+
))
|
|
2558
|
+
throw new Error("Server does not support file synchronization.");
|
|
2559
|
+
}
|
|
2560
|
+
setSettleMs(value) {
|
|
2561
|
+
if (!Number.isSafeInteger(value) || value < 0 || value > 864e5)
|
|
2562
|
+
throw new Error("Invalid stable wait interval.");
|
|
2563
|
+
this.settleMs = value;
|
|
2564
|
+
}
|
|
2565
|
+
close() {
|
|
2566
|
+
this.stopped = true;
|
|
2567
|
+
this.abort.abort();
|
|
2568
|
+
this.watcher?.close();
|
|
2569
|
+
this.watcher = void 0;
|
|
2570
|
+
}
|
|
2571
|
+
async cycle() {
|
|
2572
|
+
if (this.busy || this.stopped) throw new Error("Synchronizer is already running or closed.");
|
|
2573
|
+
this.busy = true;
|
|
2574
|
+
const settleMs = this.settleMs;
|
|
2575
|
+
const report = {
|
|
2576
|
+
published: false,
|
|
2577
|
+
appliedFiles: 0,
|
|
2578
|
+
waitingFiles: 0,
|
|
2579
|
+
pendingPublication: false,
|
|
2580
|
+
issues: this.watchIssues.splice(0)
|
|
1858
2581
|
};
|
|
1859
|
-
|
|
1860
|
-
const bucket = basename(dirname5(dirname5(path)));
|
|
1861
|
-
const md5 = (cwd2) => createHash9("md5").update(resolve4(cwd2)).digest("hex");
|
|
1862
|
-
let cwd = md5(projectDir) === bucket ? projectDir : void 0;
|
|
1863
|
-
for (const uri of root.get(9) ?? []) {
|
|
2582
|
+
try {
|
|
1864
2583
|
try {
|
|
1865
|
-
|
|
1866
|
-
|
|
1867
|
-
|
|
1868
|
-
|
|
2584
|
+
await this.initializeAuthor();
|
|
2585
|
+
const ownStat = await lstat4(await safePath(this.root, this.binding.memberId));
|
|
2586
|
+
if (!ownStat.isDirectory() || ownStat.isSymbolicLink())
|
|
2587
|
+
throw new Error("Author directory is unavailable or is a symlink.");
|
|
2588
|
+
this.listen();
|
|
2589
|
+
if (this.clock() - this.lastScan >= (this.options.reconcileMs ?? 3e5)) {
|
|
2590
|
+
await this.reconcile("", report.issues);
|
|
2591
|
+
this.lastScan = this.clock();
|
|
2592
|
+
}
|
|
2593
|
+
const blocked = await this.refreshDirty(report.issues);
|
|
2594
|
+
await this.audit(report.issues);
|
|
2595
|
+
report.published = await this.queue.flush(this.root, this.clock());
|
|
2596
|
+
if (!this.state.pending(this.root) && !this.stopped) {
|
|
2597
|
+
await this.stage(blocked, report.issues, settleMs);
|
|
2598
|
+
report.published = await this.queue.flush(this.root, this.clock()) || report.published;
|
|
2599
|
+
}
|
|
2600
|
+
} catch (error) {
|
|
2601
|
+
report.issues.push(`publish: ${message(error)}`);
|
|
2602
|
+
}
|
|
2603
|
+
if (!this.stopped) {
|
|
2604
|
+
try {
|
|
2605
|
+
for (const member of (await this.client.members()).members) {
|
|
2606
|
+
if (member === this.binding.memberId || this.stopped) continue;
|
|
2607
|
+
try {
|
|
2608
|
+
report.appliedFiles += await this.receive(member);
|
|
2609
|
+
} catch (error) {
|
|
2610
|
+
report.issues.push(`${member}: ${message(error)}`);
|
|
2611
|
+
}
|
|
2612
|
+
}
|
|
2613
|
+
} catch (error) {
|
|
2614
|
+
report.issues.push(`receive: ${message(error)}`);
|
|
1869
2615
|
}
|
|
1870
|
-
} catch {
|
|
1871
2616
|
}
|
|
2617
|
+
report.waitingFiles = this.index.index(true).length;
|
|
2618
|
+
report.pendingPublication = Boolean(this.state.pending(this.root));
|
|
2619
|
+
this.index.saveStatus({ ...report, checkedAt: (/* @__PURE__ */ new Date()).toISOString() });
|
|
2620
|
+
return report;
|
|
2621
|
+
} finally {
|
|
2622
|
+
this.busy = false;
|
|
1872
2623
|
}
|
|
1873
|
-
|
|
1874
|
-
|
|
1875
|
-
|
|
1876
|
-
|
|
1877
|
-
|
|
1878
|
-
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
)
|
|
1886
|
-
|
|
1887
|
-
|
|
1888
|
-
|
|
1889
|
-
|
|
1890
|
-
|
|
1891
|
-
|
|
1892
|
-
|
|
1893
|
-
|
|
2624
|
+
}
|
|
2625
|
+
async manifest(owner) {
|
|
2626
|
+
let revision;
|
|
2627
|
+
let after = "";
|
|
2628
|
+
const files = [];
|
|
2629
|
+
for (; ; ) {
|
|
2630
|
+
const page = await this.client.fileManifest(owner, revision, after);
|
|
2631
|
+
if (revision !== void 0 && page.revision !== revision)
|
|
2632
|
+
throw new Error("Manifest changed version during pagination.");
|
|
2633
|
+
revision = page.revision;
|
|
2634
|
+
files.push(...page.files);
|
|
2635
|
+
if (page.nextCursor === null) return { revision, files };
|
|
2636
|
+
if (page.nextCursor === after || files.length > 1e5)
|
|
2637
|
+
throw new Error("Manifest pagination did not make bounded progress.");
|
|
2638
|
+
after = page.nextCursor;
|
|
2639
|
+
}
|
|
2640
|
+
}
|
|
2641
|
+
async initializeAuthor() {
|
|
2642
|
+
if (this.state.publication(this.root).initialized) return;
|
|
2643
|
+
const manifest = await this.manifest(this.binding.memberId);
|
|
2644
|
+
if (manifest.revision > 0 && this.options.adoptBaseline) {
|
|
2645
|
+
for (const file of manifest.files) {
|
|
2646
|
+
const local = await readStable(this.ownRoot, file.path);
|
|
2647
|
+
if (local.sha256 !== file.sha256)
|
|
2648
|
+
throw new Error(
|
|
2649
|
+
`Cannot adopt baseline: local ${file.path} differs from the published file.`
|
|
2650
|
+
);
|
|
1894
2651
|
}
|
|
1895
|
-
if (!text.trim()) continue;
|
|
1896
|
-
const id = createHash9("sha256").update(JSON.stringify([message2.role, cursor.requestId ?? "", text])).digest("hex");
|
|
1897
|
-
const count = (counts.get(id) ?? 0) + 1;
|
|
1898
|
-
counts.set(id, count);
|
|
1899
|
-
messages.push({
|
|
1900
|
-
id: `${id}#${count}`,
|
|
1901
|
-
role: message2.role,
|
|
1902
|
-
text,
|
|
1903
|
-
source: `blob:${key}`
|
|
1904
|
-
});
|
|
1905
2652
|
}
|
|
1906
|
-
|
|
1907
|
-
|
|
1908
|
-
|
|
1909
|
-
agent: "cursor",
|
|
1910
|
-
nativeId: meta.agentId,
|
|
1911
|
-
cwd,
|
|
1912
|
-
messages
|
|
2653
|
+
const ownerPath = await safePath(this.root, this.binding.memberId);
|
|
2654
|
+
await mkdir9(ownerPath).catch((error) => {
|
|
2655
|
+
if (error.code !== "EEXIST") throw error;
|
|
1913
2656
|
});
|
|
1914
|
-
|
|
1915
|
-
db.close();
|
|
2657
|
+
this.state.initializePublication(this.root, manifest.revision, manifest.files);
|
|
1916
2658
|
}
|
|
1917
|
-
|
|
1918
|
-
|
|
1919
|
-
// apps/client/sessions/source.ts
|
|
1920
|
-
var MAX_SOURCE_BYTES = 64 * 1024 * 1024;
|
|
1921
|
-
async function sourceSignature(path, agent) {
|
|
1922
|
-
const paths = agent === "cursor" ? [path, `${path}-wal`] : [path];
|
|
1923
|
-
const signatures = [];
|
|
1924
|
-
for (const file of paths) {
|
|
2659
|
+
listen() {
|
|
2660
|
+
if (this.options.watch === false || this.watcher || this.stopped) return;
|
|
1925
2661
|
try {
|
|
1926
|
-
|
|
1927
|
-
|
|
1928
|
-
|
|
1929
|
-
|
|
1930
|
-
|
|
1931
|
-
|
|
1932
|
-
|
|
1933
|
-
|
|
1934
|
-
|
|
2662
|
+
this.watcher = watch(this.ownRoot, { recursive: true }, (_event, filename) => {
|
|
2663
|
+
if (!filename) {
|
|
2664
|
+
this.lastScan = -Infinity;
|
|
2665
|
+
return;
|
|
2666
|
+
}
|
|
2667
|
+
const path = filename.toString().split(sep4).join("/");
|
|
2668
|
+
if (!ignored(path)) this.index.event(path, this.clock());
|
|
2669
|
+
});
|
|
2670
|
+
this.watcher.on("error", (error) => {
|
|
2671
|
+
this.watchIssues.push(`File watcher: ${message(error)}; metadata scans remain enabled.`);
|
|
2672
|
+
this.watcher?.close();
|
|
2673
|
+
this.watcher = void 0;
|
|
2674
|
+
this.lastScan = -Infinity;
|
|
2675
|
+
});
|
|
1935
2676
|
} catch (error) {
|
|
1936
|
-
|
|
1937
|
-
signatures.push(null);
|
|
1938
|
-
else throw error;
|
|
2677
|
+
this.watchIssues.push(`File watcher unavailable: ${message(error)}; using metadata scans.`);
|
|
1939
2678
|
}
|
|
1940
2679
|
}
|
|
1941
|
-
|
|
1942
|
-
|
|
1943
|
-
|
|
1944
|
-
|
|
1945
|
-
|
|
1946
|
-
|
|
1947
|
-
|
|
1948
|
-
|
|
1949
|
-
|
|
1950
|
-
try {
|
|
1951
|
-
const size = (await handle.stat()).size;
|
|
1952
|
-
if (size > MAX_SOURCE_BYTES) throw new Error("Session source exceeds 64 MiB.");
|
|
1953
|
-
bytes = Buffer.alloc(size + 1);
|
|
1954
|
-
let offset = 0;
|
|
1955
|
-
while (offset < bytes.length) {
|
|
1956
|
-
const read = await handle.read(bytes, offset, bytes.length - offset, offset);
|
|
1957
|
-
if (!read.bytesRead) break;
|
|
1958
|
-
offset += read.bytesRead;
|
|
1959
|
-
}
|
|
1960
|
-
bytes = bytes.subarray(0, offset);
|
|
1961
|
-
if (offset > size) throw new Error("Session source changed while reading; retry next cycle.");
|
|
1962
|
-
} finally {
|
|
1963
|
-
await handle.close();
|
|
1964
|
-
}
|
|
1965
|
-
if (agent === "dsh" && path.endsWith(".zstd")) {
|
|
1966
|
-
const decompress = zlib.zstdDecompressSync;
|
|
1967
|
-
if (!decompress)
|
|
1968
|
-
throw new Error(
|
|
1969
|
-
"Compressed DSH sessions require Node.js with zstd support (22.15+ or 24+)."
|
|
1970
|
-
);
|
|
1971
|
-
bytes = decompress(bytes, { maxOutputLength: MAX_SOURCE_BYTES });
|
|
1972
|
-
}
|
|
1973
|
-
result = normalizeJsonl(agent, new TextDecoder("utf-8", { fatal: true }).decode(bytes));
|
|
1974
|
-
}
|
|
1975
|
-
if (signature2 !== await sourceSignature(path, agent))
|
|
1976
|
-
throw new Error("Session source changed while reading; retry next cycle.");
|
|
1977
|
-
const cwd = await realpath4(result.snapshot.cwd);
|
|
1978
|
-
const project = await realpath4(projectDir);
|
|
1979
|
-
const rel = relative3(project, cwd);
|
|
1980
|
-
if (rel === ".." || rel.startsWith(`..${sep3}`) || isAbsolute3(rel))
|
|
1981
|
-
throw new Error("Session cwd is outside the registered project.");
|
|
1982
|
-
return { ...result, signature: signature2 };
|
|
1983
|
-
}
|
|
1984
|
-
|
|
1985
|
-
// apps/client/sessions/discovery.ts
|
|
1986
|
-
import { opendir as opendir2 } from "node:fs/promises";
|
|
1987
|
-
import { join as join7 } from "node:path";
|
|
1988
|
-
async function* candidates(directory, agent) {
|
|
1989
|
-
const queue = [directory];
|
|
1990
|
-
while (queue.length) {
|
|
1991
|
-
const current = queue.shift();
|
|
1992
|
-
const dir = await opendir2(current);
|
|
1993
|
-
for await (const entry of dir) {
|
|
1994
|
-
const path = join7(current, entry.name);
|
|
1995
|
-
if (entry.isDirectory()) queue.push(path);
|
|
1996
|
-
if (entry.isFile() && (agent === "cursor" ? entry.name === "store.db" : entry.name.endsWith(".jsonl") || agent === "dsh" && entry.name.endsWith(".jsonl.zstd")))
|
|
1997
|
-
yield path;
|
|
1998
|
-
else yield null;
|
|
2680
|
+
async reconcile(prefix, issues) {
|
|
2681
|
+
const scan = await walk(this.ownRoot, prefix);
|
|
2682
|
+
const now = this.clock();
|
|
2683
|
+
for (const [path, signature2] of scan.files) this.index.observe(path, signature2, now);
|
|
2684
|
+
if (scan.complete) {
|
|
2685
|
+
for (const prior of this.index.index()) {
|
|
2686
|
+
if ((!prefix || prior.path.startsWith(`${prefix}/`)) && !ignored(prior.path) && !scan.files.has(prior.path))
|
|
2687
|
+
this.index.observe(prior.path, null, now);
|
|
2688
|
+
}
|
|
1999
2689
|
}
|
|
2690
|
+
issues.push(...scan.issues);
|
|
2000
2691
|
}
|
|
2001
|
-
|
|
2002
|
-
|
|
2003
|
-
|
|
2004
|
-
|
|
2005
|
-
|
|
2006
|
-
|
|
2007
|
-
|
|
2008
|
-
|
|
2009
|
-
|
|
2010
|
-
|
|
2011
|
-
|
|
2012
|
-
|
|
2013
|
-
|
|
2014
|
-
if (!configs.some((config) => config.directory === directory)) {
|
|
2015
|
-
await scan.iterator.return(void 0);
|
|
2016
|
-
this.scans.delete(directory);
|
|
2692
|
+
async refreshDirty(issues) {
|
|
2693
|
+
const blocked = /* @__PURE__ */ new Set();
|
|
2694
|
+
for (const prior of this.index.index(true)) {
|
|
2695
|
+
if (this.stopped) break;
|
|
2696
|
+
try {
|
|
2697
|
+
const current = await metadata(this.ownRoot, prior.path);
|
|
2698
|
+
if (current?.directory) {
|
|
2699
|
+
await this.reconcile(prior.path, issues);
|
|
2700
|
+
this.index.observe(prior.path, null, this.clock());
|
|
2701
|
+
} else this.index.observe(prior.path, current?.signature ?? null, this.clock());
|
|
2702
|
+
} catch (error) {
|
|
2703
|
+
blocked.add(prior.path);
|
|
2704
|
+
issues.push(`${prior.path}: ${message(error)}`);
|
|
2017
2705
|
}
|
|
2018
2706
|
}
|
|
2019
|
-
|
|
2020
|
-
|
|
2021
|
-
|
|
2022
|
-
|
|
2023
|
-
|
|
2024
|
-
|
|
2025
|
-
|
|
2707
|
+
return blocked;
|
|
2708
|
+
}
|
|
2709
|
+
async audit(issues) {
|
|
2710
|
+
let bytes = 0;
|
|
2711
|
+
for (const prior of this.index.audit(
|
|
2712
|
+
this.clock() - (this.options.auditAgeMs ?? 864e5),
|
|
2713
|
+
this.options.auditBudget ?? 20
|
|
2714
|
+
)) {
|
|
2715
|
+
if (this.stopped || bytes > 20 * 1024 * 1024) break;
|
|
2026
2716
|
try {
|
|
2027
|
-
|
|
2028
|
-
|
|
2029
|
-
|
|
2030
|
-
|
|
2031
|
-
|
|
2032
|
-
scan.nextAt = Date.now() + 3e5;
|
|
2033
|
-
break;
|
|
2034
|
-
}
|
|
2035
|
-
const path = entry.value;
|
|
2036
|
-
if (!path || this.state.get(path)) continue;
|
|
2037
|
-
const signature2 = await sourceSignature(path, config.agent);
|
|
2038
|
-
const db = this.state.state.db;
|
|
2039
|
-
if (db.prepare(
|
|
2040
|
-
"SELECT 1 FROM session_discovery_cache WHERE root=? AND path=? AND signature=?"
|
|
2041
|
-
).get(this.state.root, path, signature2))
|
|
2042
|
-
continue;
|
|
2043
|
-
reads--;
|
|
2044
|
-
try {
|
|
2045
|
-
const result = await readSource(config.agent, path, config.projectDir);
|
|
2046
|
-
if (result.incomplete) continue;
|
|
2047
|
-
this.state.track(config.agent, path, config.projectDir);
|
|
2048
|
-
} catch (error) {
|
|
2049
|
-
const message2 = error instanceof Error ? error.message : "Discovery failed.";
|
|
2050
|
-
if (!message2.includes("outside the registered project"))
|
|
2051
|
-
issues.push(`${path}: ${message2}`);
|
|
2052
|
-
if (!message2.includes("outside the registered project")) continue;
|
|
2053
|
-
}
|
|
2054
|
-
db.prepare(
|
|
2055
|
-
"INSERT INTO session_discovery_cache VALUES (?,?,?) ON CONFLICT(root,path) DO UPDATE SET signature=excluded.signature"
|
|
2056
|
-
).run(this.state.root, path, signature2);
|
|
2057
|
-
}
|
|
2717
|
+
const local = await readStable(this.ownRoot, prior.path);
|
|
2718
|
+
bytes += local.bytes.length;
|
|
2719
|
+
if (local.sha256 !== prior.hash || local.signature !== prior.signature)
|
|
2720
|
+
this.index.event(prior.path, this.clock());
|
|
2721
|
+
this.index.verified(prior.path, this.clock());
|
|
2058
2722
|
} catch (error) {
|
|
2059
|
-
|
|
2060
|
-
|
|
2061
|
-
);
|
|
2062
|
-
await scan.iterator.return(void 0);
|
|
2063
|
-
scan.iterator = candidates(config.directory, config.agent);
|
|
2064
|
-
scan.nextAt = Date.now() + 3e5;
|
|
2723
|
+
this.index.event(prior.path, this.clock());
|
|
2724
|
+
issues.push(`${prior.path}: ${message(error)}`);
|
|
2065
2725
|
}
|
|
2066
2726
|
}
|
|
2067
|
-
return issues;
|
|
2068
|
-
}
|
|
2069
|
-
async close() {
|
|
2070
|
-
for (const scan of this.scans.values()) await scan.iterator.return(void 0);
|
|
2071
|
-
}
|
|
2072
|
-
};
|
|
2073
|
-
|
|
2074
|
-
// apps/client/sessions/synchronizer.ts
|
|
2075
|
-
var SessionSynchronizer = class {
|
|
2076
|
-
constructor(state, root, directory, signal) {
|
|
2077
|
-
this.root = root;
|
|
2078
|
-
this.directory = directory;
|
|
2079
|
-
const binding = state.binding(root);
|
|
2080
|
-
if (!binding) throw new Error("Connect this root before collecting sessions.");
|
|
2081
|
-
this.client = new HubClient(binding.serverUrl, 1e4, binding, signal);
|
|
2082
|
-
this.blobs = new LocalBlobStore(join8(directory, "blobs"));
|
|
2083
|
-
this.index = new SessionState(state, root);
|
|
2084
|
-
this.discovery = new SessionDiscovery(this.index);
|
|
2085
2727
|
}
|
|
2086
|
-
|
|
2087
|
-
|
|
2088
|
-
|
|
2089
|
-
|
|
2090
|
-
|
|
2091
|
-
|
|
2092
|
-
|
|
2093
|
-
|
|
2094
|
-
const
|
|
2095
|
-
|
|
2096
|
-
|
|
2097
|
-
const
|
|
2098
|
-
|
|
2099
|
-
const info = await this.client.serverInfo();
|
|
2100
|
-
if (!["blobs", "session-revisions"].every(
|
|
2101
|
-
(capability) => info.capabilities.includes(capability)
|
|
2102
|
-
))
|
|
2103
|
-
throw new Error("Server does not support session publication. Upgrade the server first.");
|
|
2104
|
-
this.capable = true;
|
|
2105
|
-
}
|
|
2106
|
-
let reads = 5;
|
|
2107
|
-
let uploads = 5;
|
|
2108
|
-
for (const source of sources) {
|
|
2109
|
-
if (reads <= 0 || uploads <= 0 || report.checked >= 200) break;
|
|
2110
|
-
report.checked++;
|
|
2111
|
-
this.index.checked(source.path, source.lastError);
|
|
2728
|
+
async stage(blocked, issues, settleMs) {
|
|
2729
|
+
const ready = this.index.index(true).filter(
|
|
2730
|
+
(row) => !blocked.has(row.path) && !row.eventPending && this.clock() - row.observedAt >= settleMs
|
|
2731
|
+
);
|
|
2732
|
+
if (!ready.length) return;
|
|
2733
|
+
const baseline = this.state.ownerFiles(this.root);
|
|
2734
|
+
const planned = new Map(baseline);
|
|
2735
|
+
const changes = [];
|
|
2736
|
+
const snapshots = [];
|
|
2737
|
+
let bytes = 0;
|
|
2738
|
+
ready.sort((a, b) => Number(a.signature !== null) - Number(b.signature !== null));
|
|
2739
|
+
for (const row of ready) {
|
|
2740
|
+
if (this.stopped || changes.length >= MAX_BATCH_FILES || bytes > 20 * 1024 * 1024) break;
|
|
2112
2741
|
try {
|
|
2113
|
-
if (
|
|
2114
|
-
|
|
2115
|
-
|
|
2116
|
-
|
|
2742
|
+
if (row.signature === null) {
|
|
2743
|
+
const current = await metadata(this.ownRoot, row.path);
|
|
2744
|
+
if (current && !current.directory) {
|
|
2745
|
+
this.index.event(row.path, this.clock());
|
|
2117
2746
|
continue;
|
|
2118
2747
|
}
|
|
2119
|
-
|
|
2120
|
-
|
|
2121
|
-
|
|
2748
|
+
if (baseline.has(row.path) && row.seenLocal) {
|
|
2749
|
+
changes.push({ kind: "delete", path: row.path });
|
|
2750
|
+
planned.delete(row.path);
|
|
2751
|
+
} else {
|
|
2752
|
+
this.index.clean(row.path, row.generation, null, this.clock());
|
|
2753
|
+
continue;
|
|
2754
|
+
}
|
|
2755
|
+
snapshots.push({ path: row.path, generation: row.generation, hash: null });
|
|
2122
2756
|
continue;
|
|
2123
2757
|
}
|
|
2124
|
-
const
|
|
2125
|
-
|
|
2126
|
-
|
|
2758
|
+
const key = row.path.toLowerCase();
|
|
2759
|
+
const collision = [...planned.keys()].some(
|
|
2760
|
+
(path) => path !== row.path && (path.toLowerCase() === key || path.toLowerCase().startsWith(`${key}/`) || key.startsWith(`${path.toLowerCase()}/`))
|
|
2761
|
+
);
|
|
2762
|
+
if (collision) {
|
|
2763
|
+
issues.push(
|
|
2764
|
+
`${row.path}: conflicts with a preserved server path; rename the local file to publish it.`
|
|
2765
|
+
);
|
|
2127
2766
|
continue;
|
|
2128
2767
|
}
|
|
2129
|
-
|
|
2130
|
-
|
|
2131
|
-
if (
|
|
2132
|
-
|
|
2133
|
-
if (!observed.snapshot.messages.length) {
|
|
2134
|
-
this.index.checked(source.path);
|
|
2768
|
+
const local = await readStable(this.ownRoot, row.path);
|
|
2769
|
+
bytes += local.bytes.length;
|
|
2770
|
+
if (local.signature !== row.signature || this.index.get(row.path)?.generation !== row.generation) {
|
|
2771
|
+
this.index.event(row.path, this.clock());
|
|
2135
2772
|
continue;
|
|
2136
2773
|
}
|
|
2137
|
-
|
|
2138
|
-
|
|
2139
|
-
throw new Error(
|
|
2140
|
-
"Native session identity changed at this path; register the new source explicitly."
|
|
2141
|
-
);
|
|
2142
|
-
const bytes = Buffer.from(JSON.stringify(observed.snapshot));
|
|
2143
|
-
if (bytes.length > MAX_BLOB_BYTES)
|
|
2144
|
-
throw new Error("Normalized session exceeds the 10 MiB snapshot limit.");
|
|
2145
|
-
const blob = await this.blobs.put(bytes);
|
|
2146
|
-
let revision = source.revision;
|
|
2147
|
-
if (!source.key) {
|
|
2148
|
-
try {
|
|
2149
|
-
const owner = this.index.state.binding(this.root).memberId;
|
|
2150
|
-
const current = await this.client.session(owner, key);
|
|
2151
|
-
if (current.blob.sha256 !== blob.sha256 && !source.adoptExisting)
|
|
2152
|
-
throw new Error(
|
|
2153
|
-
"Server session already exists with different content; inspect it, then track with --adopt-existing to continue."
|
|
2154
|
-
);
|
|
2155
|
-
revision = current.revision;
|
|
2156
|
-
} catch (error) {
|
|
2157
|
-
if (!(error instanceof HubRequestError && error.code === "SESSION_NOT_FOUND"))
|
|
2158
|
-
throw error;
|
|
2159
|
-
}
|
|
2160
|
-
this.index.baseline(source.path, key, revision);
|
|
2161
|
-
}
|
|
2162
|
-
if (blob.sha256 === source.hash) {
|
|
2163
|
-
this.index.acknowledge(source.path, revision, blob.sha256, observed.signature);
|
|
2774
|
+
if (baseline.get(row.path)?.sha256 === local.sha256) {
|
|
2775
|
+
this.index.clean(row.path, row.generation, local.sha256, this.clock());
|
|
2164
2776
|
continue;
|
|
2165
2777
|
}
|
|
2166
|
-
this.
|
|
2167
|
-
|
|
2168
|
-
|
|
2169
|
-
|
|
2170
|
-
);
|
|
2171
|
-
uploads--;
|
|
2172
|
-
await this.flush(this.index.get(source.path));
|
|
2173
|
-
report.published++;
|
|
2778
|
+
const ref = await this.blobs.put(local.bytes);
|
|
2779
|
+
changes.push({ kind: "upsert", path: row.path, ...ref });
|
|
2780
|
+
planned.set(row.path, ref);
|
|
2781
|
+
snapshots.push({ path: row.path, generation: row.generation, hash: local.sha256 });
|
|
2174
2782
|
} catch (error) {
|
|
2175
|
-
|
|
2176
|
-
|
|
2177
|
-
report.issues.push(`${source.path}: ${message2}`);
|
|
2783
|
+
this.index.event(row.path, this.clock());
|
|
2784
|
+
issues.push(`${row.path}: ${message(error)}`);
|
|
2178
2785
|
}
|
|
2179
2786
|
}
|
|
2180
|
-
return
|
|
2787
|
+
if (!changes.length) return;
|
|
2788
|
+
const rootStat = await lstat4(this.ownRoot);
|
|
2789
|
+
if (!rootStat.isDirectory() || rootStat.isSymbolicLink())
|
|
2790
|
+
throw new Error("Author directory disappeared before publication.");
|
|
2791
|
+
this.state.enqueue(this.root, {
|
|
2792
|
+
operationId: randomUUID6(),
|
|
2793
|
+
parentRevision: this.state.publication(this.root).revision,
|
|
2794
|
+
changes
|
|
2795
|
+
});
|
|
2796
|
+
for (const snapshot of snapshots)
|
|
2797
|
+
this.index.clean(snapshot.path, snapshot.generation, snapshot.hash, this.clock());
|
|
2181
2798
|
}
|
|
2182
|
-
async
|
|
2183
|
-
|
|
2184
|
-
|
|
2185
|
-
|
|
2186
|
-
|
|
2187
|
-
|
|
2188
|
-
|
|
2189
|
-
|
|
2190
|
-
|
|
2191
|
-
|
|
2192
|
-
|
|
2193
|
-
|
|
2799
|
+
async receive(owner) {
|
|
2800
|
+
if (!this.index.job(owner)) {
|
|
2801
|
+
const revision = this.index.revision(owner);
|
|
2802
|
+
if (revision === void 0) {
|
|
2803
|
+
const manifest = await this.manifest(owner);
|
|
2804
|
+
this.index.queue(
|
|
2805
|
+
owner,
|
|
2806
|
+
manifest.revision,
|
|
2807
|
+
manifest.files.map((file) => ({
|
|
2808
|
+
kind: "upsert",
|
|
2809
|
+
path: file.path,
|
|
2810
|
+
sha256: file.sha256,
|
|
2811
|
+
size: file.size
|
|
2812
|
+
}))
|
|
2813
|
+
);
|
|
2814
|
+
} else {
|
|
2815
|
+
const delta = await this.client.fileChanges(owner, revision);
|
|
2816
|
+
if (!delta.revisions.length) return 0;
|
|
2817
|
+
const changes = /* @__PURE__ */ new Map();
|
|
2818
|
+
let expected = revision + 1;
|
|
2819
|
+
for (const entry of delta.revisions) {
|
|
2820
|
+
if (entry.revision !== expected++) throw new Error("Server change history has a gap.");
|
|
2821
|
+
for (const change of entry.changes) changes.set(change.path, change);
|
|
2822
|
+
}
|
|
2823
|
+
const finalNames = new Set(
|
|
2824
|
+
[...changes.values()].filter((change) => change.kind === "upsert").map((change) => change.path.toLowerCase())
|
|
2825
|
+
);
|
|
2826
|
+
for (const change of changes.values()) {
|
|
2827
|
+
if (change.kind === "delete" && !this.index.received(owner, change.path) && finalNames.has(change.path.toLowerCase()))
|
|
2828
|
+
changes.delete(change.path);
|
|
2829
|
+
}
|
|
2830
|
+
const ordered = [...changes.values()].sort(
|
|
2831
|
+
(a, b) => a.kind !== b.kind ? a.kind === "delete" ? -1 : 1 : a.kind === "delete" ? b.path.length - a.path.length : a.path.length - b.path.length
|
|
2832
|
+
);
|
|
2833
|
+
this.index.queue(owner, delta.revisions.at(-1).revision, ordered);
|
|
2194
2834
|
}
|
|
2195
|
-
|
|
2196
|
-
|
|
2197
|
-
|
|
2198
|
-
|
|
2199
|
-
|
|
2200
|
-
|
|
2201
|
-
|
|
2202
|
-
|
|
2203
|
-
);
|
|
2204
|
-
|
|
2205
|
-
|
|
2206
|
-
|
|
2207
|
-
|
|
2208
|
-
|
|
2209
|
-
|
|
2835
|
+
}
|
|
2836
|
+
const job = this.index.job(owner);
|
|
2837
|
+
const ownerRoot = await safePath(this.root, owner);
|
|
2838
|
+
await mkdir9(ownerRoot).catch((error) => {
|
|
2839
|
+
if (error.code !== "EEXIST") throw error;
|
|
2840
|
+
});
|
|
2841
|
+
const ownerStat = await lstat4(ownerRoot);
|
|
2842
|
+
if (!ownerStat.isDirectory() || ownerStat.isSymbolicLink())
|
|
2843
|
+
throw new Error("Mirror root is not an ordinary directory.");
|
|
2844
|
+
let count = 0;
|
|
2845
|
+
let bytes = 0;
|
|
2846
|
+
for (let offset = job.nextIndex; offset < job.changes.length; offset++) {
|
|
2847
|
+
if (this.stopped || count >= (this.options.applyBudget ?? 200) || bytes > 20 * 1024 * 1024)
|
|
2848
|
+
break;
|
|
2849
|
+
const change = job.changes[offset];
|
|
2850
|
+
await this.apply(owner, ownerRoot, change);
|
|
2851
|
+
this.index.applied(owner, change, offset + 1);
|
|
2852
|
+
count++;
|
|
2853
|
+
if (change.kind === "upsert") bytes += change.size;
|
|
2854
|
+
}
|
|
2855
|
+
if (this.index.job(owner).nextIndex === job.changes.length) this.index.finish(owner);
|
|
2856
|
+
return count;
|
|
2857
|
+
}
|
|
2858
|
+
async apply(owner, root, change) {
|
|
2859
|
+
const prior = this.index.received(owner, change.path);
|
|
2860
|
+
const current = await metadata(root, change.path);
|
|
2861
|
+
let local;
|
|
2862
|
+
if (current && !current.directory) local = await readStable(root, change.path);
|
|
2863
|
+
if (change.kind === "delete") {
|
|
2864
|
+
if (!current) return;
|
|
2865
|
+
if (current.directory && !prior) return;
|
|
2866
|
+
if (!local || !prior || local.sha256 !== prior.sha256)
|
|
2867
|
+
throw new Error(`Local mirror differs; preserving ${change.path}.`);
|
|
2868
|
+
await removeFile(root, change.path, local.signature);
|
|
2869
|
+
} else {
|
|
2870
|
+
if (local?.sha256 === change.sha256) return;
|
|
2871
|
+
if (local && (!prior || local.sha256 !== prior.sha256))
|
|
2872
|
+
throw new Error(`Local mirror differs; preserving ${change.path}.`);
|
|
2873
|
+
if (!current && prior && !this.options.restoreMissing)
|
|
2874
|
+
throw new Error(
|
|
2875
|
+
`Local mirror was removed; use --restore-missing to restore ${change.path}.`
|
|
2876
|
+
);
|
|
2877
|
+
let content = await this.blobs.get(change.sha256);
|
|
2878
|
+
if (!content) {
|
|
2879
|
+
content = await this.client.downloadBlob(change.sha256);
|
|
2880
|
+
await this.blobs.put(content);
|
|
2881
|
+
}
|
|
2882
|
+
if (content.byteLength !== change.size)
|
|
2883
|
+
throw new Error(`Content size mismatch: ${change.path}`);
|
|
2884
|
+
await replaceFile(root, change.path, content, local?.signature ?? null);
|
|
2885
|
+
}
|
|
2886
|
+
}
|
|
2887
|
+
};
|
|
2888
|
+
function message(error) {
|
|
2889
|
+
return diagnostic(error);
|
|
2890
|
+
}
|
|
2891
|
+
|
|
2892
|
+
// apps/client/sessions/synchronizer.ts
|
|
2893
|
+
import { randomUUID as randomUUID7 } from "node:crypto";
|
|
2894
|
+
import { join as join14 } from "node:path";
|
|
2895
|
+
|
|
2896
|
+
// packages/protocol/session-key.ts
|
|
2897
|
+
import { createHash as createHash9 } from "node:crypto";
|
|
2898
|
+
function sessionKey(agent, nativeId) {
|
|
2899
|
+
return createHash9("sha256").update(JSON.stringify([agent, nativeId])).digest("hex");
|
|
2900
|
+
}
|
|
2901
|
+
|
|
2902
|
+
// apps/client/sessions/source.ts
|
|
2903
|
+
import { open as open4, stat, realpath as realpath5 } from "node:fs/promises";
|
|
2904
|
+
import { relative as relative4, isAbsolute as isAbsolute4, sep as sep5 } from "node:path";
|
|
2905
|
+
import * as zlib from "node:zlib";
|
|
2906
|
+
|
|
2907
|
+
// apps/client/sessions/cursor.ts
|
|
2908
|
+
import { createHash as createHash10 } from "node:crypto";
|
|
2909
|
+
import { basename as basename2, dirname as dirname7, resolve as resolve7 } from "node:path";
|
|
2910
|
+
import { DatabaseSync as DatabaseSync2 } from "node:sqlite";
|
|
2911
|
+
import { fileURLToPath } from "node:url";
|
|
2912
|
+
function fields(bytes) {
|
|
2913
|
+
const data = Buffer.from(bytes);
|
|
2914
|
+
let offset = 0;
|
|
2915
|
+
const varint = () => {
|
|
2916
|
+
let value = 0n;
|
|
2917
|
+
for (let i = 0; i < 10; i++) {
|
|
2918
|
+
if (offset >= data.length) throw new Error("Truncated Cursor protobuf.");
|
|
2919
|
+
const byte = data[offset++];
|
|
2920
|
+
value |= BigInt(byte & 127) << BigInt(i * 7);
|
|
2921
|
+
if (byte < 128) return value;
|
|
2922
|
+
}
|
|
2923
|
+
throw new Error("Invalid Cursor protobuf integer.");
|
|
2924
|
+
};
|
|
2925
|
+
const result2 = /* @__PURE__ */ new Map();
|
|
2926
|
+
while (offset < data.length) {
|
|
2927
|
+
const tag = varint();
|
|
2928
|
+
const field = Number(tag >> 3n);
|
|
2929
|
+
if (field < 1 || field > 536870911) throw new Error("Invalid Cursor protobuf field.");
|
|
2930
|
+
const wire = Number(tag & 7n);
|
|
2931
|
+
if (wire === 0) varint();
|
|
2932
|
+
else if (wire === 1) offset += 8;
|
|
2933
|
+
else if (wire === 5) offset += 4;
|
|
2934
|
+
else if (wire === 2) {
|
|
2935
|
+
const length = Number(varint());
|
|
2936
|
+
if (!Number.isSafeInteger(length) || length > data.length - offset)
|
|
2937
|
+
throw new Error("Truncated Cursor protobuf value.");
|
|
2938
|
+
const values = result2.get(field) ?? [];
|
|
2939
|
+
values.push(data.subarray(offset, offset + length));
|
|
2940
|
+
result2.set(field, values);
|
|
2941
|
+
offset += length;
|
|
2942
|
+
} else throw new Error("Unsupported Cursor protobuf wire type.");
|
|
2943
|
+
if (offset > data.length) throw new Error("Truncated Cursor protobuf value.");
|
|
2944
|
+
}
|
|
2945
|
+
return result2;
|
|
2946
|
+
}
|
|
2947
|
+
function readCursor(path, projectDir) {
|
|
2948
|
+
const db = new DatabaseSync2(path, { readOnly: true });
|
|
2949
|
+
try {
|
|
2950
|
+
db.exec("PRAGMA query_only=ON; PRAGMA busy_timeout=100; BEGIN");
|
|
2951
|
+
if (db.prepare("PRAGMA user_version").get()?.user_version !== 1)
|
|
2952
|
+
throw new Error("Unsupported Cursor CLI store version (expected 1).");
|
|
2953
|
+
const row = db.prepare("SELECT value FROM meta WHERE key='0' AND length(value)<=2097152").get();
|
|
2954
|
+
const hex = row?.value;
|
|
2955
|
+
if (typeof hex !== "string" || !/^(?:[a-fA-F0-9]{2})+$/.test(hex))
|
|
2956
|
+
throw new Error("Invalid Cursor metadata.");
|
|
2957
|
+
const meta = object(JSON.parse(Buffer.from(hex, "hex").toString("utf8")));
|
|
2958
|
+
if (typeof meta.agentId !== "string" || meta.agentId !== basename2(dirname7(path)) || meta.subagentInfo != null)
|
|
2959
|
+
throw new Error("Invalid or delegated Cursor session identity.");
|
|
2960
|
+
let budget = 64 * 1024 * 1024;
|
|
2961
|
+
const blob = (id) => {
|
|
2962
|
+
if (!/^[a-f0-9]{64}$/.test(id)) throw new Error("Invalid Cursor blob reference.");
|
|
2963
|
+
const value = db.prepare("SELECT data FROM blobs WHERE id=? AND length(data)<=?").get(id, budget)?.data;
|
|
2964
|
+
if (!(value instanceof Uint8Array))
|
|
2965
|
+
throw new Error("Cursor history missing or exceeds 64 MiB.");
|
|
2966
|
+
const bytes = Buffer.from(value);
|
|
2967
|
+
budget -= bytes.length;
|
|
2968
|
+
if (createHash10("sha256").update(bytes).digest("hex") !== id)
|
|
2969
|
+
throw new Error("Cursor blob failed integrity verification.");
|
|
2970
|
+
return bytes;
|
|
2971
|
+
};
|
|
2972
|
+
const root = fields(blob(String(meta.latestRootBlobId)));
|
|
2973
|
+
const bucket = basename2(dirname7(dirname7(path)));
|
|
2974
|
+
const md5 = (cwd2) => createHash10("md5").update(resolve7(cwd2)).digest("hex");
|
|
2975
|
+
let cwd = md5(projectDir) === bucket ? projectDir : void 0;
|
|
2976
|
+
for (const uri of root.get(9) ?? []) {
|
|
2977
|
+
try {
|
|
2978
|
+
const native = fileURLToPath(uri.toString("utf8"));
|
|
2979
|
+
if (md5(native) === bucket) {
|
|
2980
|
+
cwd = native;
|
|
2981
|
+
break;
|
|
2982
|
+
}
|
|
2983
|
+
} catch {
|
|
2984
|
+
}
|
|
2985
|
+
}
|
|
2986
|
+
if (!cwd) throw new Error("Cursor workspace cannot be verified against its storage bucket.");
|
|
2987
|
+
const refs = [];
|
|
2988
|
+
for (const archive of root.get(13) ?? root.get(11) ?? [])
|
|
2989
|
+
refs.push(...fields(blob(archive.toString("hex"))).get(1) ?? []);
|
|
2990
|
+
refs.push(...root.get(1) ?? []);
|
|
2991
|
+
if (refs.length > 5e4) throw new Error("Cursor history exceeds 50000 messages.");
|
|
2992
|
+
const messages = [];
|
|
2993
|
+
const counts = /* @__PURE__ */ new Map();
|
|
2994
|
+
for (const ref of refs) {
|
|
2995
|
+
const key = ref.toString("hex");
|
|
2996
|
+
const message2 = object(
|
|
2997
|
+
JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(blob(key)))
|
|
2210
2998
|
);
|
|
2211
|
-
|
|
2999
|
+
const cursor = object(object(message2.providerOptions).cursor);
|
|
3000
|
+
if (cursor.isSummary || !["user", "assistant"].includes(String(message2.role))) continue;
|
|
3001
|
+
let text = textContent(message2.content);
|
|
3002
|
+
if (message2.role === "user") {
|
|
3003
|
+
if (!cursor.requestId) continue;
|
|
3004
|
+
const start = text.indexOf("<user_query>");
|
|
3005
|
+
const end = text.lastIndexOf("</user_query>");
|
|
3006
|
+
if (start >= 0 && end > start) text = text.slice(start + 12, end).trim();
|
|
3007
|
+
}
|
|
3008
|
+
if (!text.trim()) continue;
|
|
3009
|
+
const id = createHash10("sha256").update(JSON.stringify([message2.role, cursor.requestId ?? "", text])).digest("hex");
|
|
3010
|
+
const count = (counts.get(id) ?? 0) + 1;
|
|
3011
|
+
counts.set(id, count);
|
|
3012
|
+
messages.push({
|
|
3013
|
+
id: `${id}#${count}`,
|
|
3014
|
+
role: message2.role,
|
|
3015
|
+
text,
|
|
3016
|
+
source: `blob:${key}`
|
|
3017
|
+
});
|
|
2212
3018
|
}
|
|
3019
|
+
return SessionSnapshotSchema.parse({
|
|
3020
|
+
formatVersion: 1,
|
|
3021
|
+
adapterVersion: 1,
|
|
3022
|
+
agent: "cursor",
|
|
3023
|
+
nativeId: meta.agentId,
|
|
3024
|
+
cwd,
|
|
3025
|
+
messages
|
|
3026
|
+
});
|
|
3027
|
+
} finally {
|
|
3028
|
+
db.close();
|
|
2213
3029
|
}
|
|
2214
|
-
|
|
2215
|
-
await this.discovery.close();
|
|
2216
|
-
}
|
|
2217
|
-
};
|
|
2218
|
-
|
|
2219
|
-
// apps/client/files/synchronizer.ts
|
|
2220
|
-
import { randomUUID as randomUUID7 } from "node:crypto";
|
|
2221
|
-
import { watch } from "node:fs";
|
|
2222
|
-
import { lstat as lstat2, mkdir as mkdir6 } from "node:fs/promises";
|
|
2223
|
-
import { join as join9, sep as sep4 } from "node:path";
|
|
2224
|
-
import { performance } from "node:perf_hooks";
|
|
3030
|
+
}
|
|
2225
3031
|
|
|
2226
|
-
// apps/client/
|
|
2227
|
-
var
|
|
2228
|
-
|
|
2229
|
-
|
|
2230
|
-
|
|
2231
|
-
|
|
2232
|
-
}
|
|
2233
|
-
state;
|
|
2234
|
-
blobs;
|
|
2235
|
-
transport;
|
|
2236
|
-
async flush(root, now = Date.now()) {
|
|
2237
|
-
const pending = this.state.pending(root);
|
|
2238
|
-
if (!pending || pending.retryAt > now) return false;
|
|
3032
|
+
// apps/client/sessions/source.ts
|
|
3033
|
+
var MAX_SOURCE_BYTES = 64 * 1024 * 1024;
|
|
3034
|
+
async function sourceSignature(path, agent) {
|
|
3035
|
+
const paths = agent === "cursor" ? [path, `${path}-wal`] : [path];
|
|
3036
|
+
const signatures = [];
|
|
3037
|
+
for (const file of paths) {
|
|
2239
3038
|
try {
|
|
2240
|
-
const
|
|
2241
|
-
|
|
2242
|
-
|
|
2243
|
-
|
|
2244
|
-
|
|
2245
|
-
)
|
|
2246
|
-
|
|
2247
|
-
|
|
2248
|
-
|
|
2249
|
-
if (!hashes.includes(hash2)) throw new Error("Server requested unrelated content.");
|
|
2250
|
-
const content = await this.blobs.get(hash2);
|
|
2251
|
-
if (!content) throw new Error("A queued content snapshot is missing from local storage.");
|
|
2252
|
-
const result = await this.transport.uploadBlob(hash2, content);
|
|
2253
|
-
if (result.sha256 !== hash2 || result.size !== content.byteLength)
|
|
2254
|
-
throw new Error("Server acknowledged different content.");
|
|
2255
|
-
}
|
|
2256
|
-
this.state.acknowledge(root, await this.transport.commitFiles(pending.batch));
|
|
2257
|
-
return true;
|
|
3039
|
+
const s = await stat(file, { bigint: true });
|
|
3040
|
+
if (!s.isFile()) throw new Error("Session source must be a regular file.");
|
|
3041
|
+
signatures.push([
|
|
3042
|
+
String(s.dev),
|
|
3043
|
+
String(s.ino),
|
|
3044
|
+
String(s.size),
|
|
3045
|
+
String(s.mtimeNs),
|
|
3046
|
+
String(s.ctimeNs)
|
|
3047
|
+
]);
|
|
2258
3048
|
} catch (error) {
|
|
2259
|
-
|
|
2260
|
-
|
|
2261
|
-
|
|
2262
|
-
pending.batch.operationId,
|
|
2263
|
-
error instanceof Error ? error.message : "Publication failed.",
|
|
2264
|
-
now,
|
|
2265
|
-
blocked
|
|
2266
|
-
);
|
|
2267
|
-
throw error;
|
|
3049
|
+
if (file !== path && error.code === "ENOENT")
|
|
3050
|
+
signatures.push(null);
|
|
3051
|
+
else throw error;
|
|
2268
3052
|
}
|
|
2269
3053
|
}
|
|
2270
|
-
|
|
2271
|
-
|
|
2272
|
-
|
|
2273
|
-
|
|
2274
|
-
|
|
2275
|
-
|
|
2276
|
-
|
|
2277
|
-
|
|
2278
|
-
|
|
2279
|
-
if (!binding) throw new Error("Connect this root before synchronizing.");
|
|
2280
|
-
this.binding = binding;
|
|
2281
|
-
this.client = new HubClient(binding.serverUrl, 3e4, binding, this.abort.signal);
|
|
2282
|
-
this.blobs = new LocalBlobStore(join9(stateDirectory, "blobs"));
|
|
2283
|
-
this.queue = new PublicationQueue(state, this.blobs, this.client);
|
|
2284
|
-
this.index = new SyncState(state, root);
|
|
2285
|
-
this.ownRoot = join9(root, binding.memberId);
|
|
2286
|
-
const started = performance.now();
|
|
2287
|
-
const epoch = Date.now();
|
|
2288
|
-
this.clock = options.clock ?? (() => epoch + performance.now() - started);
|
|
2289
|
-
this.settleMs = options.settleMs ?? 6e4;
|
|
2290
|
-
this.index.restartWaiting(this.clock());
|
|
2291
|
-
}
|
|
2292
|
-
state;
|
|
2293
|
-
root;
|
|
2294
|
-
options;
|
|
2295
|
-
index;
|
|
2296
|
-
binding;
|
|
2297
|
-
client;
|
|
2298
|
-
blobs;
|
|
2299
|
-
queue;
|
|
2300
|
-
ownRoot;
|
|
2301
|
-
clock;
|
|
2302
|
-
settleMs;
|
|
2303
|
-
watcher;
|
|
2304
|
-
watchIssues = [];
|
|
2305
|
-
lastScan = -Infinity;
|
|
2306
|
-
busy = false;
|
|
2307
|
-
stopped = false;
|
|
2308
|
-
abort = new AbortController();
|
|
2309
|
-
async checkCapabilities() {
|
|
2310
|
-
const info = await this.client.serverInfo();
|
|
2311
|
-
if (!["identity", "blobs", "file-revisions"].every(
|
|
2312
|
-
(capability) => info.capabilities.includes(capability)
|
|
2313
|
-
))
|
|
2314
|
-
throw new Error("Server does not support file synchronization.");
|
|
2315
|
-
}
|
|
2316
|
-
setSettleMs(value) {
|
|
2317
|
-
if (!Number.isSafeInteger(value) || value < 0 || value > 864e5)
|
|
2318
|
-
throw new Error("Invalid stable wait interval.");
|
|
2319
|
-
this.settleMs = value;
|
|
2320
|
-
}
|
|
2321
|
-
close() {
|
|
2322
|
-
this.stopped = true;
|
|
2323
|
-
this.abort.abort();
|
|
2324
|
-
this.watcher?.close();
|
|
2325
|
-
this.watcher = void 0;
|
|
2326
|
-
}
|
|
2327
|
-
async cycle() {
|
|
2328
|
-
if (this.busy || this.stopped) throw new Error("Synchronizer is already running or closed.");
|
|
2329
|
-
this.busy = true;
|
|
2330
|
-
const settleMs = this.settleMs;
|
|
2331
|
-
const report = {
|
|
2332
|
-
published: false,
|
|
2333
|
-
appliedFiles: 0,
|
|
2334
|
-
waitingFiles: 0,
|
|
2335
|
-
pendingPublication: false,
|
|
2336
|
-
issues: this.watchIssues.splice(0)
|
|
2337
|
-
};
|
|
3054
|
+
return JSON.stringify(signatures);
|
|
3055
|
+
}
|
|
3056
|
+
async function readSource(agent, path, projectDir) {
|
|
3057
|
+
const signature2 = await sourceSignature(path, agent);
|
|
3058
|
+
let result2;
|
|
3059
|
+
if (agent === "cursor") result2 = { snapshot: readCursor(path, projectDir), incomplete: false };
|
|
3060
|
+
else {
|
|
3061
|
+
const handle = await open4(path, "r");
|
|
3062
|
+
let bytes;
|
|
2338
3063
|
try {
|
|
2339
|
-
|
|
2340
|
-
|
|
2341
|
-
|
|
2342
|
-
|
|
2343
|
-
|
|
2344
|
-
|
|
2345
|
-
if (
|
|
2346
|
-
|
|
2347
|
-
this.lastScan = this.clock();
|
|
2348
|
-
}
|
|
2349
|
-
const blocked = await this.refreshDirty(report.issues);
|
|
2350
|
-
await this.audit(report.issues);
|
|
2351
|
-
report.published = await this.queue.flush(this.root, this.clock());
|
|
2352
|
-
if (!this.state.pending(this.root) && !this.stopped) {
|
|
2353
|
-
await this.stage(blocked, report.issues, settleMs);
|
|
2354
|
-
report.published = await this.queue.flush(this.root, this.clock()) || report.published;
|
|
2355
|
-
}
|
|
2356
|
-
} catch (error) {
|
|
2357
|
-
report.issues.push(`publish: ${message(error)}`);
|
|
2358
|
-
}
|
|
2359
|
-
if (!this.stopped) {
|
|
2360
|
-
try {
|
|
2361
|
-
for (const member of (await this.client.members()).members) {
|
|
2362
|
-
if (member === this.binding.memberId || this.stopped) continue;
|
|
2363
|
-
try {
|
|
2364
|
-
report.appliedFiles += await this.receive(member);
|
|
2365
|
-
} catch (error) {
|
|
2366
|
-
report.issues.push(`${member}: ${message(error)}`);
|
|
2367
|
-
}
|
|
2368
|
-
}
|
|
2369
|
-
} catch (error) {
|
|
2370
|
-
report.issues.push(`receive: ${message(error)}`);
|
|
2371
|
-
}
|
|
3064
|
+
const size = (await handle.stat()).size;
|
|
3065
|
+
if (size > MAX_SOURCE_BYTES) throw new Error("Session source exceeds 64 MiB.");
|
|
3066
|
+
bytes = Buffer.alloc(size + 1);
|
|
3067
|
+
let offset = 0;
|
|
3068
|
+
while (offset < bytes.length) {
|
|
3069
|
+
const read = await handle.read(bytes, offset, bytes.length - offset, offset);
|
|
3070
|
+
if (!read.bytesRead) break;
|
|
3071
|
+
offset += read.bytesRead;
|
|
2372
3072
|
}
|
|
2373
|
-
|
|
2374
|
-
|
|
2375
|
-
this.index.saveStatus({ ...report, checkedAt: (/* @__PURE__ */ new Date()).toISOString() });
|
|
2376
|
-
return report;
|
|
3073
|
+
bytes = bytes.subarray(0, offset);
|
|
3074
|
+
if (offset > size) throw new Error("Session source changed while reading; retry next cycle.");
|
|
2377
3075
|
} finally {
|
|
2378
|
-
|
|
2379
|
-
}
|
|
2380
|
-
}
|
|
2381
|
-
async manifest(owner) {
|
|
2382
|
-
let revision;
|
|
2383
|
-
let after = "";
|
|
2384
|
-
const files = [];
|
|
2385
|
-
for (; ; ) {
|
|
2386
|
-
const page = await this.client.fileManifest(owner, revision, after);
|
|
2387
|
-
if (revision !== void 0 && page.revision !== revision)
|
|
2388
|
-
throw new Error("Manifest changed version during pagination.");
|
|
2389
|
-
revision = page.revision;
|
|
2390
|
-
files.push(...page.files);
|
|
2391
|
-
if (page.nextCursor === null) return { revision, files };
|
|
2392
|
-
if (page.nextCursor === after || files.length > 1e5)
|
|
2393
|
-
throw new Error("Manifest pagination did not make bounded progress.");
|
|
2394
|
-
after = page.nextCursor;
|
|
3076
|
+
await handle.close();
|
|
2395
3077
|
}
|
|
2396
|
-
|
|
2397
|
-
|
|
2398
|
-
|
|
2399
|
-
const manifest = await this.manifest(this.binding.memberId);
|
|
2400
|
-
if (manifest.revision > 0) {
|
|
2401
|
-
if (!this.options.adoptBaseline)
|
|
3078
|
+
if (agent === "dsh" && path.endsWith(".zstd")) {
|
|
3079
|
+
const decompress = zlib.zstdDecompressSync;
|
|
3080
|
+
if (!decompress)
|
|
2402
3081
|
throw new Error(
|
|
2403
|
-
"
|
|
3082
|
+
"Compressed DSH sessions require Node.js with zstd support (22.15+ or 24+)."
|
|
2404
3083
|
);
|
|
2405
|
-
|
|
2406
|
-
const local = await readStable(this.ownRoot, file.path);
|
|
2407
|
-
if (local.sha256 !== file.sha256)
|
|
2408
|
-
throw new Error(
|
|
2409
|
-
`Cannot adopt baseline: local ${file.path} differs from the published file.`
|
|
2410
|
-
);
|
|
2411
|
-
}
|
|
2412
|
-
} else {
|
|
2413
|
-
const ownerPath = await safePath(this.root, this.binding.memberId);
|
|
2414
|
-
await mkdir6(ownerPath).catch((error) => {
|
|
2415
|
-
if (error.code !== "EEXIST") throw error;
|
|
2416
|
-
});
|
|
3084
|
+
bytes = decompress(bytes, { maxOutputLength: MAX_SOURCE_BYTES });
|
|
2417
3085
|
}
|
|
2418
|
-
|
|
3086
|
+
result2 = normalizeJsonl(agent, new TextDecoder("utf-8", { fatal: true }).decode(bytes));
|
|
2419
3087
|
}
|
|
2420
|
-
|
|
2421
|
-
|
|
2422
|
-
|
|
2423
|
-
|
|
2424
|
-
|
|
2425
|
-
|
|
2426
|
-
|
|
2427
|
-
|
|
2428
|
-
|
|
2429
|
-
|
|
2430
|
-
|
|
2431
|
-
|
|
2432
|
-
|
|
2433
|
-
|
|
2434
|
-
|
|
2435
|
-
|
|
2436
|
-
|
|
2437
|
-
|
|
2438
|
-
|
|
3088
|
+
if (signature2 !== await sourceSignature(path, agent))
|
|
3089
|
+
throw new Error("Session source changed while reading; retry next cycle.");
|
|
3090
|
+
const cwd = await realpath5(result2.snapshot.cwd);
|
|
3091
|
+
const project = await realpath5(projectDir);
|
|
3092
|
+
const rel = relative4(project, cwd);
|
|
3093
|
+
if (rel === ".." || rel.startsWith(`..${sep5}`) || isAbsolute4(rel))
|
|
3094
|
+
throw new Error("Session cwd is outside the registered project.");
|
|
3095
|
+
return { ...result2, signature: signature2 };
|
|
3096
|
+
}
|
|
3097
|
+
|
|
3098
|
+
// apps/client/sessions/discovery.ts
|
|
3099
|
+
import { opendir as opendir2 } from "node:fs/promises";
|
|
3100
|
+
import { join as join13 } from "node:path";
|
|
3101
|
+
async function* candidates(directory, agent) {
|
|
3102
|
+
const queue = [directory];
|
|
3103
|
+
while (queue.length) {
|
|
3104
|
+
const current = queue.shift();
|
|
3105
|
+
const dir = await opendir2(current);
|
|
3106
|
+
for await (const entry of dir) {
|
|
3107
|
+
const path = join13(current, entry.name);
|
|
3108
|
+
if (entry.isDirectory()) queue.push(path);
|
|
3109
|
+
if (entry.isFile() && (agent === "cursor" ? entry.name === "store.db" : entry.name.endsWith(".jsonl") || agent === "dsh" && entry.name.endsWith(".jsonl.zstd")))
|
|
3110
|
+
yield path;
|
|
3111
|
+
else yield null;
|
|
2439
3112
|
}
|
|
2440
3113
|
}
|
|
2441
|
-
|
|
2442
|
-
|
|
2443
|
-
|
|
2444
|
-
|
|
2445
|
-
if (scan.complete) {
|
|
2446
|
-
for (const prior of this.index.index()) {
|
|
2447
|
-
if ((!prefix || prior.path.startsWith(`${prefix}/`)) && !ignored(prior.path) && !scan.files.has(prior.path))
|
|
2448
|
-
this.index.observe(prior.path, null, now);
|
|
2449
|
-
}
|
|
2450
|
-
}
|
|
2451
|
-
issues.push(...scan.issues);
|
|
3114
|
+
}
|
|
3115
|
+
var SessionDiscovery = class {
|
|
3116
|
+
constructor(state) {
|
|
3117
|
+
this.state = state;
|
|
2452
3118
|
}
|
|
2453
|
-
|
|
2454
|
-
|
|
2455
|
-
|
|
2456
|
-
|
|
2457
|
-
|
|
2458
|
-
|
|
2459
|
-
|
|
2460
|
-
|
|
2461
|
-
|
|
2462
|
-
|
|
2463
|
-
|
|
2464
|
-
blocked.add(prior.path);
|
|
2465
|
-
issues.push(`${prior.path}: ${message(error)}`);
|
|
3119
|
+
state;
|
|
3120
|
+
scans = /* @__PURE__ */ new Map();
|
|
3121
|
+
async cycle() {
|
|
3122
|
+
const issues = [];
|
|
3123
|
+
let entries = 500;
|
|
3124
|
+
let reads = 5;
|
|
3125
|
+
const configs = this.state.directories();
|
|
3126
|
+
for (const [directory, scan] of this.scans) {
|
|
3127
|
+
if (!configs.some((config) => config.directory === directory)) {
|
|
3128
|
+
await scan.iterator.return(void 0);
|
|
3129
|
+
this.scans.delete(directory);
|
|
2466
3130
|
}
|
|
2467
3131
|
}
|
|
2468
|
-
|
|
2469
|
-
|
|
2470
|
-
|
|
2471
|
-
|
|
2472
|
-
|
|
2473
|
-
|
|
2474
|
-
|
|
2475
|
-
)) {
|
|
2476
|
-
if (this.stopped || bytes > 20 * 1024 * 1024) break;
|
|
3132
|
+
for (const config of configs) {
|
|
3133
|
+
let scan = this.scans.get(config.directory);
|
|
3134
|
+
if (!scan) {
|
|
3135
|
+
scan = { iterator: candidates(config.directory, config.agent), nextAt: 0 };
|
|
3136
|
+
this.scans.set(config.directory, scan);
|
|
3137
|
+
}
|
|
3138
|
+
if (scan.nextAt > Date.now()) continue;
|
|
2477
3139
|
try {
|
|
2478
|
-
|
|
2479
|
-
|
|
2480
|
-
|
|
2481
|
-
|
|
2482
|
-
|
|
3140
|
+
while (entries > 0 && reads > 0) {
|
|
3141
|
+
entries--;
|
|
3142
|
+
const entry = await scan.iterator.next();
|
|
3143
|
+
if (entry.done) {
|
|
3144
|
+
scan.iterator = candidates(config.directory, config.agent);
|
|
3145
|
+
scan.nextAt = Date.now() + 3e5;
|
|
3146
|
+
break;
|
|
3147
|
+
}
|
|
3148
|
+
const path = entry.value;
|
|
3149
|
+
if (!path || this.state.get(path)) continue;
|
|
3150
|
+
const signature2 = await sourceSignature(path, config.agent);
|
|
3151
|
+
const db = this.state.state.db;
|
|
3152
|
+
if (db.prepare(
|
|
3153
|
+
"SELECT 1 FROM session_discovery_cache WHERE root=? AND path=? AND signature=?"
|
|
3154
|
+
).get(this.state.root, path, signature2))
|
|
3155
|
+
continue;
|
|
3156
|
+
reads--;
|
|
3157
|
+
try {
|
|
3158
|
+
const result2 = await readSource(config.agent, path, config.projectDir);
|
|
3159
|
+
if (result2.incomplete) continue;
|
|
3160
|
+
this.state.track(config.agent, path, config.projectDir);
|
|
3161
|
+
} catch (error) {
|
|
3162
|
+
const message2 = error instanceof Error ? error.message : "Discovery failed.";
|
|
3163
|
+
if (!message2.includes("outside the registered project"))
|
|
3164
|
+
issues.push(`${path}: ${message2}`);
|
|
3165
|
+
if (!message2.includes("outside the registered project")) continue;
|
|
3166
|
+
}
|
|
3167
|
+
db.prepare(
|
|
3168
|
+
"INSERT INTO session_discovery_cache VALUES (?,?,?) ON CONFLICT(root,path) DO UPDATE SET signature=excluded.signature"
|
|
3169
|
+
).run(this.state.root, path, signature2);
|
|
3170
|
+
}
|
|
2483
3171
|
} catch (error) {
|
|
2484
|
-
|
|
2485
|
-
|
|
3172
|
+
issues.push(
|
|
3173
|
+
`${config.directory}: ${error instanceof Error ? error.message : "Discovery failed."}`
|
|
3174
|
+
);
|
|
3175
|
+
await scan.iterator.return(void 0);
|
|
3176
|
+
scan.iterator = candidates(config.directory, config.agent);
|
|
3177
|
+
scan.nextAt = Date.now() + 3e5;
|
|
2486
3178
|
}
|
|
2487
3179
|
}
|
|
3180
|
+
return issues;
|
|
2488
3181
|
}
|
|
2489
|
-
async
|
|
2490
|
-
const
|
|
2491
|
-
|
|
2492
|
-
|
|
2493
|
-
|
|
2494
|
-
|
|
2495
|
-
|
|
2496
|
-
|
|
2497
|
-
|
|
2498
|
-
|
|
2499
|
-
|
|
2500
|
-
|
|
2501
|
-
|
|
3182
|
+
async close() {
|
|
3183
|
+
for (const scan of this.scans.values()) await scan.iterator.return(void 0);
|
|
3184
|
+
}
|
|
3185
|
+
};
|
|
3186
|
+
|
|
3187
|
+
// apps/client/sessions/synchronizer.ts
|
|
3188
|
+
var SessionSynchronizer = class {
|
|
3189
|
+
constructor(state, root, directory, signal) {
|
|
3190
|
+
this.root = root;
|
|
3191
|
+
this.directory = directory;
|
|
3192
|
+
const binding = state.binding(root);
|
|
3193
|
+
if (!binding) throw new Error("Connect this root before collecting sessions.");
|
|
3194
|
+
this.client = new HubClient(binding.serverUrl, 1e4, binding, signal);
|
|
3195
|
+
this.blobs = new LocalBlobStore(join14(directory, "blobs"));
|
|
3196
|
+
this.index = new SessionState(state, root);
|
|
3197
|
+
this.discovery = new SessionDiscovery(this.index);
|
|
3198
|
+
}
|
|
3199
|
+
root;
|
|
3200
|
+
directory;
|
|
3201
|
+
index;
|
|
3202
|
+
client;
|
|
3203
|
+
blobs;
|
|
3204
|
+
discovery;
|
|
3205
|
+
capable = false;
|
|
3206
|
+
async cycle() {
|
|
3207
|
+
const report = { published: 0, checked: 0, issues: [] };
|
|
3208
|
+
report.issues.push(...await consumeHints(this.directory, this.root, this.index));
|
|
3209
|
+
report.issues.push(...await this.discovery.cycle());
|
|
3210
|
+
const sources = this.index.sources(void 0, 200);
|
|
3211
|
+
if (sources.length && !this.capable) {
|
|
3212
|
+
const info = await this.client.serverInfo();
|
|
3213
|
+
if (!["blobs", "session-revisions"].every(
|
|
3214
|
+
(capability) => info.capabilities.includes(capability)
|
|
3215
|
+
))
|
|
3216
|
+
throw new Error("Server does not support session publication. Upgrade the server first.");
|
|
3217
|
+
this.capable = true;
|
|
3218
|
+
}
|
|
3219
|
+
let reads = 5;
|
|
3220
|
+
let uploads = 5;
|
|
3221
|
+
for (const source of sources) {
|
|
3222
|
+
if (reads <= 0 || uploads <= 0 || report.checked >= 200) break;
|
|
3223
|
+
report.checked++;
|
|
3224
|
+
this.index.checked(source.path, source.lastError);
|
|
2502
3225
|
try {
|
|
2503
|
-
if (
|
|
2504
|
-
|
|
2505
|
-
|
|
2506
|
-
|
|
2507
|
-
continue;
|
|
2508
|
-
}
|
|
2509
|
-
if (baseline.has(row.path)) {
|
|
2510
|
-
changes.push({ kind: "delete", path: row.path });
|
|
2511
|
-
planned.delete(row.path);
|
|
2512
|
-
} else {
|
|
2513
|
-
this.index.clean(row.path, row.generation, null, this.clock());
|
|
3226
|
+
if (source.pending) {
|
|
3227
|
+
if (source.retryAt > Date.now()) {
|
|
3228
|
+
if (source.lastError)
|
|
3229
|
+
report.issues.push(`${source.path}: ${source.lastError} (retry deferred)`);
|
|
2514
3230
|
continue;
|
|
2515
3231
|
}
|
|
2516
|
-
|
|
3232
|
+
uploads--;
|
|
3233
|
+
await this.flush(source);
|
|
3234
|
+
report.published++;
|
|
2517
3235
|
continue;
|
|
2518
3236
|
}
|
|
2519
|
-
const
|
|
2520
|
-
|
|
2521
|
-
|
|
2522
|
-
);
|
|
2523
|
-
if (collision) continue;
|
|
2524
|
-
const local = await readStable(this.ownRoot, row.path);
|
|
2525
|
-
bytes += local.bytes.length;
|
|
2526
|
-
if (local.signature !== row.signature || this.index.get(row.path)?.generation !== row.generation) {
|
|
2527
|
-
this.index.event(row.path, this.clock());
|
|
3237
|
+
const signature2 = await sourceSignature(source.path, source.agent);
|
|
3238
|
+
if (signature2 === source.signature && Date.now() - source.verifiedAt < 864e5) {
|
|
3239
|
+
this.index.checked(source.path);
|
|
2528
3240
|
continue;
|
|
2529
3241
|
}
|
|
2530
|
-
|
|
2531
|
-
|
|
3242
|
+
reads--;
|
|
3243
|
+
const observed = await readSource(source.agent, source.path, source.projectDir);
|
|
3244
|
+
if (observed.incomplete)
|
|
3245
|
+
throw new Error("Final session record is incomplete; waiting for the next save.");
|
|
3246
|
+
if (!observed.snapshot.messages.length) {
|
|
3247
|
+
this.index.checked(source.path);
|
|
3248
|
+
continue;
|
|
3249
|
+
}
|
|
3250
|
+
const key = sessionKey(source.agent, observed.snapshot.nativeId);
|
|
3251
|
+
if (source.key && source.key !== key)
|
|
3252
|
+
throw new Error(
|
|
3253
|
+
"Native session identity changed at this path; register the new source explicitly."
|
|
3254
|
+
);
|
|
3255
|
+
const bytes = Buffer.from(JSON.stringify(observed.snapshot));
|
|
3256
|
+
if (bytes.length > MAX_BLOB_BYTES)
|
|
3257
|
+
throw new Error("Normalized session exceeds the 10 MiB snapshot limit.");
|
|
3258
|
+
const blob = await this.blobs.put(bytes);
|
|
3259
|
+
let revision = source.revision;
|
|
3260
|
+
if (!source.key) {
|
|
3261
|
+
try {
|
|
3262
|
+
const owner = this.index.state.binding(this.root).memberId;
|
|
3263
|
+
const current = await this.client.session(owner, key);
|
|
3264
|
+
if (current.blob.sha256 !== blob.sha256 && !source.adoptExisting)
|
|
3265
|
+
throw new Error(
|
|
3266
|
+
"Server session already exists with different content; inspect it, then track with --adopt-existing to continue."
|
|
3267
|
+
);
|
|
3268
|
+
revision = current.revision;
|
|
3269
|
+
} catch (error) {
|
|
3270
|
+
if (!(error instanceof HubRequestError && error.code === "SESSION_NOT_FOUND"))
|
|
3271
|
+
throw error;
|
|
3272
|
+
}
|
|
3273
|
+
this.index.baseline(source.path, key, revision);
|
|
3274
|
+
}
|
|
3275
|
+
if (blob.sha256 === source.hash) {
|
|
3276
|
+
this.index.acknowledge(source.path, revision, blob.sha256, observed.signature);
|
|
2532
3277
|
continue;
|
|
2533
3278
|
}
|
|
2534
|
-
|
|
2535
|
-
|
|
2536
|
-
|
|
2537
|
-
|
|
3279
|
+
this.index.stage(
|
|
3280
|
+
source.path,
|
|
3281
|
+
{ operationId: randomUUID7(), sessionKey: key, parentRevision: revision, blob },
|
|
3282
|
+
observed.signature
|
|
3283
|
+
);
|
|
3284
|
+
uploads--;
|
|
3285
|
+
await this.flush(this.index.get(source.path));
|
|
3286
|
+
report.published++;
|
|
2538
3287
|
} catch (error) {
|
|
2539
|
-
|
|
2540
|
-
|
|
3288
|
+
const message2 = error instanceof Error ? error.message : "Session synchronization failed.";
|
|
3289
|
+
this.index.checked(source.path, message2);
|
|
3290
|
+
report.issues.push(`${source.path}: ${message2}`);
|
|
2541
3291
|
}
|
|
2542
3292
|
}
|
|
2543
|
-
|
|
2544
|
-
const rootStat = await lstat2(this.ownRoot);
|
|
2545
|
-
if (!rootStat.isDirectory() || rootStat.isSymbolicLink())
|
|
2546
|
-
throw new Error("Author directory disappeared before publication.");
|
|
2547
|
-
this.state.enqueue(this.root, {
|
|
2548
|
-
operationId: randomUUID7(),
|
|
2549
|
-
parentRevision: this.state.publication(this.root).revision,
|
|
2550
|
-
changes
|
|
2551
|
-
});
|
|
2552
|
-
for (const snapshot of snapshots)
|
|
2553
|
-
this.index.clean(snapshot.path, snapshot.generation, snapshot.hash, this.clock());
|
|
3293
|
+
return report;
|
|
2554
3294
|
}
|
|
2555
|
-
async
|
|
2556
|
-
|
|
2557
|
-
|
|
2558
|
-
|
|
2559
|
-
|
|
2560
|
-
|
|
2561
|
-
|
|
2562
|
-
|
|
2563
|
-
|
|
2564
|
-
|
|
2565
|
-
|
|
2566
|
-
|
|
2567
|
-
size: file.size
|
|
2568
|
-
}))
|
|
2569
|
-
);
|
|
2570
|
-
} else {
|
|
2571
|
-
const delta = await this.client.fileChanges(owner, revision);
|
|
2572
|
-
if (!delta.revisions.length) return 0;
|
|
2573
|
-
const changes = /* @__PURE__ */ new Map();
|
|
2574
|
-
let expected = revision + 1;
|
|
2575
|
-
for (const entry of delta.revisions) {
|
|
2576
|
-
if (entry.revision !== expected++) throw new Error("Server change history has a gap.");
|
|
2577
|
-
for (const change of entry.changes) changes.set(change.path, change);
|
|
2578
|
-
}
|
|
2579
|
-
const finalNames = new Set(
|
|
2580
|
-
[...changes.values()].filter((change) => change.kind === "upsert").map((change) => change.path.toLowerCase())
|
|
2581
|
-
);
|
|
2582
|
-
for (const change of changes.values()) {
|
|
2583
|
-
if (change.kind === "delete" && !this.index.received(owner, change.path) && finalNames.has(change.path.toLowerCase()))
|
|
2584
|
-
changes.delete(change.path);
|
|
2585
|
-
}
|
|
2586
|
-
const ordered = [...changes.values()].sort(
|
|
2587
|
-
(a, b) => a.kind !== b.kind ? a.kind === "delete" ? -1 : 1 : a.kind === "delete" ? b.path.length - a.path.length : a.path.length - b.path.length
|
|
2588
|
-
);
|
|
2589
|
-
this.index.queue(owner, delta.revisions.at(-1).revision, ordered);
|
|
3295
|
+
async flush(source) {
|
|
3296
|
+
const request = source.pending;
|
|
3297
|
+
try {
|
|
3298
|
+
const { missing: missing2 } = await this.client.missingBlobs([request.blob.sha256]);
|
|
3299
|
+
if (missing2.some((hash2) => hash2 !== request.blob.sha256))
|
|
3300
|
+
throw new Error("Server requested unrelated session content.");
|
|
3301
|
+
if (missing2.length) {
|
|
3302
|
+
const bytes = await this.blobs.get(request.blob.sha256);
|
|
3303
|
+
if (!bytes) throw new Error("Queued session snapshot is missing.");
|
|
3304
|
+
const ref = await this.client.uploadBlob(request.blob.sha256, bytes);
|
|
3305
|
+
if (ref.sha256 !== request.blob.sha256 || ref.size !== request.blob.size)
|
|
3306
|
+
throw new Error("Server acknowledged different session content.");
|
|
2590
3307
|
}
|
|
3308
|
+
const result2 = await this.client.commitSession(request);
|
|
3309
|
+
if (result2.operationId !== request.operationId || result2.revision < request.parentRevision || result2.revision > request.parentRevision + 1)
|
|
3310
|
+
throw new Error("Invalid session acknowledgement.");
|
|
3311
|
+
this.index.acknowledge(
|
|
3312
|
+
source.path,
|
|
3313
|
+
result2.revision,
|
|
3314
|
+
request.blob.sha256,
|
|
3315
|
+
source.pendingSignature
|
|
3316
|
+
);
|
|
3317
|
+
} catch (error) {
|
|
3318
|
+
const blocked = error instanceof HubRequestError && error.status >= 400 && error.status < 500 && ![408, 429].includes(error.status);
|
|
3319
|
+
this.index.postpone(
|
|
3320
|
+
source,
|
|
3321
|
+
error instanceof Error ? error.message : "Upload failed.",
|
|
3322
|
+
blocked
|
|
3323
|
+
);
|
|
3324
|
+
throw error;
|
|
2591
3325
|
}
|
|
2592
|
-
|
|
2593
|
-
|
|
2594
|
-
await
|
|
2595
|
-
|
|
3326
|
+
}
|
|
3327
|
+
async close() {
|
|
3328
|
+
await this.discovery.close();
|
|
3329
|
+
}
|
|
3330
|
+
};
|
|
3331
|
+
|
|
3332
|
+
// apps/client/runtime/job.ts
|
|
3333
|
+
var SyncJob = class {
|
|
3334
|
+
constructor(root, directory, version, log, options = {}) {
|
|
3335
|
+
this.root = root;
|
|
3336
|
+
this.log = log;
|
|
3337
|
+
this.lock = acquireRootLock(root, directory);
|
|
3338
|
+
let state;
|
|
3339
|
+
try {
|
|
3340
|
+
state = new ClientState(directory);
|
|
3341
|
+
this.state = state;
|
|
3342
|
+
this.runtime = new RuntimeState(state);
|
|
3343
|
+
this.files = options.sessionsOnly ? void 0 : new FileSynchronizer(state, root, directory, {
|
|
3344
|
+
...options.settleMs === void 0 ? {} : { settleMs: options.settleMs },
|
|
3345
|
+
adoptBaseline: Boolean(options.adoptBaseline),
|
|
3346
|
+
restoreMissing: Boolean(options.restoreMissing)
|
|
3347
|
+
});
|
|
3348
|
+
this.sessions = new SessionSynchronizer(state, root, directory, this.abort.signal);
|
|
3349
|
+
if (options.retry) {
|
|
3350
|
+
state.retryPublication(root);
|
|
3351
|
+
this.sessions.index.retry();
|
|
3352
|
+
}
|
|
3353
|
+
const overrides = {};
|
|
3354
|
+
if (options.intervalMs !== void 0)
|
|
3355
|
+
overrides.checkIntervalSeconds = options.intervalMs / 1e3;
|
|
3356
|
+
if (options.settleMs !== void 0) overrides.fileSettleSeconds = options.settleMs / 1e3;
|
|
3357
|
+
this.control = new ControlPlane(state, root, version, overrides, (policy) => {
|
|
3358
|
+
this.intervalMs = Math.round(policy.checkIntervalSeconds * 1e3);
|
|
3359
|
+
this.files?.setSettleMs(Math.round(policy.fileSettleSeconds * 1e3));
|
|
3360
|
+
});
|
|
3361
|
+
} catch (e) {
|
|
3362
|
+
state?.close();
|
|
3363
|
+
this.lock.close();
|
|
3364
|
+
throw e;
|
|
3365
|
+
}
|
|
3366
|
+
}
|
|
3367
|
+
root;
|
|
3368
|
+
log;
|
|
3369
|
+
state;
|
|
3370
|
+
runtime;
|
|
3371
|
+
control;
|
|
3372
|
+
files;
|
|
3373
|
+
sessions;
|
|
3374
|
+
lock;
|
|
3375
|
+
abort = new AbortController();
|
|
3376
|
+
pending;
|
|
3377
|
+
stopped = false;
|
|
3378
|
+
closing;
|
|
3379
|
+
controlStarted = false;
|
|
3380
|
+
lastIssue = "";
|
|
3381
|
+
issueCount = 0;
|
|
3382
|
+
lastLogAt = 0;
|
|
3383
|
+
nextAt = 0;
|
|
3384
|
+
intervalMs = 6e4;
|
|
3385
|
+
tick() {
|
|
3386
|
+
if (this.stopped) return Promise.reject(new Error("Synchronization stopped."));
|
|
3387
|
+
this.pending ??= this.cycle().finally(() => {
|
|
3388
|
+
this.pending = void 0;
|
|
2596
3389
|
});
|
|
2597
|
-
|
|
2598
|
-
|
|
2599
|
-
|
|
2600
|
-
|
|
2601
|
-
|
|
2602
|
-
|
|
2603
|
-
|
|
2604
|
-
|
|
2605
|
-
|
|
2606
|
-
|
|
2607
|
-
|
|
2608
|
-
|
|
2609
|
-
|
|
3390
|
+
return this.pending;
|
|
3391
|
+
}
|
|
3392
|
+
async once() {
|
|
3393
|
+
const prior = this.runtime.status(this.root);
|
|
3394
|
+
const report = await this.tick();
|
|
3395
|
+
if (this.control.enabled) await this.control.tick();
|
|
3396
|
+
const errors = [
|
|
3397
|
+
...report.issues,
|
|
3398
|
+
...report.sessions.issues,
|
|
3399
|
+
...this.control.enabled ? [this.control.status.configError, this.control.status.reportError].filter(
|
|
3400
|
+
(e) => Boolean(e)
|
|
3401
|
+
) : []
|
|
3402
|
+
].map(redact);
|
|
3403
|
+
if (errors[0] && !this.stopped) {
|
|
3404
|
+
const status = this.runtime.status(this.root);
|
|
3405
|
+
this.runtime.save(this.root, {
|
|
3406
|
+
...status,
|
|
3407
|
+
state: "waiting",
|
|
3408
|
+
lastSuccessAt: prior?.lastSuccessAt ?? null,
|
|
3409
|
+
issue: errors[0]
|
|
3410
|
+
});
|
|
3411
|
+
if (errors[0] !== report.errors[0]) this.log.write("sync.waiting", this.root, errors[0]);
|
|
2610
3412
|
}
|
|
2611
|
-
|
|
2612
|
-
return count;
|
|
3413
|
+
return { ...report, errors, control: this.control.enabled ? this.control.status : null };
|
|
2613
3414
|
}
|
|
2614
|
-
async
|
|
2615
|
-
const prior = this.
|
|
2616
|
-
|
|
2617
|
-
|
|
2618
|
-
|
|
2619
|
-
|
|
2620
|
-
|
|
2621
|
-
|
|
2622
|
-
|
|
2623
|
-
|
|
2624
|
-
|
|
3415
|
+
async cycle() {
|
|
3416
|
+
const prior = this.runtime.status(this.root);
|
|
3417
|
+
this.runtime.save(this.root, {
|
|
3418
|
+
state: "syncing",
|
|
3419
|
+
checkedAt: prior?.checkedAt ?? null,
|
|
3420
|
+
lastSuccessAt: prior?.lastSuccessAt ?? null,
|
|
3421
|
+
pendingFiles: prior?.pendingFiles ?? 0,
|
|
3422
|
+
pendingDownloads: prior?.pendingDownloads ?? 0,
|
|
3423
|
+
issue: null
|
|
3424
|
+
});
|
|
3425
|
+
let report = {
|
|
3426
|
+
published: false,
|
|
3427
|
+
appliedFiles: 0,
|
|
3428
|
+
waitingFiles: 0,
|
|
3429
|
+
pendingPublication: false,
|
|
3430
|
+
issues: []
|
|
3431
|
+
};
|
|
3432
|
+
let sessions = { published: 0, checked: 0, issues: [] };
|
|
3433
|
+
try {
|
|
3434
|
+
await verifyWorkspace(this.state, this.root);
|
|
3435
|
+
if (!this.controlStarted) {
|
|
3436
|
+
await this.control.tick();
|
|
3437
|
+
this.control.start();
|
|
3438
|
+
this.controlStarted = true;
|
|
3439
|
+
}
|
|
3440
|
+
if (this.files) report = await this.files.cycle();
|
|
3441
|
+
sessions = await this.sessions.cycle();
|
|
3442
|
+
} catch (e) {
|
|
3443
|
+
report.issues.push(diagnostic(e));
|
|
3444
|
+
}
|
|
3445
|
+
const errors = [
|
|
3446
|
+
...report.issues,
|
|
3447
|
+
...sessions.issues,
|
|
3448
|
+
...this.control.enabled ? [this.control.status.configError, this.control.status.reportError].filter(
|
|
3449
|
+
(e) => Boolean(e)
|
|
3450
|
+
) : []
|
|
3451
|
+
].map(redact);
|
|
3452
|
+
const pendingDownloads = Number(
|
|
3453
|
+
this.state.db.prepare(
|
|
3454
|
+
"SELECT COALESCE(SUM(json_array_length(changes_json)-next_index),0) n FROM receive_jobs WHERE root=?"
|
|
3455
|
+
).get(this.root).n
|
|
3456
|
+
);
|
|
3457
|
+
const pendingFiles = report.waitingFiles + (this.state.pending(this.root)?.batch.changes.length ?? 0);
|
|
3458
|
+
const now = Date.now(), checkedAt = new Date(now).toISOString();
|
|
3459
|
+
this.control.updateSync({
|
|
3460
|
+
checkedAt,
|
|
3461
|
+
published: report.published,
|
|
3462
|
+
appliedFiles: report.appliedFiles,
|
|
3463
|
+
pendingFiles,
|
|
3464
|
+
sessionSources: Number(
|
|
3465
|
+
this.state.db.prepare("SELECT COUNT(*) n FROM session_sources WHERE root=?").get(this.root).n
|
|
3466
|
+
),
|
|
3467
|
+
errors: errors.slice(0, 20).map((e) => e.slice(0, 1e3))
|
|
3468
|
+
});
|
|
3469
|
+
const issue = errors[0] ?? "";
|
|
3470
|
+
const issueKey = issue.replace(/[a-f0-9]{8}(?:-[a-f0-9]{4}){3}-[a-f0-9]{12}/gi, "[request]");
|
|
3471
|
+
if (issue) {
|
|
3472
|
+
this.issueCount++;
|
|
3473
|
+
if (issueKey !== this.lastIssue || now - this.lastLogAt >= 9e5) {
|
|
3474
|
+
this.log.write("sync.waiting", this.root, issue);
|
|
3475
|
+
this.lastLogAt = now;
|
|
3476
|
+
}
|
|
2625
3477
|
} else {
|
|
2626
|
-
if (
|
|
2627
|
-
|
|
2628
|
-
|
|
2629
|
-
if (
|
|
2630
|
-
|
|
2631
|
-
|
|
3478
|
+
if (this.lastIssue)
|
|
3479
|
+
this.log.write("sync.recovered", this.root, `${this.issueCount} failed checks`);
|
|
3480
|
+
this.issueCount = 0;
|
|
3481
|
+
if (report.published || report.appliedFiles || sessions.published)
|
|
3482
|
+
this.log.write(
|
|
3483
|
+
"sync.changed",
|
|
3484
|
+
this.root,
|
|
3485
|
+
`files received=${report.appliedFiles}, files published=${Number(report.published)}, sessions published=${sessions.published}`
|
|
2632
3486
|
);
|
|
2633
|
-
let content = await this.blobs.get(change.sha256);
|
|
2634
|
-
if (!content) {
|
|
2635
|
-
content = await this.client.downloadBlob(change.sha256);
|
|
2636
|
-
await this.blobs.put(content);
|
|
2637
|
-
}
|
|
2638
|
-
if (content.byteLength !== change.size)
|
|
2639
|
-
throw new Error(`Content size mismatch: ${change.path}`);
|
|
2640
|
-
await replaceFile(root, change.path, content, local?.signature ?? null);
|
|
2641
3487
|
}
|
|
3488
|
+
this.lastIssue = issueKey;
|
|
3489
|
+
this.nextAt = now + (issue ? Math.min(9e5, Math.max(this.intervalMs, 1e4) * 2 ** Math.min(this.issueCount - 1, 5)) : pendingDownloads ? 250 : this.intervalMs);
|
|
3490
|
+
this.runtime.save(this.root, {
|
|
3491
|
+
state: this.stopped ? "stopped" : issue ? "waiting" : "idle",
|
|
3492
|
+
checkedAt,
|
|
3493
|
+
lastSuccessAt: !issue && !pendingDownloads ? checkedAt : prior?.lastSuccessAt ?? null,
|
|
3494
|
+
pendingFiles,
|
|
3495
|
+
pendingDownloads,
|
|
3496
|
+
issue: issue || null
|
|
3497
|
+
});
|
|
3498
|
+
return {
|
|
3499
|
+
...report,
|
|
3500
|
+
sessions,
|
|
3501
|
+
pendingDownloads,
|
|
3502
|
+
control: this.control.enabled ? this.control.status : null,
|
|
3503
|
+
errors
|
|
3504
|
+
};
|
|
3505
|
+
}
|
|
3506
|
+
close() {
|
|
3507
|
+
if (this.closing) return this.closing;
|
|
3508
|
+
this.stopped = true;
|
|
3509
|
+
this.abort.abort();
|
|
3510
|
+
this.files?.close();
|
|
3511
|
+
return this.closing = (async () => {
|
|
3512
|
+
await this.control.close();
|
|
3513
|
+
await this.pending?.catch(() => {
|
|
3514
|
+
});
|
|
3515
|
+
await this.sessions.close();
|
|
3516
|
+
const prior = this.runtime.status(this.root);
|
|
3517
|
+
if (prior) this.runtime.save(this.root, { ...prior, state: "stopped" });
|
|
3518
|
+
this.state.close();
|
|
3519
|
+
this.lock.close();
|
|
3520
|
+
})();
|
|
2642
3521
|
}
|
|
2643
3522
|
};
|
|
2644
|
-
function message(error) {
|
|
2645
|
-
return error instanceof Error ? error.message : String(error);
|
|
2646
|
-
}
|
|
2647
3523
|
|
|
2648
3524
|
// apps/client/sync-command.ts
|
|
2649
|
-
var help2 = `Usage: teammy sync [
|
|
3525
|
+
var help2 = `Usage: teammy sync on | off [--root DIR]
|
|
2650
3526
|
|
|
2651
|
-
|
|
2652
|
-
|
|
2653
|
-
--once Run one check
|
|
2654
|
-
--
|
|
2655
|
-
--
|
|
2656
|
-
--adopt-baseline Accept existing server history only if every published file matches locally
|
|
2657
|
-
--restore-missing Restore missing mirror files; existing local edits are still preserved
|
|
2658
|
-
--retry Retry file/session publications paused by a previous failure
|
|
2659
|
-
--sessions-only Collect sessions without applying shared files
|
|
2660
|
-
-h, --help Show this help
|
|
3527
|
+
on Start background sync and resume after login
|
|
3528
|
+
off Stop this project's sync, including after login
|
|
3529
|
+
--once Run one check without changing the enabled state
|
|
3530
|
+
--foreground Run in this terminal for diagnostics (turn background sync off first)
|
|
3531
|
+
--json Machine-readable output
|
|
2661
3532
|
|
|
2662
|
-
|
|
2663
|
-
|
|
2664
|
-
|
|
3533
|
+
Advanced: --state-dir DIR, --interval-ms N, --settle-ms N,
|
|
3534
|
+
--restore-missing, --adopt-baseline, --retry, --sessions-only
|
|
3535
|
+
Use teammy status or teammy logs for diagnostics.
|
|
2665
3536
|
`;
|
|
2666
3537
|
async function runSyncCommand(args, version) {
|
|
2667
|
-
const
|
|
2668
|
-
|
|
3538
|
+
const action = ["on", "off"].includes(args[0] ?? "") ? args[0] : void 0;
|
|
3539
|
+
const { values } = parseArgs3({
|
|
3540
|
+
args: action ? args.slice(1) : args,
|
|
2669
3541
|
options: {
|
|
2670
|
-
|
|
2671
|
-
"state-dir": { type: "string", default: join10(homedir2(), ".teammy", "client") },
|
|
3542
|
+
...locationOptions,
|
|
2672
3543
|
once: { type: "boolean" },
|
|
3544
|
+
foreground: { type: "boolean" },
|
|
2673
3545
|
"interval-ms": { type: "string" },
|
|
2674
3546
|
"settle-ms": { type: "string" },
|
|
2675
3547
|
"adopt-baseline": { type: "boolean" },
|
|
2676
3548
|
"restore-missing": { type: "boolean" },
|
|
2677
3549
|
retry: { type: "boolean" },
|
|
2678
|
-
"sessions-only": { type: "boolean" }
|
|
2679
|
-
help: { type: "boolean", short: "h" }
|
|
3550
|
+
"sessions-only": { type: "boolean" }
|
|
2680
3551
|
}
|
|
2681
3552
|
});
|
|
2682
|
-
if (values.help) {
|
|
3553
|
+
if (values.help || !action && !values.once && !values.foreground) {
|
|
2683
3554
|
process.stdout.write(help2);
|
|
2684
3555
|
return 0;
|
|
2685
3556
|
}
|
|
2686
|
-
|
|
2687
|
-
|
|
2688
|
-
|
|
2689
|
-
|
|
2690
|
-
|
|
2691
|
-
|
|
2692
|
-
|
|
2693
|
-
|
|
2694
|
-
|
|
2695
|
-
|
|
2696
|
-
|
|
2697
|
-
|
|
3557
|
+
if (action && (values.once || values.foreground) || values.once && values.foreground)
|
|
3558
|
+
throw new Error("Choose sync on, sync off, --once or --foreground.");
|
|
3559
|
+
const options = {};
|
|
3560
|
+
for (const [key, target, min] of [
|
|
3561
|
+
["interval-ms", "intervalMs", 1e3],
|
|
3562
|
+
["settle-ms", "settleMs", 0]
|
|
3563
|
+
]) {
|
|
3564
|
+
if (values[key] === void 0) continue;
|
|
3565
|
+
const n = Number(values[key]);
|
|
3566
|
+
if (!Number.isSafeInteger(n) || n < min || n > 864e5)
|
|
3567
|
+
throw new Error("Invalid sync timing options.");
|
|
3568
|
+
options[target] = n;
|
|
3569
|
+
}
|
|
3570
|
+
if (values["adopt-baseline"]) options.adoptBaseline = true;
|
|
3571
|
+
if (values["restore-missing"]) options.restoreMissing = true;
|
|
3572
|
+
if (values.retry) options.retry = true;
|
|
3573
|
+
if (values["sessions-only"]) options.sessionsOnly = true;
|
|
3574
|
+
if (action && Object.keys(options).length)
|
|
3575
|
+
throw new Error(
|
|
3576
|
+
"Timing and recovery overrides require --once or --foreground. Manage normal sync timing in the dashboard."
|
|
3577
|
+
);
|
|
3578
|
+
const root = await workspacePath(values.root), directory = await stateLocation(root, values["state-dir"]);
|
|
3579
|
+
const state = new ClientState(directory), runtime = new RuntimeState(state), log = new ClientLog(directory);
|
|
3580
|
+
try {
|
|
3581
|
+
if (!state.binding(root)) throw new Error("Connect this workspace first.");
|
|
3582
|
+
if (action) {
|
|
3583
|
+
if (action === "on") await verifyWorkspace(state, root);
|
|
3584
|
+
runtime.enable(root, action === "on");
|
|
3585
|
+
if (action === "on") await wakeDaemon(directory, version);
|
|
3586
|
+
else {
|
|
3587
|
+
if (await runningDaemon(directory))
|
|
3588
|
+
await daemonRequest(directory, { method: "wake" }, 45e3);
|
|
3589
|
+
const status = runtime.status(root);
|
|
3590
|
+
if (status) runtime.save(root, { ...status, state: "stopped" });
|
|
3591
|
+
log.write("sync.off_requested", root);
|
|
3592
|
+
}
|
|
3593
|
+
if (values.json) json({ enabled: action === "on", root });
|
|
3594
|
+
else result(action === "on" ? "\u540E\u53F0\u540C\u6B65\u5DF2\u5F00\u542F" : "\u540C\u6B65\u5DF2\u505C\u6B62", root);
|
|
3595
|
+
return 0;
|
|
3596
|
+
}
|
|
3597
|
+
const daemon = await runningDaemon(directory);
|
|
3598
|
+
if (daemon && values.once && !Object.keys(options).length) {
|
|
3599
|
+
const report = await daemonRequest(directory, { method: "once", root }, 6e5);
|
|
3600
|
+
if (values.json) json(report);
|
|
3601
|
+
else result(report.errors.length ? "\u540C\u6B65\u9700\u8981\u5904\u7406" : "\u540C\u6B65\u68C0\u67E5\u5B8C\u6210", report.errors[0]);
|
|
3602
|
+
return report.errors.length ? 1 : 0;
|
|
3603
|
+
}
|
|
3604
|
+
if (runtime.enabled(root))
|
|
3605
|
+
throw new Error(
|
|
3606
|
+
'Run "teammy sync off" before using foreground synchronization or recovery overrides.'
|
|
3607
|
+
);
|
|
3608
|
+
const job = new SyncJob(root, directory, version, log, options);
|
|
3609
|
+
const abort = new AbortController();
|
|
3610
|
+
const stop = () => {
|
|
3611
|
+
abort.abort();
|
|
3612
|
+
void job.close();
|
|
3613
|
+
};
|
|
3614
|
+
process.once("SIGINT", stop);
|
|
3615
|
+
process.once("SIGTERM", stop);
|
|
3616
|
+
let prior = "";
|
|
3617
|
+
try {
|
|
3618
|
+
if (values.foreground && !values.json) result("\u540C\u6B65\u8FD0\u884C\u4E2D", "\u6309 Ctrl+C \u505C\u6B62");
|
|
3619
|
+
do {
|
|
3620
|
+
const report = await (values.once ? job.once() : job.tick());
|
|
3621
|
+
if (values.json) json(report);
|
|
3622
|
+
else if (values.once)
|
|
3623
|
+
result(report.errors.length ? "\u540C\u6B65\u9700\u8981\u5904\u7406" : "\u540C\u6B65\u68C0\u67E5\u5B8C\u6210", report.errors[0]);
|
|
3624
|
+
else if ((report.errors[0] ?? "") !== prior) {
|
|
3625
|
+
result(report.errors.length ? "\u540C\u6B65\u7B49\u5F85\u5904\u7406" : "\u540C\u6B65\u5DF2\u6062\u590D", report.errors[0]);
|
|
3626
|
+
prior = report.errors[0] ?? "";
|
|
3627
|
+
}
|
|
3628
|
+
if (values.once) return report.errors.length ? 1 : 0;
|
|
3629
|
+
await setTimeout4(Math.max(100, job.nextAt - Date.now()), void 0, {
|
|
3630
|
+
signal: abort.signal
|
|
3631
|
+
}).catch((e) => {
|
|
3632
|
+
if (!abort.signal.aborted) throw e;
|
|
3633
|
+
});
|
|
3634
|
+
} while (!abort.signal.aborted);
|
|
3635
|
+
} finally {
|
|
3636
|
+
process.off("SIGINT", stop);
|
|
3637
|
+
process.off("SIGTERM", stop);
|
|
3638
|
+
await job.close();
|
|
3639
|
+
}
|
|
3640
|
+
return 0;
|
|
3641
|
+
} catch (e) {
|
|
3642
|
+
log.write("sync.command_failed", root, diagnostic(e));
|
|
3643
|
+
throw e;
|
|
3644
|
+
} finally {
|
|
3645
|
+
state.close();
|
|
3646
|
+
}
|
|
3647
|
+
}
|
|
3648
|
+
|
|
3649
|
+
// apps/client/runtime/daemon.ts
|
|
3650
|
+
import { createServer } from "node:net";
|
|
3651
|
+
import { chmod as chmod2, unlink as unlink4 } from "node:fs/promises";
|
|
3652
|
+
async function runDaemon(directory, version) {
|
|
3653
|
+
const lock = acquireRootLock("daemon:" + directory, directory), state = new ClientState(directory), runtime = new RuntimeState(state), log = new ClientLog(directory);
|
|
3654
|
+
const jobs = /* @__PURE__ */ new Map(), retry = /* @__PURE__ */ new Map();
|
|
3655
|
+
let closing = false, serial = Promise.resolve();
|
|
3656
|
+
const reconcile = () => {
|
|
3657
|
+
serial = serial.catch(() => {
|
|
3658
|
+
}).then(async () => {
|
|
3659
|
+
if (closing) return;
|
|
3660
|
+
const enabled = new Set(runtime.roots());
|
|
3661
|
+
for (const [root, job] of jobs)
|
|
3662
|
+
if (!enabled.has(root)) {
|
|
3663
|
+
await job.close();
|
|
3664
|
+
jobs.delete(root);
|
|
3665
|
+
log.write("sync.off", root);
|
|
3666
|
+
}
|
|
3667
|
+
for (const root of enabled) {
|
|
3668
|
+
if (jobs.has(root) || oneShots.has(root) || (retry.get(root) ?? 0) > Date.now()) continue;
|
|
3669
|
+
try {
|
|
3670
|
+
jobs.set(root, new SyncJob(root, directory, version, log));
|
|
3671
|
+
retry.delete(root);
|
|
3672
|
+
log.write("sync.on", root);
|
|
3673
|
+
} catch (e) {
|
|
3674
|
+
retry.set(root, Date.now() + 6e4);
|
|
3675
|
+
log.write("sync.start_failed", root, diagnostic(e));
|
|
3676
|
+
runtime.save(root, {
|
|
3677
|
+
state: "waiting",
|
|
3678
|
+
checkedAt: null,
|
|
3679
|
+
lastSuccessAt: runtime.status(root)?.lastSuccessAt ?? null,
|
|
3680
|
+
pendingFiles: 0,
|
|
3681
|
+
pendingDownloads: 0,
|
|
3682
|
+
issue: diagnostic(e)
|
|
3683
|
+
});
|
|
3684
|
+
}
|
|
3685
|
+
}
|
|
3686
|
+
for (const job of jobs.values())
|
|
3687
|
+
if (job.nextAt <= Date.now())
|
|
3688
|
+
void job.tick().catch((e) => {
|
|
3689
|
+
job.nextAt = Date.now() + 6e4;
|
|
3690
|
+
log.write("sync.failed", job.root, diagnostic(e));
|
|
3691
|
+
});
|
|
3692
|
+
});
|
|
3693
|
+
return serial;
|
|
3694
|
+
};
|
|
3695
|
+
const sockets = /* @__PURE__ */ new Set(), temporaryJobs = /* @__PURE__ */ new Set();
|
|
3696
|
+
const oneShots = /* @__PURE__ */ new Map();
|
|
3697
|
+
const server = createServer((socket) => {
|
|
3698
|
+
sockets.add(socket);
|
|
3699
|
+
socket.once("close", () => sockets.delete(socket));
|
|
3700
|
+
let body = "", used = false;
|
|
3701
|
+
socket.setEncoding("utf8");
|
|
3702
|
+
socket.setTimeout(6e5, () => socket.destroy());
|
|
3703
|
+
socket.on("error", () => {
|
|
3704
|
+
});
|
|
3705
|
+
socket.on("data", (chunk) => {
|
|
3706
|
+
if (used) return;
|
|
3707
|
+
body += chunk.toString();
|
|
3708
|
+
if (body.length > 8192) {
|
|
3709
|
+
socket.destroy();
|
|
3710
|
+
return;
|
|
3711
|
+
}
|
|
3712
|
+
if (!body.includes("\n")) return;
|
|
3713
|
+
used = true;
|
|
3714
|
+
void (async () => {
|
|
3715
|
+
const input = RequestSchema.parse(JSON.parse(body.split("\n")[0]));
|
|
3716
|
+
if (closing) throw new Error("Background service is stopping.");
|
|
3717
|
+
if (input.method === "ping") return { pid: process.pid, version };
|
|
3718
|
+
if (input.method === "wake") {
|
|
3719
|
+
retry.clear();
|
|
3720
|
+
await reconcile();
|
|
3721
|
+
return { ok: true };
|
|
3722
|
+
}
|
|
3723
|
+
const root = input.root;
|
|
3724
|
+
if (!root || !state.binding(root)) throw new Error("Workspace is not connected.");
|
|
3725
|
+
if (input.method === "status")
|
|
3726
|
+
return {
|
|
3727
|
+
enabled: runtime.enabled(root),
|
|
3728
|
+
running: jobs.has(root),
|
|
3729
|
+
status: runtime.status(root)
|
|
3730
|
+
};
|
|
3731
|
+
const active = jobs.get(root);
|
|
3732
|
+
if (active) return active.once();
|
|
3733
|
+
if (runtime.enabled(root)) throw new Error("Workspace could not start. Run teammy status.");
|
|
3734
|
+
let work = oneShots.get(root);
|
|
3735
|
+
if (!work) {
|
|
3736
|
+
const job = new SyncJob(root, directory, version, log);
|
|
3737
|
+
temporaryJobs.add(job);
|
|
3738
|
+
work = job.once().finally(async () => {
|
|
3739
|
+
await job.close();
|
|
3740
|
+
temporaryJobs.delete(job);
|
|
3741
|
+
oneShots.delete(root);
|
|
3742
|
+
});
|
|
3743
|
+
oneShots.set(root, work);
|
|
3744
|
+
}
|
|
3745
|
+
return work;
|
|
3746
|
+
})().then(
|
|
3747
|
+
(result2) => socket.end(JSON.stringify({ ok: true, result: result2 }) + "\n"),
|
|
3748
|
+
(e) => socket.end(JSON.stringify({ ok: false, error: diagnostic(e) }) + "\n")
|
|
3749
|
+
);
|
|
3750
|
+
});
|
|
3751
|
+
});
|
|
3752
|
+
let timer;
|
|
3753
|
+
let resolveStop = () => {
|
|
3754
|
+
};
|
|
3755
|
+
const stopped = new Promise((resolve9) => {
|
|
3756
|
+
resolveStop = resolve9;
|
|
3757
|
+
});
|
|
2698
3758
|
const stop = () => {
|
|
2699
|
-
|
|
2700
|
-
|
|
3759
|
+
closing = true;
|
|
3760
|
+
resolveStop();
|
|
2701
3761
|
};
|
|
2702
3762
|
try {
|
|
2703
|
-
|
|
2704
|
-
|
|
2705
|
-
|
|
2706
|
-
|
|
2707
|
-
|
|
2708
|
-
|
|
2709
|
-
|
|
2710
|
-
|
|
2711
|
-
|
|
3763
|
+
await prepareSocketDirectory(directory);
|
|
3764
|
+
await unlink4(socketPath(directory)).catch((e) => {
|
|
3765
|
+
if (e.code !== "ENOENT") throw e;
|
|
3766
|
+
});
|
|
3767
|
+
await new Promise((resolve9, reject) => {
|
|
3768
|
+
server.once("error", reject);
|
|
3769
|
+
server.listen(socketPath(directory), () => {
|
|
3770
|
+
server.off("error", reject);
|
|
3771
|
+
resolve9();
|
|
2712
3772
|
});
|
|
2713
|
-
const overrides = {};
|
|
2714
|
-
if (values["interval-ms"] !== void 0) overrides.checkIntervalSeconds = intervalMs / 1e3;
|
|
2715
|
-
if (values["settle-ms"] !== void 0) overrides.fileSettleSeconds = settleMs / 1e3;
|
|
2716
|
-
control = new ControlPlane(state, root, version, overrides, (policy) => {
|
|
2717
|
-
intervalMs = Math.round(policy.checkIntervalSeconds * 1e3);
|
|
2718
|
-
synchronizer?.setSettleMs(Math.round(policy.fileSettleSeconds * 1e3));
|
|
2719
3773
|
});
|
|
2720
|
-
await
|
|
2721
|
-
await synchronizer?.checkCapabilities();
|
|
2722
|
-
if (!values.once) control.start();
|
|
3774
|
+
await chmod2(socketPath(directory), 384);
|
|
2723
3775
|
process.once("SIGINT", stop);
|
|
2724
3776
|
process.once("SIGTERM", stop);
|
|
2725
|
-
|
|
2726
|
-
|
|
2727
|
-
|
|
2728
|
-
|
|
2729
|
-
|
|
2730
|
-
|
|
2731
|
-
|
|
2732
|
-
published: 0,
|
|
2733
|
-
checked: 0,
|
|
2734
|
-
issues: [error instanceof Error ? error.message : "Session synchronization failed."]
|
|
2735
|
-
};
|
|
2736
|
-
}
|
|
2737
|
-
control.updateSync({
|
|
2738
|
-
checkedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2739
|
-
published: report.published,
|
|
2740
|
-
appliedFiles: report.appliedFiles,
|
|
2741
|
-
pendingFiles: report.waitingFiles + (state.pending(root)?.batch.changes.length ?? 0),
|
|
2742
|
-
sessionSources: Number(
|
|
2743
|
-
state.db.prepare("SELECT COUNT(*) n FROM session_sources WHERE root=?").get(root).n
|
|
2744
|
-
),
|
|
2745
|
-
errors: [...report.issues, ...sessionReport.issues].slice(0, 20).map((message2) => message2.slice(0, 1e3))
|
|
2746
|
-
});
|
|
2747
|
-
if (values.once && control.enabled) await control.tick();
|
|
2748
|
-
const combined = {
|
|
2749
|
-
...report,
|
|
2750
|
-
sessions: sessionReport,
|
|
2751
|
-
control: control.enabled ? control.status : null
|
|
2752
|
-
};
|
|
2753
|
-
process.stdout.write(`${JSON.stringify(combined)}
|
|
2754
|
-
`);
|
|
2755
|
-
if (values.once)
|
|
2756
|
-
return report.issues.length || sessionReport.issues.length || control.enabled && (control.status.configError || control.status.reportError) ? 1 : 0;
|
|
2757
|
-
const finishedAt = Date.now();
|
|
2758
|
-
while (!stopped.signal.aborted && Date.now() - finishedAt < intervalMs)
|
|
2759
|
-
await setTimeout3(Math.min(1e3, intervalMs - (Date.now() - finishedAt)), void 0, {
|
|
2760
|
-
signal: stopped.signal
|
|
2761
|
-
}).catch((error) => {
|
|
2762
|
-
if (!stopped.signal.aborted) throw error;
|
|
2763
|
-
});
|
|
2764
|
-
} while (!stopped.signal.aborted);
|
|
2765
|
-
return 0;
|
|
3777
|
+
log.write("daemon.started", void 0, `version=${version}`);
|
|
3778
|
+
await reconcile();
|
|
3779
|
+
timer = setInterval(
|
|
3780
|
+
() => void reconcile().catch((e) => log.write("daemon.error", void 0, diagnostic(e))),
|
|
3781
|
+
1e3
|
|
3782
|
+
);
|
|
3783
|
+
await stopped;
|
|
2766
3784
|
} finally {
|
|
3785
|
+
closing = true;
|
|
3786
|
+
clearInterval(timer);
|
|
2767
3787
|
process.off("SIGINT", stop);
|
|
2768
3788
|
process.off("SIGTERM", stop);
|
|
2769
|
-
|
|
2770
|
-
|
|
2771
|
-
await
|
|
2772
|
-
|
|
3789
|
+
await serial.catch(() => {
|
|
3790
|
+
});
|
|
3791
|
+
await Promise.all([...jobs.values(), ...temporaryJobs].map((job) => job.close()));
|
|
3792
|
+
await Promise.allSettled(oneShots.values());
|
|
3793
|
+
for (const socket of sockets) socket.destroy();
|
|
3794
|
+
await new Promise((resolve9) => server.close(() => resolve9()));
|
|
3795
|
+
await unlink4(socketPath(directory)).catch(() => {
|
|
3796
|
+
});
|
|
3797
|
+
log.write("daemon.stopped");
|
|
3798
|
+
state.close();
|
|
2773
3799
|
lock.close();
|
|
2774
3800
|
}
|
|
2775
3801
|
}
|
|
@@ -2783,13 +3809,15 @@ Usage:
|
|
|
2783
3809
|
teammy --version Show the installed client version
|
|
2784
3810
|
teammy server-info --url URL Check server connection and protocol capabilities
|
|
2785
3811
|
teammy server-info --help Show connection options
|
|
2786
|
-
teammy connect --url URL [--member ID | --token-file FILE] [--reconnect] [--root DIR] [--state-dir DIR]
|
|
3812
|
+
teammy connect --url URL --project ID [--member ID | --token-file FILE] [--reconnect] [--root DIR] [--state-dir DIR]
|
|
2787
3813
|
teammy status [--root DIR] [--state-dir DIR]
|
|
2788
|
-
teammy sync [--root DIR]
|
|
3814
|
+
teammy sync on | off [--root DIR]
|
|
3815
|
+
teammy logs [--root DIR] Show recent diagnostic events
|
|
2789
3816
|
teammy session --help Collect, read and search Agent sessions
|
|
2790
3817
|
|
|
2791
3818
|
Root defaults to ./workspaces; local state defaults to ~/.teammy/client.
|
|
2792
|
-
|
|
3819
|
+
connect starts background sync; enabled projects resume after login.
|
|
3820
|
+
Use --json for scripts; manage sync timing in the server dashboard.
|
|
2793
3821
|
The hub server is installed separately as @teammy/server (command: teammy-server).
|
|
2794
3822
|
`;
|
|
2795
3823
|
var serverInfoHelp = `Usage: teammy server-info --url URL
|
|
@@ -2816,6 +3844,21 @@ async function runCli(args, version) {
|
|
|
2816
3844
|
process.stderr.write('Install @teammy/server and run "teammy-server" to start the hub.\n');
|
|
2817
3845
|
return 1;
|
|
2818
3846
|
}
|
|
3847
|
+
if (command === "_daemon") {
|
|
3848
|
+
const { values } = parseArgs4({
|
|
3849
|
+
args: args.slice(1),
|
|
3850
|
+
options: { "state-dir": { type: "string" } }
|
|
3851
|
+
});
|
|
3852
|
+
if (!values["state-dir"]) throw new Error("Missing daemon state directory.");
|
|
3853
|
+
const directory = resolve8(values["state-dir"]);
|
|
3854
|
+
try {
|
|
3855
|
+
await runDaemon(directory, version);
|
|
3856
|
+
return 0;
|
|
3857
|
+
} catch (e) {
|
|
3858
|
+
new ClientLog(directory).write("daemon.failed", void 0, diagnostic(e));
|
|
3859
|
+
return 1;
|
|
3860
|
+
}
|
|
3861
|
+
}
|
|
2819
3862
|
if (command === "session") {
|
|
2820
3863
|
try {
|
|
2821
3864
|
return await runSessionCommand(args.slice(1));
|
|
@@ -2832,64 +3875,17 @@ async function runCli(args, version) {
|
|
|
2832
3875
|
try {
|
|
2833
3876
|
return await runSyncCommand(args.slice(1), version);
|
|
2834
3877
|
} catch (error) {
|
|
2835
|
-
process.stderr.write(
|
|
2836
|
-
|
|
2837
|
-
`
|
|
2838
|
-
);
|
|
3878
|
+
process.stderr.write(`${diagnostic(error)}
|
|
3879
|
+
`);
|
|
2839
3880
|
return 1;
|
|
2840
3881
|
}
|
|
2841
3882
|
}
|
|
2842
|
-
if (command === "connect" || command === "status") {
|
|
3883
|
+
if (command === "connect" || command === "status" || command === "logs") {
|
|
2843
3884
|
try {
|
|
2844
|
-
|
|
2845
|
-
|
|
2846
|
-
|
|
2847
|
-
url: { type: "string" },
|
|
2848
|
-
"token-file": { type: "string" },
|
|
2849
|
-
member: { type: "string" },
|
|
2850
|
-
reconnect: { type: "boolean" },
|
|
2851
|
-
root: { type: "string", default: resolve6("workspaces") },
|
|
2852
|
-
"state-dir": { type: "string", default: join11(homedir3(), ".teammy", "client") }
|
|
2853
|
-
}
|
|
2854
|
-
});
|
|
2855
|
-
const rootPath = resolve6(values.root);
|
|
2856
|
-
if (command === "connect") await mkdir7(rootPath, { recursive: true });
|
|
2857
|
-
const root = await realpath6(rootPath);
|
|
2858
|
-
const state = new ClientState(await stateLocation(root, values["state-dir"]));
|
|
2859
|
-
try {
|
|
2860
|
-
if (command === "status") {
|
|
2861
|
-
const binding = state.binding(root);
|
|
2862
|
-
if (!binding) throw new Error("This root is not connected. Run teammy connect first.");
|
|
2863
|
-
const { token: _token, ...publicBinding } = binding;
|
|
2864
|
-
process.stdout.write(
|
|
2865
|
-
`${JSON.stringify({ ...publicBinding, control: readControlStatus(state, root), sessions: new SessionState(state, root).status(), publication: state.publication(root), pending: state.pending(root) ? { operationId: state.pending(root).batch.operationId, retryAt: state.pending(root).retryAt, lastError: state.pending(root).lastError } : null, sync: new SyncState(state, root).status() }, null, 2)}
|
|
2866
|
-
`
|
|
2867
|
-
);
|
|
2868
|
-
} else {
|
|
2869
|
-
if (!values.url) throw new Error("--url is required.");
|
|
2870
|
-
const connected = await connectProject(
|
|
2871
|
-
state,
|
|
2872
|
-
root,
|
|
2873
|
-
await stateLocation(root, values["state-dir"]),
|
|
2874
|
-
{
|
|
2875
|
-
url: values.url,
|
|
2876
|
-
member: values.member,
|
|
2877
|
-
tokenFile: values["token-file"],
|
|
2878
|
-
reconnect: values.reconnect
|
|
2879
|
-
}
|
|
2880
|
-
);
|
|
2881
|
-
process.stdout.write(`${JSON.stringify(connected, null, 2)}
|
|
3885
|
+
return await runClientCommand(command, args.slice(1), version);
|
|
3886
|
+
} catch (e) {
|
|
3887
|
+
process.stderr.write(`${diagnostic(e)}
|
|
2882
3888
|
`);
|
|
2883
|
-
}
|
|
2884
|
-
} finally {
|
|
2885
|
-
state.close();
|
|
2886
|
-
}
|
|
2887
|
-
return 0;
|
|
2888
|
-
} catch (error) {
|
|
2889
|
-
process.stderr.write(
|
|
2890
|
-
`${error instanceof Error ? error.message : "Unable to open connection."}
|
|
2891
|
-
`
|
|
2892
|
-
);
|
|
2893
3889
|
return 1;
|
|
2894
3890
|
}
|
|
2895
3891
|
}
|
|
@@ -2898,7 +3894,7 @@ async function runCli(args, version) {
|
|
|
2898
3894
|
return 1;
|
|
2899
3895
|
}
|
|
2900
3896
|
try {
|
|
2901
|
-
const { values } =
|
|
3897
|
+
const { values } = parseArgs4({
|
|
2902
3898
|
args: args.slice(1),
|
|
2903
3899
|
options: {
|
|
2904
3900
|
help: { type: "boolean", short: "h" },
|