@indigoai-us/hq-cli 5.126.0 → 5.127.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +211 -0
- package/assets/lanes-hooks/hq-lanes-drain.json +15 -0
- package/assets/lanes-hooks/hq-lanes-drain.mjs +254 -0
- package/assets/lanes-hooks/hq-lanes-drain.sh +21 -0
- package/assets/lanes-reconcile/hq-lanes-reconcile.service +11 -0
- package/assets/lanes-reconcile/hq-lanes-reconcile.timer +12 -0
- package/dist/command-catalog.generated.d.ts +245 -11
- package/dist/command-catalog.generated.js +323 -11
- package/dist/command-registration-plan.d.ts +1 -0
- package/dist/command-registration-plan.js +11 -2
- package/dist/commands/agents.d.ts +12 -0
- package/dist/commands/agents.js +46 -2
- package/dist/commands/lanes.d.ts +55 -16
- package/dist/commands/lanes.js +1187 -250
- package/dist/commands/mesh.js +254 -19
- package/dist/lib/lanes/accounts.d.ts +67 -7
- package/dist/lib/lanes/accounts.js +233 -43
- package/dist/lib/lanes/compaction.d.ts +96 -0
- package/dist/lib/lanes/compaction.js +197 -0
- package/dist/lib/lanes/config.d.ts +15 -0
- package/dist/lib/lanes/config.js +65 -8
- package/dist/lib/lanes/delivery.d.ts +151 -0
- package/dist/lib/lanes/delivery.js +354 -0
- package/dist/lib/lanes/dropbox.d.ts +96 -0
- package/dist/lib/lanes/dropbox.js +408 -0
- package/dist/lib/lanes/effort.d.ts +50 -0
- package/dist/lib/lanes/effort.js +109 -0
- package/dist/lib/lanes/errors.d.ts +2 -2
- package/dist/lib/lanes/hooks-install.d.ts +81 -0
- package/dist/lib/lanes/hooks-install.js +326 -0
- package/dist/lib/lanes/hq-root.d.ts +19 -0
- package/dist/lib/lanes/hq-root.js +28 -0
- package/dist/lib/lanes/index.d.ts +35 -5
- package/dist/lib/lanes/index.js +19 -4
- package/dist/lib/lanes/liveness.d.ts +7 -1
- package/dist/lib/lanes/liveness.js +64 -5
- package/dist/lib/lanes/machine-identity.d.ts +34 -0
- package/dist/lib/lanes/machine-identity.js +92 -0
- package/dist/lib/lanes/mesh-events.d.ts +109 -0
- package/dist/lib/lanes/mesh-events.js +275 -0
- package/dist/lib/lanes/outpost-dispatch.d.ts +65 -0
- package/dist/lib/lanes/outpost-dispatch.js +90 -0
- package/dist/lib/lanes/questions.d.ts +75 -0
- package/dist/lib/lanes/questions.js +243 -0
- package/dist/lib/lanes/quota.d.ts +95 -0
- package/dist/lib/lanes/quota.js +320 -0
- package/dist/lib/lanes/reconcile-install.d.ts +69 -0
- package/dist/lib/lanes/reconcile-install.js +221 -0
- package/dist/lib/lanes/reconcile.d.ts +102 -0
- package/dist/lib/lanes/reconcile.js +985 -0
- package/dist/lib/lanes/registry.d.ts +18 -5
- package/dist/lib/lanes/registry.js +293 -19
- package/dist/lib/lanes/spawn.d.ts +274 -0
- package/dist/lib/lanes/spawn.js +1363 -0
- package/dist/lib/lanes/store.d.ts +7 -3
- package/dist/lib/lanes/store.js +150 -32
- package/dist/lib/lanes/types.d.ts +346 -4
- package/dist/lib/lanes/types.js +138 -5
- package/dist/lib/lanes/watcher-main.d.ts +9 -0
- package/dist/lib/lanes/watcher-main.js +22 -0
- package/dist/lib/lanes/watcher.d.ts +216 -0
- package/dist/lib/lanes/watcher.js +859 -0
- package/dist/lib/mesh/api.d.ts +71 -7
- package/dist/lib/mesh/api.js +167 -28
- package/dist/lib/mesh/live/backfill-held.d.ts +18 -7
- package/dist/lib/mesh/live/backfill-held.js +66 -21
- package/dist/lib/mesh/live/daemon/run.d.ts +2 -0
- package/dist/lib/mesh/live/daemon/run.js +111 -54
- package/dist/lib/mesh/live/daemon/state.d.ts +2 -0
- package/dist/lib/mesh/live/daemon/transcript-watch.d.ts +3 -0
- package/dist/lib/mesh/live/daemon/transcript-watch.js +31 -8
- package/dist/lib/scan-packages/index.d.ts +2 -0
- package/dist/lib/scan-packages/index.js +1 -1
- package/dist/lib/work-context/state.d.ts +12 -0
- package/dist/lib/work-context/state.js +131 -3
- package/package.json +3 -1
package/dist/lib/mesh/api.d.ts
CHANGED
|
@@ -23,10 +23,40 @@ export interface MeshThread {
|
|
|
23
23
|
lastActivityAt?: string;
|
|
24
24
|
createdAt?: string;
|
|
25
25
|
}
|
|
26
|
+
export interface MeshProjectStoryInput {
|
|
27
|
+
id: string;
|
|
28
|
+
title: string;
|
|
29
|
+
description?: string;
|
|
30
|
+
acceptanceCriteria?: string[];
|
|
31
|
+
status?: string;
|
|
32
|
+
passes?: boolean;
|
|
33
|
+
priority?: number;
|
|
34
|
+
}
|
|
35
|
+
export interface MeshProjectRepo {
|
|
36
|
+
path: string;
|
|
37
|
+
branch: string;
|
|
38
|
+
}
|
|
39
|
+
export interface MeshProjectView {
|
|
40
|
+
companyUid: string;
|
|
41
|
+
projectId: string;
|
|
42
|
+
name: string;
|
|
43
|
+
description: string;
|
|
44
|
+
stories: MeshProjectStoryInput[];
|
|
45
|
+
repos: MeshProjectRepo[];
|
|
46
|
+
version?: number;
|
|
47
|
+
}
|
|
48
|
+
export interface MeshProjectRegistration {
|
|
49
|
+
companyUid: string;
|
|
50
|
+
projectId: string;
|
|
51
|
+
threadId: string;
|
|
52
|
+
channelId: string;
|
|
53
|
+
channelCreated: boolean;
|
|
54
|
+
}
|
|
26
55
|
export declare function meshJson(token: string, path: string, init?: {
|
|
27
56
|
method?: string;
|
|
28
57
|
body?: Record<string, unknown>;
|
|
29
58
|
query?: Record<string, string>;
|
|
59
|
+
signal?: AbortSignal;
|
|
30
60
|
}): Promise<unknown>;
|
|
31
61
|
export declare function resolveMeshCompany(token: string, company?: string): Promise<MeshCompany>;
|
|
32
62
|
/**
|
|
@@ -47,7 +77,14 @@ export declare function listActiveMembershipCompanies(token: string): Promise<Me
|
|
|
47
77
|
* auth failures so the caller can surface DEFAULT_COMPANY_UNAVAILABLE.
|
|
48
78
|
*/
|
|
49
79
|
export declare function resolveActiveMembershipCompany(token: string, slug: string): Promise<MeshCompany | null>;
|
|
50
|
-
export
|
|
80
|
+
export interface RequireTokenOptions {
|
|
81
|
+
/**
|
|
82
|
+
* False from detached or unattended callers (the lane watcher). A person
|
|
83
|
+
* cache that cannot refresh then throws instead of opening a browser.
|
|
84
|
+
*/
|
|
85
|
+
interactive?: boolean;
|
|
86
|
+
}
|
|
87
|
+
export declare function requireToken(opts?: RequireTokenOptions): Promise<string>;
|
|
51
88
|
export declare function listActiveThreads(token: string, companyUid: string, projectId?: string): Promise<MeshThread[]>;
|
|
52
89
|
export declare function clamp(value: string, max: number): string;
|
|
53
90
|
export declare function eventPayload(kind: string, opts: {
|
|
@@ -62,15 +99,43 @@ export declare function ensureProjectThread(token: string, company: MeshCompany,
|
|
|
62
99
|
threadId: string;
|
|
63
100
|
created: boolean;
|
|
64
101
|
}>;
|
|
65
|
-
export declare function appendThreadEvent(token: string, companyUid: string, threadId: string, eventKind: string, payload: Record<string, unknown
|
|
102
|
+
export declare function appendThreadEvent(token: string, companyUid: string, threadId: string, eventKind: string, payload: Record<string, unknown>, signal?: AbortSignal): Promise<{
|
|
66
103
|
eventId?: string;
|
|
67
104
|
createdAt?: string;
|
|
68
105
|
}>;
|
|
106
|
+
export interface StoryPatch {
|
|
107
|
+
status?: string;
|
|
108
|
+
assigneeUid?: string;
|
|
109
|
+
clearAssignee?: boolean;
|
|
110
|
+
}
|
|
111
|
+
export interface StoryPatchResult {
|
|
112
|
+
projectId: string;
|
|
113
|
+
status: string;
|
|
114
|
+
version?: number;
|
|
115
|
+
assigneeUid: string | null;
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* PATCH one Board story. The server derives the actor from the request
|
|
119
|
+
* credential. This body never includes actorUid.
|
|
120
|
+
*/
|
|
121
|
+
export declare function patchStory(token: string, companyUid: string, projectId: string, storyId: string, patch: StoryPatch): Promise<StoryPatchResult>;
|
|
69
122
|
export declare function patchStoryStatus(token: string, companyUid: string, projectId: string, storyId: string, status: string): Promise<{
|
|
70
123
|
status: string;
|
|
71
124
|
version?: number;
|
|
72
125
|
projectId: string;
|
|
73
126
|
}>;
|
|
127
|
+
/** Read the full durable Board view before preparing a replacement PUT. */
|
|
128
|
+
export declare function getProjectView(token: string, companyUid: string, projectId: string): Promise<MeshProjectView>;
|
|
129
|
+
/** Replace a project's durable Board view and keep the local project cache current. */
|
|
130
|
+
export declare function putProjectView(token: string, companyUid: string, projectId: string, input: {
|
|
131
|
+
name?: string;
|
|
132
|
+
description?: string;
|
|
133
|
+
stories?: MeshProjectStoryInput[];
|
|
134
|
+
repos?: MeshProjectRepo[];
|
|
135
|
+
version?: number;
|
|
136
|
+
}): Promise<MeshProjectView>;
|
|
137
|
+
/** Register the project genesis thread and deterministic project channel. */
|
|
138
|
+
export declare function registerMeshProject(token: string, companyUid: string, projectId: string): Promise<MeshProjectRegistration>;
|
|
74
139
|
export declare function pairDmUidsFromInbox(inbox: unknown): string[];
|
|
75
140
|
export interface WarmCacheResult {
|
|
76
141
|
directory: boolean;
|
|
@@ -94,13 +159,12 @@ export declare function meshCachePrincipalUid(tokens: {
|
|
|
94
159
|
accessToken?: string;
|
|
95
160
|
} | null | undefined, fallbackToken?: string): string;
|
|
96
161
|
/**
|
|
97
|
-
* Resolve the
|
|
98
|
-
*
|
|
99
|
-
*
|
|
162
|
+
* Resolve the authenticated request principal. The request token is the source
|
|
163
|
+
* of truth; cached credentials may contribute an entity uid only when they
|
|
164
|
+
* identify the same caller. Only the uid is kept — credentials are discarded.
|
|
100
165
|
*/
|
|
101
166
|
export declare function resolveMeshPrincipalUid(token: string, tokens?: {
|
|
102
167
|
idToken?: string;
|
|
103
168
|
accessToken?: string;
|
|
104
|
-
} | null): Promise<string>;
|
|
105
|
-
export declare function callerLabelFromToken(token: string): string;
|
|
169
|
+
} | null): Promise<string | undefined>;
|
|
106
170
|
//# sourceMappingURL=api.d.ts.map
|
package/dist/lib/mesh/api.js
CHANGED
|
@@ -30,6 +30,7 @@ export async function meshJson(token, path, init = {}) {
|
|
|
30
30
|
method: init.method,
|
|
31
31
|
body: init.body,
|
|
32
32
|
query: init.query,
|
|
33
|
+
signal: init.signal,
|
|
33
34
|
});
|
|
34
35
|
const text = await res.text();
|
|
35
36
|
let data = {};
|
|
@@ -48,7 +49,11 @@ export async function meshJson(token, path, init = {}) {
|
|
|
48
49
|
// 400 whose body text matched no pattern was retried forever.
|
|
49
50
|
const detail = err.error || err.message || res.statusText || "request failed";
|
|
50
51
|
const code = typeof err.code === "string" && err.code ? `${err.code}: ` : "";
|
|
51
|
-
|
|
52
|
+
const context = [
|
|
53
|
+
typeof err.stage === "string" && err.stage ? `stage=${err.stage}` : "",
|
|
54
|
+
typeof err.recovery === "string" && err.recovery ? `recovery=${err.recovery}` : "",
|
|
55
|
+
].filter(Boolean);
|
|
56
|
+
throw new Error(`${res.status} ${code}${detail}${context.length ? ` (${context.join("; ")})` : ""}`);
|
|
52
57
|
}
|
|
53
58
|
return data;
|
|
54
59
|
}
|
|
@@ -107,7 +112,11 @@ export async function resolveActiveMembershipCompany(token, slug) {
|
|
|
107
112
|
const trimmed = slug.trim();
|
|
108
113
|
if (!trimmed)
|
|
109
114
|
return null;
|
|
110
|
-
const
|
|
115
|
+
const payload = asRecord(await meshJson(token, "/membership/me"));
|
|
116
|
+
if (!payload || !Array.isArray(payload.memberships)) {
|
|
117
|
+
throw new Error("Malformed membership response: expected memberships array");
|
|
118
|
+
}
|
|
119
|
+
const rows = activeMembershipCompanies(payload.memberships);
|
|
111
120
|
const needle = trimmed.toLowerCase();
|
|
112
121
|
const matches = rows.filter((c) => c.companySlug.toLowerCase() === needle ||
|
|
113
122
|
c.companyUid.toLowerCase() === needle);
|
|
@@ -118,7 +127,29 @@ export async function resolveActiveMembershipCompany(token, slug) {
|
|
|
118
127
|
// Ambiguous slug across memberships — refuse rather than guess.
|
|
119
128
|
return null;
|
|
120
129
|
}
|
|
121
|
-
|
|
130
|
+
function activeMembershipCompanies(rows) {
|
|
131
|
+
const out = [];
|
|
132
|
+
const seen = new Set();
|
|
133
|
+
for (const row of rows) {
|
|
134
|
+
const rec = asRecord(row);
|
|
135
|
+
if (!rec)
|
|
136
|
+
continue;
|
|
137
|
+
if (String(rec.status || "").toLowerCase() !== "active")
|
|
138
|
+
continue;
|
|
139
|
+
const companyUid = typeof rec.companyUid === "string" ? rec.companyUid : "";
|
|
140
|
+
if (!companyUid || seen.has(companyUid))
|
|
141
|
+
continue;
|
|
142
|
+
const companySlug = typeof rec.companySlug === "string"
|
|
143
|
+
? rec.companySlug
|
|
144
|
+
: typeof rec.companyFolderSlug === "string"
|
|
145
|
+
? rec.companyFolderSlug
|
|
146
|
+
: companyUid;
|
|
147
|
+
seen.add(companyUid);
|
|
148
|
+
out.push({ companyUid, companySlug: companySlug || companyUid });
|
|
149
|
+
}
|
|
150
|
+
return out;
|
|
151
|
+
}
|
|
152
|
+
export async function requireToken(opts = {}) {
|
|
122
153
|
// Agent/machine boxes must authenticate the way the mesh daemon does:
|
|
123
154
|
// an agent's identity claims (custom:entityType=agent, custom:entityUid)
|
|
124
155
|
// ride the ID token ONLY; a person-cache ACCESS token on the box carries
|
|
@@ -129,10 +160,11 @@ export async function requireToken() {
|
|
|
129
160
|
// hard policy indigo-agent-caller-resolution-id-token, agent claims ride
|
|
130
161
|
// the ID token only; the server is correct to require it. Humans keep the
|
|
131
162
|
// person access token (unchanged).
|
|
163
|
+
const interactive = opts.interactive ?? true;
|
|
132
164
|
if (isMachineIdentity()) {
|
|
133
|
-
return ensureCognitoToken({ tokenSource: "machine" });
|
|
165
|
+
return ensureCognitoToken({ tokenSource: "machine", interactive });
|
|
134
166
|
}
|
|
135
|
-
return ensureCognitoToken();
|
|
167
|
+
return ensureCognitoToken({ interactive });
|
|
136
168
|
}
|
|
137
169
|
function asRecord(value) {
|
|
138
170
|
return value && typeof value === "object" && !Array.isArray(value)
|
|
@@ -291,20 +323,32 @@ export async function ensureProjectThread(token, company, projectId, opts) {
|
|
|
291
323
|
throw new Error("Work mesh did not return a threadId");
|
|
292
324
|
return { threadId, created: true };
|
|
293
325
|
}
|
|
294
|
-
export async function appendThreadEvent(token, companyUid, threadId, eventKind, payload) {
|
|
326
|
+
export async function appendThreadEvent(token, companyUid, threadId, eventKind, payload, signal) {
|
|
295
327
|
const data = asRecord(await meshJson(token, `/v1/work-mesh/threads/${encodeURIComponent(threadId)}/events`, {
|
|
296
328
|
method: "POST",
|
|
297
329
|
body: { companyUid, eventKind, payload },
|
|
330
|
+
signal,
|
|
298
331
|
}));
|
|
299
332
|
return {
|
|
300
333
|
eventId: typeof data?.eventId === "string" ? data.eventId : undefined,
|
|
301
334
|
createdAt: typeof data?.createdAt === "string" ? data.createdAt : undefined,
|
|
302
335
|
};
|
|
303
336
|
}
|
|
304
|
-
|
|
337
|
+
/**
|
|
338
|
+
* PATCH one Board story. The server derives the actor from the request
|
|
339
|
+
* credential. This body never includes actorUid.
|
|
340
|
+
*/
|
|
341
|
+
export async function patchStory(token, companyUid, projectId, storyId, patch) {
|
|
342
|
+
const body = { companyUid };
|
|
343
|
+
if (patch.status)
|
|
344
|
+
body.status = patch.status;
|
|
345
|
+
if (patch.assigneeUid)
|
|
346
|
+
body.assigneeUid = patch.assigneeUid;
|
|
347
|
+
if (patch.clearAssignee)
|
|
348
|
+
body.clearAssignee = true;
|
|
305
349
|
const view = asRecord(await meshJson(token, `/v1/work-mesh/projects/${encodeURIComponent(projectId)}/stories/${encodeURIComponent(storyId)}`, {
|
|
306
350
|
method: "PATCH",
|
|
307
|
-
body
|
|
351
|
+
body,
|
|
308
352
|
}));
|
|
309
353
|
if (view && typeof view.projectId === "string" && typeof view.companyUid === "string") {
|
|
310
354
|
writeMeshCacheFile(["projects", view.companyUid, `${view.projectId}.json`], view);
|
|
@@ -313,10 +357,88 @@ export async function patchStoryStatus(token, companyUid, projectId, storyId, st
|
|
|
313
357
|
const patched = stories
|
|
314
358
|
.map(asRecord)
|
|
315
359
|
.find((row) => row && row.id === storyId);
|
|
360
|
+
const assigneeUid = patch.clearAssignee
|
|
361
|
+
? typeof patched?.assigneeUid === "string"
|
|
362
|
+
? patched.assigneeUid
|
|
363
|
+
: null
|
|
364
|
+
: typeof patched?.assigneeUid === "string"
|
|
365
|
+
? patched.assigneeUid
|
|
366
|
+
: patch.assigneeUid ?? null;
|
|
316
367
|
return {
|
|
317
368
|
projectId: String(view?.projectId || projectId),
|
|
318
|
-
status: typeof patched?.status === "string" ? patched.status : status,
|
|
369
|
+
status: typeof patched?.status === "string" ? patched.status : patch.status || "",
|
|
319
370
|
version: typeof view?.version === "number" ? view.version : undefined,
|
|
371
|
+
assigneeUid,
|
|
372
|
+
};
|
|
373
|
+
}
|
|
374
|
+
export async function patchStoryStatus(token, companyUid, projectId, storyId, status) {
|
|
375
|
+
const patched = await patchStory(token, companyUid, projectId, storyId, { status });
|
|
376
|
+
return {
|
|
377
|
+
projectId: patched.projectId,
|
|
378
|
+
status: patched.status,
|
|
379
|
+
version: patched.version,
|
|
380
|
+
};
|
|
381
|
+
}
|
|
382
|
+
function projectViewFromResponse(view, companyUid, projectId) {
|
|
383
|
+
const returnedProjectId = String(view.projectId || projectId).trim();
|
|
384
|
+
const returnedCompanyUid = String(view.companyUid || companyUid).trim();
|
|
385
|
+
const stories = Array.isArray(view.stories)
|
|
386
|
+
? view.stories.filter((story) => asRecord(story))
|
|
387
|
+
: [];
|
|
388
|
+
const repos = Array.isArray(view.repos)
|
|
389
|
+
? view.repos.flatMap((repo) => {
|
|
390
|
+
const row = asRecord(repo);
|
|
391
|
+
const path = typeof row?.path === "string" ? row.path.trim() : "";
|
|
392
|
+
if (!path)
|
|
393
|
+
return [];
|
|
394
|
+
return [{ path, branch: typeof row?.branch === "string" ? row.branch.trim() : "" }];
|
|
395
|
+
})
|
|
396
|
+
: [];
|
|
397
|
+
return {
|
|
398
|
+
companyUid: returnedCompanyUid,
|
|
399
|
+
projectId: returnedProjectId,
|
|
400
|
+
name: String(view.name || returnedProjectId),
|
|
401
|
+
description: typeof view.description === "string" ? view.description : "",
|
|
402
|
+
stories,
|
|
403
|
+
repos,
|
|
404
|
+
version: typeof view.version === "number" ? view.version : undefined,
|
|
405
|
+
};
|
|
406
|
+
}
|
|
407
|
+
/** Read the full durable Board view before preparing a replacement PUT. */
|
|
408
|
+
export async function getProjectView(token, companyUid, projectId) {
|
|
409
|
+
const view = asRecord(await meshJson(token, `/v1/work-mesh/projects/${encodeURIComponent(projectId)}`, {
|
|
410
|
+
query: { companyUid },
|
|
411
|
+
}));
|
|
412
|
+
if (!view)
|
|
413
|
+
throw new Error("Work mesh returned an invalid project view");
|
|
414
|
+
return projectViewFromResponse(view, companyUid, projectId);
|
|
415
|
+
}
|
|
416
|
+
/** Replace a project's durable Board view and keep the local project cache current. */
|
|
417
|
+
export async function putProjectView(token, companyUid, projectId, input) {
|
|
418
|
+
const view = asRecord(await meshJson(token, `/v1/work-mesh/projects/${encodeURIComponent(projectId)}`, {
|
|
419
|
+
method: "PUT",
|
|
420
|
+
body: { companyUid, ...input },
|
|
421
|
+
}));
|
|
422
|
+
if (!view)
|
|
423
|
+
throw new Error("Work mesh returned an invalid project view");
|
|
424
|
+
const result = projectViewFromResponse(view, companyUid, projectId);
|
|
425
|
+
writeMeshCacheFile(["projects", result.companyUid, `${result.projectId}.json`], view);
|
|
426
|
+
return result;
|
|
427
|
+
}
|
|
428
|
+
/** Register the project genesis thread and deterministic project channel. */
|
|
429
|
+
export async function registerMeshProject(token, companyUid, projectId) {
|
|
430
|
+
const result = asRecord(await meshJson(token, `/v1/work-mesh/projects/${encodeURIComponent(projectId)}/register`, { method: "POST", body: { companyUid } }));
|
|
431
|
+
const threadId = String(result?.threadId || "").trim();
|
|
432
|
+
const channelId = String(result?.channelId || "").trim();
|
|
433
|
+
if (!threadId || !channelId) {
|
|
434
|
+
throw new Error("Work mesh registration did not return a threadId and channelId");
|
|
435
|
+
}
|
|
436
|
+
return {
|
|
437
|
+
companyUid: String(result?.companyUid || companyUid).trim(),
|
|
438
|
+
projectId: String(result?.projectId || projectId).trim(),
|
|
439
|
+
threadId,
|
|
440
|
+
channelId,
|
|
441
|
+
channelCreated: result?.channelCreated === true,
|
|
320
442
|
};
|
|
321
443
|
}
|
|
322
444
|
export function pairDmUidsFromInbox(inbox) {
|
|
@@ -423,15 +545,43 @@ export function meshCachePrincipalUid(tokens, fallbackToken) {
|
|
|
423
545
|
}
|
|
424
546
|
return "session";
|
|
425
547
|
}
|
|
548
|
+
function tokensIdentifySamePrincipal(token, candidate) {
|
|
549
|
+
if (token === candidate)
|
|
550
|
+
return true;
|
|
551
|
+
const tokenClaims = peekIdToken(token);
|
|
552
|
+
const candidateClaims = peekIdToken(candidate);
|
|
553
|
+
const tokenEntityUid = entityUidFromJwt(token);
|
|
554
|
+
const candidateEntityUid = entityUidFromJwt(candidate);
|
|
555
|
+
if (tokenEntityUid && candidateEntityUid && tokenEntityUid !== candidateEntityUid) {
|
|
556
|
+
return false;
|
|
557
|
+
}
|
|
558
|
+
for (const claim of ["sub", "cognito:username", "username", "email"]) {
|
|
559
|
+
const tokenValue = tokenClaims[claim];
|
|
560
|
+
const candidateValue = candidateClaims[claim];
|
|
561
|
+
if (typeof tokenValue === "string" &&
|
|
562
|
+
tokenValue.length > 0 &&
|
|
563
|
+
tokenValue === candidateValue) {
|
|
564
|
+
return true;
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
return false;
|
|
568
|
+
}
|
|
426
569
|
/**
|
|
427
|
-
* Resolve the
|
|
428
|
-
*
|
|
429
|
-
*
|
|
570
|
+
* Resolve the authenticated request principal. The request token is the source
|
|
571
|
+
* of truth; cached credentials may contribute an entity uid only when they
|
|
572
|
+
* identify the same caller. Only the uid is kept — credentials are discarded.
|
|
430
573
|
*/
|
|
431
574
|
export async function resolveMeshPrincipalUid(token, tokens) {
|
|
432
|
-
const
|
|
433
|
-
if (
|
|
434
|
-
return
|
|
575
|
+
const fromRequestToken = entityUidFromJwt(token);
|
|
576
|
+
if (fromRequestToken)
|
|
577
|
+
return fromRequestToken;
|
|
578
|
+
for (const candidate of [tokens?.idToken, tokens?.accessToken]) {
|
|
579
|
+
if (!candidate || !tokensIdentifySamePrincipal(token, candidate))
|
|
580
|
+
continue;
|
|
581
|
+
const uid = entityUidFromJwt(candidate);
|
|
582
|
+
if (uid)
|
|
583
|
+
return uid;
|
|
584
|
+
}
|
|
435
585
|
try {
|
|
436
586
|
const data = await meshJson(token, "/v1/realtime/credentials", {
|
|
437
587
|
method: "POST",
|
|
@@ -442,19 +592,8 @@ export async function resolveMeshPrincipalUid(token, tokens) {
|
|
|
442
592
|
return uid;
|
|
443
593
|
}
|
|
444
594
|
catch {
|
|
445
|
-
//
|
|
595
|
+
// Best-effort; callers must fail rather than claim work as "session".
|
|
446
596
|
}
|
|
447
|
-
return
|
|
448
|
-
}
|
|
449
|
-
export function callerLabelFromToken(token) {
|
|
450
|
-
const claims = peekIdToken(token);
|
|
451
|
-
const entityUid = claims["custom:entityUid"];
|
|
452
|
-
if (typeof entityUid === "string" && entityUid)
|
|
453
|
-
return entityUid;
|
|
454
|
-
if (typeof claims.email === "string" && claims.email)
|
|
455
|
-
return claims.email;
|
|
456
|
-
if (typeof claims.sub === "string" && claims.sub)
|
|
457
|
-
return claims.sub;
|
|
458
|
-
return "hq-cli";
|
|
597
|
+
return undefined;
|
|
459
598
|
}
|
|
460
599
|
//# sourceMappingURL=api.js.map
|
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
*/
|
|
23
23
|
import type { ReconcileObservation, ReconcileOutcome } from "../../work-context/reconcile.js";
|
|
24
24
|
import { type SessionStateFile } from "../../work-context/state.js";
|
|
25
|
+
import type { MembershipValidationResult } from "../../work-context/config.js";
|
|
25
26
|
import { type OutboxOperation } from "../../work-context/outbox.js";
|
|
26
27
|
/**
|
|
27
28
|
* One distinct session discovered in held.jsonl, carrying the company evidence
|
|
@@ -75,8 +76,20 @@ export interface BackfillHeldResult {
|
|
|
75
76
|
unresolved: number;
|
|
76
77
|
/** Unresolved because otherwise-trusted local evidence disagreed. */
|
|
77
78
|
conflictingEvidence: number;
|
|
79
|
+
/**
|
|
80
|
+
* Unresolved because no local company evidence existed (not even a slug).
|
|
81
|
+
* These sessions cannot be recovered without --use-device-default or new
|
|
82
|
+
* local activity; they are never silently folded into the plain unresolved
|
|
83
|
+
* count.
|
|
84
|
+
*/
|
|
85
|
+
noEvidence: number;
|
|
78
86
|
/** Sessions that errored during reconcile. */
|
|
79
87
|
errors: number;
|
|
88
|
+
/** Why each session with otherwise-valid local evidence stayed unresolved. */
|
|
89
|
+
unresolvedReasons: Array<{
|
|
90
|
+
sessionId: string;
|
|
91
|
+
reason: string;
|
|
92
|
+
}>;
|
|
80
93
|
dryRun: boolean;
|
|
81
94
|
}
|
|
82
95
|
export interface BackfillHeldDeps {
|
|
@@ -88,7 +101,7 @@ export interface BackfillHeldDeps {
|
|
|
88
101
|
hqRoot?: string;
|
|
89
102
|
/** Durable device allowlist of verified local HQ roots. */
|
|
90
103
|
hqRoots?: string[];
|
|
91
|
-
/** Report only; write nothing
|
|
104
|
+
/** Report only; write nothing or deliver events. Membership validation may run. */
|
|
92
105
|
dryRun?: boolean;
|
|
93
106
|
/** Cap distinct sessions processed. 0 (default) = no cap. */
|
|
94
107
|
limit?: number;
|
|
@@ -116,13 +129,11 @@ export interface BackfillHeldDeps {
|
|
|
116
129
|
/** Env passed to the dry-run resolver (tests). Default process.env. */
|
|
117
130
|
env?: NodeJS.ProcessEnv;
|
|
118
131
|
/**
|
|
119
|
-
*
|
|
120
|
-
*
|
|
121
|
-
*
|
|
122
|
-
* while the write path, given no evidence, resolved none). Default runs the
|
|
123
|
-
* shared `resolveCompany` over the same reconstructed evidence.
|
|
132
|
+
* Membership validation used by both the dry-run preview and the live
|
|
133
|
+
* reconciler. A false response means the locally resolved slug is no longer
|
|
134
|
+
* an active membership and cannot be recovered.
|
|
124
135
|
*/
|
|
125
|
-
|
|
136
|
+
validateMembership?: (slug: string) => Promise<MembershipValidationResult> | MembershipValidationResult;
|
|
126
137
|
/** Optional progress logger. */
|
|
127
138
|
log?: (message: string) => void;
|
|
128
139
|
/** Read/write seam for the bounded-startup rotation cursor. */
|
|
@@ -24,7 +24,7 @@ import * as fs from "node:fs";
|
|
|
24
24
|
import * as path from "node:path";
|
|
25
25
|
import { readSessionState } from "../../work-context/state.js";
|
|
26
26
|
import { listOutboxOperations, writeAckedRegisterState, } from "../../work-context/outbox.js";
|
|
27
|
-
import { WORK_CONTEXT_CONTRACT_VERSION } from "../../work-context/contract.js";
|
|
27
|
+
import { WORK_CONTEXT_CONTRACT_VERSION, } from "../../work-context/contract.js";
|
|
28
28
|
import { resolveCompany, sameCompany } from "../../work-context/company.js";
|
|
29
29
|
import { readMetaCompanySlug } from "../../work-context/company.js";
|
|
30
30
|
import { deriveRemoteOwnerSlug, } from "../../work-context/repo-remote.js";
|
|
@@ -222,16 +222,11 @@ export function observationFromHeldRef(ref, contractVersion, clientOperationId)
|
|
|
222
222
|
}
|
|
223
223
|
return obs;
|
|
224
224
|
}
|
|
225
|
-
/**
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
* that later fails online membership validation is the one narrow case where
|
|
231
|
-
* the write run can still under-count; the gross all-or-nothing mismatch is
|
|
232
|
-
* gone because both paths key off this same resolver + evidence.
|
|
233
|
-
*/
|
|
234
|
-
function defaultPredictCompany(ref, workContextRoot, remoteOwnerHint, env, useDeviceDefault) {
|
|
225
|
+
/** The sole success predicate for both the preview and a live reconciliation. */
|
|
226
|
+
function isSuccessfulBackfillRecovery(kind) {
|
|
227
|
+
return kind === "bound" || kind === "needs_project" || kind === "needs_task" || kind === "queued";
|
|
228
|
+
}
|
|
229
|
+
async function previewCompanyRecovery(ref, workContextRoot, remoteOwnerHint, env, useDeviceDefault, validateMembership) {
|
|
235
230
|
// This predictor is called only with a ref already rebuilt from trusted local
|
|
236
231
|
// evidence. Do not let raw held-event cwd/hqRoot values re-enter resolution.
|
|
237
232
|
const remoteOwnerSlug = remoteOwnerHint?.trim();
|
|
@@ -243,16 +238,61 @@ function defaultPredictCompany(ref, workContextRoot, remoteOwnerHint, env, useDe
|
|
|
243
238
|
remoteOwnerSlug,
|
|
244
239
|
allowDeviceDefault: useDeviceDefault,
|
|
245
240
|
});
|
|
246
|
-
|
|
241
|
+
if (resolution.status !== "resolved") {
|
|
242
|
+
return { kind: resolution.status, reason: resolution.status };
|
|
243
|
+
}
|
|
244
|
+
const resolved = resolution.company;
|
|
245
|
+
if (!resolved.slug)
|
|
246
|
+
return { kind: "queued" };
|
|
247
|
+
if (!validateMembership) {
|
|
248
|
+
// Device defaults are weak, cached evidence. Reconcile refuses to use one
|
|
249
|
+
// for tenant I/O until membership has been revalidated, so the dry-run
|
|
250
|
+
// preview must fail closed in precisely the same way.
|
|
251
|
+
if (resolved.source === "device_default") {
|
|
252
|
+
return {
|
|
253
|
+
kind: "needs_company",
|
|
254
|
+
reason: "needs_company: device-default membership validator unavailable",
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
return { kind: "queued" };
|
|
258
|
+
}
|
|
259
|
+
try {
|
|
260
|
+
const membership = await validateMembership(resolved.slug);
|
|
261
|
+
if (membership === false) {
|
|
262
|
+
return {
|
|
263
|
+
kind: "needs_company",
|
|
264
|
+
reason: `membership denied for slug=${resolved.slug}`,
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
catch (err) {
|
|
269
|
+
// reconcile continues on a failed refresh for durable local evidence, but
|
|
270
|
+
// must stop a device-default recovery because that evidence is weak.
|
|
271
|
+
if (resolved.source === "device_default") {
|
|
272
|
+
return {
|
|
273
|
+
kind: "needs_company",
|
|
274
|
+
reason: `device-default membership refresh unavailable: ${err instanceof Error ? err.message : String(err)}`,
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
return { kind: "queued" };
|
|
247
279
|
}
|
|
248
280
|
/**
|
|
249
281
|
* A backfill is only useful when the daemon can observe a durable company
|
|
250
282
|
* binding on its next held retry. Do not use the reconcile result alone here:
|
|
251
283
|
* a completed reconcile may legitimately return needs_company after a failed
|
|
252
284
|
* membership lookup, and that leaves the held events exactly where they were.
|
|
285
|
+
*
|
|
286
|
+
* A persisted companySlug without a companyUid is still a durable binding:
|
|
287
|
+
* the daemon resolves the uid via membership validation on its next held retry.
|
|
288
|
+
* Requiring companyUid causes dry-run and live counts to diverge when offline
|
|
289
|
+
* reconcile writes only a slug (resolveCompany returns trusted_explicit with
|
|
290
|
+
* no uid when no manifest lookup occurs), making live report reconciled=0 for
|
|
291
|
+
* sessions the dry-run correctly predicted as recoverable.
|
|
253
292
|
*/
|
|
254
293
|
function hasDurableCompanyRecovery(sessionId, workContextRoot, readState) {
|
|
255
|
-
|
|
294
|
+
const state = readState(sessionId, workContextRoot);
|
|
295
|
+
return Boolean(state?.companyUid || state?.companySlug);
|
|
256
296
|
}
|
|
257
297
|
function latestAcknowledgedRegistration(sessionId, operations) {
|
|
258
298
|
for (let index = operations.length - 1; index >= 0; index -= 1) {
|
|
@@ -385,8 +425,6 @@ export async function backfillHeldSessions(deps) {
|
|
|
385
425
|
const readState = deps.readState ?? readSessionState;
|
|
386
426
|
const env = deps.env ?? process.env;
|
|
387
427
|
const newOperationId = deps.newOperationId ?? (() => globalThis.crypto.randomUUID());
|
|
388
|
-
const predictCompany = deps.predictCompany ??
|
|
389
|
-
((ref) => defaultPredictCompany(ref, deps.workContextRoot, deps.remoteOwnerHint, env, deps.useDeviceDefault === true));
|
|
390
428
|
const sessions = readHeldSessions(deps.workMeshRoot);
|
|
391
429
|
// An acked register is authoritative local evidence. Older clients could
|
|
392
430
|
// lose its state projection on a later hook write; recover that projection
|
|
@@ -400,7 +438,9 @@ export async function backfillHeldSessions(deps) {
|
|
|
400
438
|
reconciled: 0,
|
|
401
439
|
unresolved: 0,
|
|
402
440
|
conflictingEvidence: 0,
|
|
441
|
+
noEvidence: 0,
|
|
403
442
|
errors: 0,
|
|
443
|
+
unresolvedReasons: [],
|
|
404
444
|
dryRun,
|
|
405
445
|
};
|
|
406
446
|
for (const ref of considered) {
|
|
@@ -440,6 +480,7 @@ export async function backfillHeldSessions(deps) {
|
|
|
440
480
|
// slug from the held event itself.
|
|
441
481
|
if (!evidence.candidate?.slug && deps.useDeviceDefault !== true) {
|
|
442
482
|
result.unresolved += 1;
|
|
483
|
+
result.noEvidence += 1;
|
|
443
484
|
deps.log?.(`would stay unresolved ${sessionId} (${hasOnlyStaleEventEvidence(ref, state) ? "STALE_EVIDENCE" : "no local company evidence"})`);
|
|
444
485
|
continue;
|
|
445
486
|
}
|
|
@@ -453,22 +494,24 @@ export async function backfillHeldSessions(deps) {
|
|
|
453
494
|
task: state?.taskId,
|
|
454
495
|
};
|
|
455
496
|
if (dryRun) {
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
if (predictCompany(evidenceRef)) {
|
|
497
|
+
const preview = await previewCompanyRecovery(evidenceRef, deps.workContextRoot, deps.remoteOwnerHint, env, deps.useDeviceDefault === true, deps.validateMembership);
|
|
498
|
+
if (isSuccessfulBackfillRecovery(preview.kind)) {
|
|
459
499
|
result.reconciled += 1;
|
|
460
500
|
deps.log?.(`would reconcile ${sessionId}${harness ? ` (${harness})` : ""}`);
|
|
461
501
|
}
|
|
462
502
|
else {
|
|
463
503
|
result.unresolved += 1;
|
|
464
|
-
|
|
504
|
+
const reason = preview.reason ?? "no company evidence";
|
|
505
|
+
result.unresolvedReasons.push({ sessionId, reason });
|
|
506
|
+
deps.log?.(`would stay unresolved ${sessionId} (${reason})`);
|
|
465
507
|
}
|
|
466
508
|
continue;
|
|
467
509
|
}
|
|
468
510
|
const obs = observationFromHeldRef(evidenceRef, contractVersion, newOperationId());
|
|
469
511
|
try {
|
|
470
512
|
const outcome = await deps.reconcile(obs);
|
|
471
|
-
if (
|
|
513
|
+
if (isSuccessfulBackfillRecovery(outcome.result.kind) &&
|
|
514
|
+
hasDurableCompanyRecovery(sessionId, deps.workContextRoot, readState)) {
|
|
472
515
|
result.reconciled += 1;
|
|
473
516
|
deps.log?.(`reconciled ${sessionId} → durable company binding`);
|
|
474
517
|
}
|
|
@@ -478,7 +521,9 @@ export async function backfillHeldSessions(deps) {
|
|
|
478
521
|
}
|
|
479
522
|
else {
|
|
480
523
|
result.unresolved += 1;
|
|
481
|
-
|
|
524
|
+
const reason = outcome.diagnostics?.at(-1) ?? `${outcome.result.kind}: no durable company binding`;
|
|
525
|
+
result.unresolvedReasons.push({ sessionId, reason });
|
|
526
|
+
deps.log?.(`unresolved ${sessionId} (${reason})`);
|
|
482
527
|
}
|
|
483
528
|
}
|
|
484
529
|
catch (err) {
|
|
@@ -90,6 +90,8 @@ export interface DaemonRunDeps {
|
|
|
90
90
|
resolveMembership?: (token: string, slug: string, signal?: AbortSignal) => Promise<{
|
|
91
91
|
companyUid: string;
|
|
92
92
|
} | null>;
|
|
93
|
+
/** Test seam immediately before a device-default revocation's guarded commit. */
|
|
94
|
+
beforeDeviceDefaultRevocationCommit?: () => void;
|
|
93
95
|
log?: (dir: string, line: string) => void;
|
|
94
96
|
/** Stop after this many ms (tests). */
|
|
95
97
|
runForMs?: number;
|