@indigoai-us/hq-cli 5.101.5 → 5.101.7
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 +34 -0
- package/dist/commands/mesh.d.ts +12 -0
- package/dist/commands/mesh.js +173 -0
- package/dist/commands/outposts.d.ts +18 -232
- package/dist/commands/outposts.js +27 -1290
- package/dist/lib/mesh/api.d.ts +88 -0
- package/dist/lib/mesh/api.js +319 -0
- package/dist/lib/mesh/cache.d.ts +10 -0
- package/dist/lib/mesh/cache.js +42 -0
- package/dist/main.js +4 -0
- package/package.json +9 -3
- package/dist/commands/outposts-heartbeat.d.ts +0 -96
- package/dist/commands/outposts-heartbeat.js +0 -188
- package/dist/outpost/session-heartbeat-publisher.d.ts +0 -76
- package/dist/outpost/session-heartbeat-publisher.js +0 -117
- package/dist/outpost/session-heartbeat.d.ts +0 -210
- package/dist/outpost/session-heartbeat.js +0 -657
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Native work-mesh client for `hq mesh`.
|
|
3
|
+
*
|
|
4
|
+
* Talks to hq-pro over the shared vault fetch + Cognito session. Writes the
|
|
5
|
+
* machine cache apps already read. Does not spawn the pack helper and does
|
|
6
|
+
* not start MQTT listen.
|
|
7
|
+
*/
|
|
8
|
+
export declare const STORY_STATUSES: Set<string>;
|
|
9
|
+
export declare const ACTIVE_STATUSES: Set<string>;
|
|
10
|
+
export interface MeshCompany {
|
|
11
|
+
companyUid: string;
|
|
12
|
+
companySlug: string;
|
|
13
|
+
}
|
|
14
|
+
export interface MeshThread {
|
|
15
|
+
threadId?: string;
|
|
16
|
+
projectId?: string;
|
|
17
|
+
companyUid?: string;
|
|
18
|
+
threadStatus?: string;
|
|
19
|
+
ownerUid?: string;
|
|
20
|
+
progressSummary?: string;
|
|
21
|
+
sourceSignalSummary?: string;
|
|
22
|
+
blockedReason?: string;
|
|
23
|
+
lastActivityAt?: string;
|
|
24
|
+
createdAt?: string;
|
|
25
|
+
}
|
|
26
|
+
export declare function meshJson(token: string, path: string, init?: {
|
|
27
|
+
method?: string;
|
|
28
|
+
body?: Record<string, unknown>;
|
|
29
|
+
query?: Record<string, string>;
|
|
30
|
+
}): Promise<unknown>;
|
|
31
|
+
export declare function resolveMeshCompany(token: string, company?: string): Promise<MeshCompany>;
|
|
32
|
+
export declare function requireToken(): Promise<string>;
|
|
33
|
+
export declare function listActiveThreads(token: string, companyUid: string, projectId?: string): Promise<MeshThread[]>;
|
|
34
|
+
export declare function clamp(value: string, max: number): string;
|
|
35
|
+
export declare function eventPayload(kind: string, opts: {
|
|
36
|
+
summary?: string;
|
|
37
|
+
reason?: string;
|
|
38
|
+
ask?: string[];
|
|
39
|
+
}, claimedBy: string): Record<string, unknown>;
|
|
40
|
+
export declare function ensureProjectThread(token: string, company: MeshCompany, projectId: string, opts: {
|
|
41
|
+
threadId?: string;
|
|
42
|
+
summary?: string;
|
|
43
|
+
}): Promise<{
|
|
44
|
+
threadId: string;
|
|
45
|
+
created: boolean;
|
|
46
|
+
}>;
|
|
47
|
+
export declare function appendThreadEvent(token: string, companyUid: string, threadId: string, eventKind: string, payload: Record<string, unknown>): Promise<{
|
|
48
|
+
eventId?: string;
|
|
49
|
+
createdAt?: string;
|
|
50
|
+
}>;
|
|
51
|
+
export declare function patchStoryStatus(token: string, companyUid: string, projectId: string, storyId: string, status: string): Promise<{
|
|
52
|
+
status: string;
|
|
53
|
+
version?: number;
|
|
54
|
+
projectId: string;
|
|
55
|
+
}>;
|
|
56
|
+
export declare function pairDmUidsFromInbox(inbox: unknown): string[];
|
|
57
|
+
export interface WarmCacheResult {
|
|
58
|
+
directory: boolean;
|
|
59
|
+
inbox: boolean;
|
|
60
|
+
contacts: boolean;
|
|
61
|
+
threads: number;
|
|
62
|
+
cacheRoot: string;
|
|
63
|
+
}
|
|
64
|
+
/** Warm inbox + contacts + pair threads + directory into the local cache. */
|
|
65
|
+
export declare function warmMeshConversationCache(token: string, principalUid: string, cacheRoot?: string): Promise<WarmCacheResult>;
|
|
66
|
+
export declare function entityUidFromJwt(token: string): string | undefined;
|
|
67
|
+
export declare function principalUidFromTopic(topic: unknown): string | undefined;
|
|
68
|
+
/** Extract prs_/agt_ from a realtime credentials payload. Never returns secrets. */
|
|
69
|
+
export declare function principalUidFromRealtimePayload(data: unknown): string | undefined;
|
|
70
|
+
/**
|
|
71
|
+
* Cache key for directory/inbox/contacts. `custom:entityUid` rides the ID
|
|
72
|
+
* token (access tokens only have email/sub), so prefer the cached idToken.
|
|
73
|
+
*/
|
|
74
|
+
export declare function meshCachePrincipalUid(tokens: {
|
|
75
|
+
idToken?: string;
|
|
76
|
+
accessToken?: string;
|
|
77
|
+
} | null | undefined, fallbackToken?: string): string;
|
|
78
|
+
/**
|
|
79
|
+
* Resolve the cache principal. JWT first; if the human access/id tokens have
|
|
80
|
+
* no entityUid, ask `/v1/realtime/credentials` (same fallback as the pack
|
|
81
|
+
* doctor). Only the uid is kept — credentials are discarded.
|
|
82
|
+
*/
|
|
83
|
+
export declare function resolveMeshPrincipalUid(token: string, tokens?: {
|
|
84
|
+
idToken?: string;
|
|
85
|
+
accessToken?: string;
|
|
86
|
+
} | null): Promise<string>;
|
|
87
|
+
export declare function callerLabelFromToken(token: string): string;
|
|
88
|
+
//# sourceMappingURL=api.d.ts.map
|
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Native work-mesh client for `hq mesh`.
|
|
3
|
+
*
|
|
4
|
+
* Talks to hq-pro over the shared vault fetch + Cognito session. Writes the
|
|
5
|
+
* machine cache apps already read. Does not spawn the pack helper and does
|
|
6
|
+
* not start MQTT listen.
|
|
7
|
+
*/
|
|
8
|
+
import { ensureCognitoToken } from "../../utils/cognito-session.js";
|
|
9
|
+
import { peekIdToken } from "../../utils/id-token.js";
|
|
10
|
+
import { getCompanyUid, vaultApiFetch } from "../../utils/vault-api.js";
|
|
11
|
+
import { meshCacheRoot, removeSessionFallbackFiles, writeMeshCacheFile, } from "./cache.js";
|
|
12
|
+
const PRINCIPAL_UID = /^(prs|agt)_/;
|
|
13
|
+
export const STORY_STATUSES = new Set([
|
|
14
|
+
"queued",
|
|
15
|
+
"in_progress",
|
|
16
|
+
"review",
|
|
17
|
+
"done",
|
|
18
|
+
]);
|
|
19
|
+
export const ACTIVE_STATUSES = new Set([
|
|
20
|
+
"open",
|
|
21
|
+
"claimed",
|
|
22
|
+
"in-progress",
|
|
23
|
+
"blocked",
|
|
24
|
+
"needs-human",
|
|
25
|
+
]);
|
|
26
|
+
export async function meshJson(token, path, init = {}) {
|
|
27
|
+
const res = await vaultApiFetch({
|
|
28
|
+
token,
|
|
29
|
+
path,
|
|
30
|
+
method: init.method,
|
|
31
|
+
body: init.body,
|
|
32
|
+
query: init.query,
|
|
33
|
+
});
|
|
34
|
+
const text = await res.text();
|
|
35
|
+
let data = {};
|
|
36
|
+
if (text) {
|
|
37
|
+
try {
|
|
38
|
+
data = JSON.parse(text);
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
data = { raw: text };
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
if (!res.ok) {
|
|
45
|
+
const err = data;
|
|
46
|
+
throw new Error(err.error || err.message || `${res.status} ${res.statusText}`);
|
|
47
|
+
}
|
|
48
|
+
return data;
|
|
49
|
+
}
|
|
50
|
+
export async function resolveMeshCompany(token, company) {
|
|
51
|
+
const explicit = process.env.HQ_WORK_MESH_COMPANY_UID || process.env.HQ_COMPANY_UID;
|
|
52
|
+
if (explicit?.trim()) {
|
|
53
|
+
return { companyUid: explicit.trim(), companySlug: company || explicit.trim() };
|
|
54
|
+
}
|
|
55
|
+
if (company && /^(cmp|co)_/.test(company)) {
|
|
56
|
+
return { companyUid: company, companySlug: company };
|
|
57
|
+
}
|
|
58
|
+
const uid = await getCompanyUid(token, company);
|
|
59
|
+
return { companyUid: uid, companySlug: company || uid };
|
|
60
|
+
}
|
|
61
|
+
export async function requireToken() {
|
|
62
|
+
return ensureCognitoToken();
|
|
63
|
+
}
|
|
64
|
+
function asRecord(value) {
|
|
65
|
+
return value && typeof value === "object" && !Array.isArray(value)
|
|
66
|
+
? value
|
|
67
|
+
: null;
|
|
68
|
+
}
|
|
69
|
+
export async function listActiveThreads(token, companyUid, projectId) {
|
|
70
|
+
const collected = [];
|
|
71
|
+
for (const status of ACTIVE_STATUSES) {
|
|
72
|
+
let cursor = "";
|
|
73
|
+
for (let page = 0; page < 10; page += 1) {
|
|
74
|
+
const query = {
|
|
75
|
+
companyUid,
|
|
76
|
+
status,
|
|
77
|
+
limit: "100",
|
|
78
|
+
};
|
|
79
|
+
if (cursor)
|
|
80
|
+
query.cursor = cursor;
|
|
81
|
+
const data = asRecord(await meshJson(token, "/v1/work-mesh/threads", { query }));
|
|
82
|
+
const rows = Array.isArray(data?.threads)
|
|
83
|
+
? data.threads
|
|
84
|
+
: Array.isArray(data?.items)
|
|
85
|
+
? data.items
|
|
86
|
+
: [];
|
|
87
|
+
for (const row of rows) {
|
|
88
|
+
const rec = asRecord(row);
|
|
89
|
+
if (!rec)
|
|
90
|
+
continue;
|
|
91
|
+
collected.push(rec);
|
|
92
|
+
}
|
|
93
|
+
cursor = typeof data?.nextCursor === "string" ? data.nextCursor : "";
|
|
94
|
+
if (!cursor)
|
|
95
|
+
break;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return collected
|
|
99
|
+
.filter((thread) => !projectId || thread.projectId === projectId)
|
|
100
|
+
.sort((a, b) => String(b.lastActivityAt || b.createdAt || "").localeCompare(String(a.lastActivityAt || a.createdAt || "")));
|
|
101
|
+
}
|
|
102
|
+
export function clamp(value, max) {
|
|
103
|
+
return value.length <= max ? value : value.slice(0, max);
|
|
104
|
+
}
|
|
105
|
+
export function eventPayload(kind, opts, claimedBy) {
|
|
106
|
+
if (kind === "claim") {
|
|
107
|
+
return {
|
|
108
|
+
claimedBy: clamp(claimedBy, 120),
|
|
109
|
+
leaseTtlIso: new Date(Date.now() + 120 * 60 * 1000).toISOString(),
|
|
110
|
+
note: clamp(opts.summary || "Starting HQ project work.", 280),
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
if (kind === "progress") {
|
|
114
|
+
return { summary: clamp(opts.summary || "Project work is in progress.", 280) };
|
|
115
|
+
}
|
|
116
|
+
if (kind === "blocked") {
|
|
117
|
+
return {
|
|
118
|
+
reason: clamp(opts.reason || opts.summary || "Project work is blocked.", 500),
|
|
119
|
+
asks: (opts.ask ?? []).filter(Boolean).slice(0, 5),
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
if (kind === "done") {
|
|
123
|
+
return { summary: clamp(opts.summary || "Project work completed.", 280) };
|
|
124
|
+
}
|
|
125
|
+
return {
|
|
126
|
+
text: clamp(opts.summary || "HQ project note.", 500),
|
|
127
|
+
noteKind: "audit",
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
export async function ensureProjectThread(token, company, projectId, opts) {
|
|
131
|
+
if (opts.threadId)
|
|
132
|
+
return { threadId: opts.threadId, created: false };
|
|
133
|
+
const existing = (await listActiveThreads(token, company.companyUid, projectId))[0];
|
|
134
|
+
if (existing?.threadId)
|
|
135
|
+
return { threadId: existing.threadId, created: false };
|
|
136
|
+
const created = asRecord(await meshJson(token, "/v1/work-mesh/threads", {
|
|
137
|
+
method: "POST",
|
|
138
|
+
body: {
|
|
139
|
+
companyUid: company.companyUid,
|
|
140
|
+
projectId,
|
|
141
|
+
sourceSignalSummary: clamp(opts.summary || `HQ project ${projectId}`, 280),
|
|
142
|
+
routing: {
|
|
143
|
+
priority: "normal",
|
|
144
|
+
tags: ["hq-project", `project:${projectId}`],
|
|
145
|
+
},
|
|
146
|
+
},
|
|
147
|
+
}));
|
|
148
|
+
const threadId = String(created?.threadId || "").trim();
|
|
149
|
+
if (!threadId)
|
|
150
|
+
throw new Error("Work mesh did not return a threadId");
|
|
151
|
+
return { threadId, created: true };
|
|
152
|
+
}
|
|
153
|
+
export async function appendThreadEvent(token, companyUid, threadId, eventKind, payload) {
|
|
154
|
+
const data = asRecord(await meshJson(token, `/v1/work-mesh/threads/${encodeURIComponent(threadId)}/events`, {
|
|
155
|
+
method: "POST",
|
|
156
|
+
body: { companyUid, eventKind, payload },
|
|
157
|
+
}));
|
|
158
|
+
return {
|
|
159
|
+
eventId: typeof data?.eventId === "string" ? data.eventId : undefined,
|
|
160
|
+
createdAt: typeof data?.createdAt === "string" ? data.createdAt : undefined,
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
export async function patchStoryStatus(token, companyUid, projectId, storyId, status) {
|
|
164
|
+
const view = asRecord(await meshJson(token, `/v1/work-mesh/projects/${encodeURIComponent(projectId)}/stories/${encodeURIComponent(storyId)}`, {
|
|
165
|
+
method: "PATCH",
|
|
166
|
+
body: { companyUid, status },
|
|
167
|
+
}));
|
|
168
|
+
if (view && typeof view.projectId === "string" && typeof view.companyUid === "string") {
|
|
169
|
+
writeMeshCacheFile(["projects", view.companyUid, `${view.projectId}.json`], view);
|
|
170
|
+
}
|
|
171
|
+
const stories = Array.isArray(view?.stories) ? view.stories : [];
|
|
172
|
+
const patched = stories
|
|
173
|
+
.map(asRecord)
|
|
174
|
+
.find((row) => row && row.id === storyId);
|
|
175
|
+
return {
|
|
176
|
+
projectId: String(view?.projectId || projectId),
|
|
177
|
+
status: typeof patched?.status === "string" ? patched.status : status,
|
|
178
|
+
version: typeof view?.version === "number" ? view.version : undefined,
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
export function pairDmUidsFromInbox(inbox) {
|
|
182
|
+
const rec = asRecord(inbox);
|
|
183
|
+
const ids = new Set();
|
|
184
|
+
const events = Array.isArray(rec?.events) ? rec.events : [];
|
|
185
|
+
for (const event of events) {
|
|
186
|
+
const row = asRecord(event);
|
|
187
|
+
const uid = String(row?.fromPersonUid || "").trim();
|
|
188
|
+
if (/^(prs|agt)_[A-Za-z0-9]+$/.test(uid))
|
|
189
|
+
ids.add(uid);
|
|
190
|
+
}
|
|
191
|
+
const pairs = Array.isArray(rec?.pairUnreads) ? rec.pairUnreads : [];
|
|
192
|
+
for (const pair of pairs) {
|
|
193
|
+
const row = asRecord(pair);
|
|
194
|
+
const uid = String(row?.withPersonUid || "").trim();
|
|
195
|
+
if (/^(prs|agt)_[A-Za-z0-9]+$/.test(uid))
|
|
196
|
+
ids.add(uid);
|
|
197
|
+
}
|
|
198
|
+
return [...ids].sort();
|
|
199
|
+
}
|
|
200
|
+
/** Warm inbox + contacts + pair threads + directory into the local cache. */
|
|
201
|
+
export async function warmMeshConversationCache(token, principalUid, cacheRoot = meshCacheRoot()) {
|
|
202
|
+
const result = {
|
|
203
|
+
directory: false,
|
|
204
|
+
inbox: false,
|
|
205
|
+
contacts: false,
|
|
206
|
+
threads: 0,
|
|
207
|
+
cacheRoot,
|
|
208
|
+
};
|
|
209
|
+
try {
|
|
210
|
+
const directory = await meshJson(token, "/v1/notify/channels", {
|
|
211
|
+
query: { cursor: "" },
|
|
212
|
+
});
|
|
213
|
+
writeMeshCacheFile(["directory", `${principalUid}.json`], directory, cacheRoot);
|
|
214
|
+
result.directory = true;
|
|
215
|
+
}
|
|
216
|
+
catch {
|
|
217
|
+
// Directory warm is best-effort; inbox is the pair-DM path.
|
|
218
|
+
}
|
|
219
|
+
const inbox = await meshJson(token, "/v1/notify/inbox", {
|
|
220
|
+
query: { limit: "50" },
|
|
221
|
+
});
|
|
222
|
+
writeMeshCacheFile(["inbox", `${principalUid}.json`], inbox, cacheRoot);
|
|
223
|
+
result.inbox = true;
|
|
224
|
+
try {
|
|
225
|
+
const contacts = await meshJson(token, "/v1/notify/contacts");
|
|
226
|
+
writeMeshCacheFile(["contacts", `${principalUid}.json`], contacts, cacheRoot);
|
|
227
|
+
result.contacts = true;
|
|
228
|
+
}
|
|
229
|
+
catch {
|
|
230
|
+
// Contacts roster is optional for the rail.
|
|
231
|
+
}
|
|
232
|
+
for (const uid of pairDmUidsFromInbox(inbox)) {
|
|
233
|
+
try {
|
|
234
|
+
const thread = await meshJson(token, "/v1/notify/thread", {
|
|
235
|
+
query: { withPersonUid: uid, limit: "50" },
|
|
236
|
+
});
|
|
237
|
+
writeMeshCacheFile(["dms", `${uid}.json`], thread, cacheRoot);
|
|
238
|
+
result.threads += 1;
|
|
239
|
+
}
|
|
240
|
+
catch {
|
|
241
|
+
// one pair must not fail the rest
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
if (PRINCIPAL_UID.test(principalUid)) {
|
|
245
|
+
removeSessionFallbackFiles(cacheRoot);
|
|
246
|
+
}
|
|
247
|
+
return result;
|
|
248
|
+
}
|
|
249
|
+
export function entityUidFromJwt(token) {
|
|
250
|
+
const uid = peekIdToken(token)["custom:entityUid"];
|
|
251
|
+
return typeof uid === "string" && PRINCIPAL_UID.test(uid) ? uid : undefined;
|
|
252
|
+
}
|
|
253
|
+
export function principalUidFromTopic(topic) {
|
|
254
|
+
if (typeof topic !== "string")
|
|
255
|
+
return undefined;
|
|
256
|
+
const match = /^hq\/((?:prs|agt)_[0-9A-HJKMNP-TV-Z]{26})(?:\/|$)/.exec(topic);
|
|
257
|
+
return match?.[1];
|
|
258
|
+
}
|
|
259
|
+
/** Extract prs_/agt_ from a realtime credentials payload. Never returns secrets. */
|
|
260
|
+
export function principalUidFromRealtimePayload(data) {
|
|
261
|
+
const rec = asRecord(data);
|
|
262
|
+
if (!rec)
|
|
263
|
+
return undefined;
|
|
264
|
+
const claimed = rec.principalUid;
|
|
265
|
+
if (typeof claimed === "string" && PRINCIPAL_UID.test(claimed))
|
|
266
|
+
return claimed;
|
|
267
|
+
const topics = asRecord(rec.topics);
|
|
268
|
+
return (principalUidFromTopic(topics?.dm) ||
|
|
269
|
+
principalUidFromTopic(rec.topic));
|
|
270
|
+
}
|
|
271
|
+
/**
|
|
272
|
+
* Cache key for directory/inbox/contacts. `custom:entityUid` rides the ID
|
|
273
|
+
* token (access tokens only have email/sub), so prefer the cached idToken.
|
|
274
|
+
*/
|
|
275
|
+
export function meshCachePrincipalUid(tokens, fallbackToken) {
|
|
276
|
+
for (const token of [tokens?.idToken, tokens?.accessToken, fallbackToken]) {
|
|
277
|
+
if (!token)
|
|
278
|
+
continue;
|
|
279
|
+
const uid = entityUidFromJwt(token);
|
|
280
|
+
if (uid)
|
|
281
|
+
return uid;
|
|
282
|
+
}
|
|
283
|
+
return "session";
|
|
284
|
+
}
|
|
285
|
+
/**
|
|
286
|
+
* Resolve the cache principal. JWT first; if the human access/id tokens have
|
|
287
|
+
* no entityUid, ask `/v1/realtime/credentials` (same fallback as the pack
|
|
288
|
+
* doctor). Only the uid is kept — credentials are discarded.
|
|
289
|
+
*/
|
|
290
|
+
export async function resolveMeshPrincipalUid(token, tokens) {
|
|
291
|
+
const fromJwt = meshCachePrincipalUid(tokens, token);
|
|
292
|
+
if (fromJwt !== "session")
|
|
293
|
+
return fromJwt;
|
|
294
|
+
try {
|
|
295
|
+
const data = await meshJson(token, "/v1/realtime/credentials", {
|
|
296
|
+
method: "POST",
|
|
297
|
+
body: { contractVersion: 2 },
|
|
298
|
+
});
|
|
299
|
+
const uid = principalUidFromRealtimePayload(data);
|
|
300
|
+
if (uid)
|
|
301
|
+
return uid;
|
|
302
|
+
}
|
|
303
|
+
catch {
|
|
304
|
+
// best-effort; session.json is worse than failing doctor
|
|
305
|
+
}
|
|
306
|
+
return "session";
|
|
307
|
+
}
|
|
308
|
+
export function callerLabelFromToken(token) {
|
|
309
|
+
const claims = peekIdToken(token);
|
|
310
|
+
const entityUid = claims["custom:entityUid"];
|
|
311
|
+
if (typeof entityUid === "string" && entityUid)
|
|
312
|
+
return entityUid;
|
|
313
|
+
if (typeof claims.email === "string" && claims.email)
|
|
314
|
+
return claims.email;
|
|
315
|
+
if (typeof claims.sub === "string" && claims.sub)
|
|
316
|
+
return claims.sub;
|
|
317
|
+
return "hq-cli";
|
|
318
|
+
}
|
|
319
|
+
//# sourceMappingURL=api.js.map
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* On-disk work-mesh cache. Same layout the desktop/web apps and the pack
|
|
3
|
+
* helper read: ~/.hq/work-mesh/cache/{projects,channels,directory,inbox,contacts,dms}.
|
|
4
|
+
*/
|
|
5
|
+
export declare function meshCacheRoot(home?: string, env?: NodeJS.ProcessEnv): string;
|
|
6
|
+
export declare function isSafeCacheSegment(value: string): boolean;
|
|
7
|
+
export declare function writeMeshCacheFile(segments: string[], value: unknown, root?: string): string | null;
|
|
8
|
+
/** Drop leftover session.json once we know the real prs_/agt_ cache key. */
|
|
9
|
+
export declare function removeSessionFallbackFiles(root?: string): void;
|
|
10
|
+
//# sourceMappingURL=cache.d.ts.map
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* On-disk work-mesh cache. Same layout the desktop/web apps and the pack
|
|
3
|
+
* helper read: ~/.hq/work-mesh/cache/{projects,channels,directory,inbox,contacts,dms}.
|
|
4
|
+
*/
|
|
5
|
+
import * as fs from "node:fs";
|
|
6
|
+
import * as os from "node:os";
|
|
7
|
+
import * as path from "node:path";
|
|
8
|
+
export function meshCacheRoot(home = os.homedir(), env = process.env) {
|
|
9
|
+
const override = env.HQ_WORK_MESH_CACHE?.trim();
|
|
10
|
+
if (override)
|
|
11
|
+
return override;
|
|
12
|
+
return path.join(home, ".hq", "work-mesh", "cache");
|
|
13
|
+
}
|
|
14
|
+
export function isSafeCacheSegment(value) {
|
|
15
|
+
const trimmed = value.trim();
|
|
16
|
+
return Boolean(trimmed &&
|
|
17
|
+
trimmed.length <= 128 &&
|
|
18
|
+
!trimmed.includes("..") &&
|
|
19
|
+
/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(trimmed));
|
|
20
|
+
}
|
|
21
|
+
export function writeMeshCacheFile(segments, value, root = meshCacheRoot()) {
|
|
22
|
+
if (segments.length === 0 || !segments.every(isSafeCacheSegment))
|
|
23
|
+
return null;
|
|
24
|
+
const dest = path.join(root, ...segments);
|
|
25
|
+
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
26
|
+
const tmp = `${dest}.tmp`;
|
|
27
|
+
fs.writeFileSync(tmp, `${JSON.stringify(value, null, 2)}\n`);
|
|
28
|
+
fs.renameSync(tmp, dest);
|
|
29
|
+
return dest;
|
|
30
|
+
}
|
|
31
|
+
/** Drop leftover session.json once we know the real prs_/agt_ cache key. */
|
|
32
|
+
export function removeSessionFallbackFiles(root = meshCacheRoot()) {
|
|
33
|
+
for (const dir of ["directory", "inbox", "contacts"]) {
|
|
34
|
+
try {
|
|
35
|
+
fs.unlinkSync(path.join(root, dir, "session.json"));
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
// missing is fine
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
//# sourceMappingURL=cache.js.map
|
package/dist/main.js
CHANGED
|
@@ -61,6 +61,7 @@ import { registerCoreCommands } from "./commands/core.js";
|
|
|
61
61
|
import { registerSearchCommand } from "./commands/search.js";
|
|
62
62
|
import { registerIndexCommand } from "./commands/index-cmd.js";
|
|
63
63
|
import { registerDoctorCommand } from "./commands/doctor.js";
|
|
64
|
+
import { registerMeshCommand } from "./commands/mesh.js";
|
|
64
65
|
import { sanitizeArgv } from "./utils/feedback-diagnostics.js";
|
|
65
66
|
import { environmentalFsErrorMessage } from "./utils/environmental-error.js";
|
|
66
67
|
import { networkTransportErrorMessage } from "./utils/network-transport-error.js";
|
|
@@ -274,6 +275,9 @@ registerIndexCommand(program);
|
|
|
274
275
|
// check registry so later check families (vault, sync, MCP, …) plug in without
|
|
275
276
|
// engine changes.
|
|
276
277
|
registerDoctorCommand(program);
|
|
278
|
+
// Work mesh (subcommand group — `hq mesh …`). Native REST + cache. Distinct
|
|
279
|
+
// from `hq doctor` (hook guardrails). Does not start MQTT listen.
|
|
280
|
+
registerMeshCommand(program);
|
|
277
281
|
program.hook("preAction", async () => {
|
|
278
282
|
await emitCliSessionStarted();
|
|
279
283
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@indigoai-us/hq-cli",
|
|
3
|
-
"version": "5.101.
|
|
3
|
+
"version": "5.101.7",
|
|
4
4
|
"description": "HQ by Indigo management CLI — modules and cloud sync",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"bin": {
|
|
@@ -24,12 +24,13 @@
|
|
|
24
24
|
"test:e2e": "vitest run --config vitest.e2e.config.ts",
|
|
25
25
|
"coverage": "vitest run --coverage --coverage.reporter=text-summary --coverage.reporter=json-summary",
|
|
26
26
|
"vitest": "vitest",
|
|
27
|
-
"clean": "rm -rf dist"
|
|
27
|
+
"clean": "rm -rf dist",
|
|
28
|
+
"prepare": "husky || true"
|
|
28
29
|
},
|
|
29
30
|
"dependencies": {
|
|
30
31
|
"@aws-sdk/client-iot-data-plane": "^3.1096.0",
|
|
31
32
|
"@aws-sdk/client-s3": "^3.1049.0",
|
|
32
|
-
"@indigoai-us/hq-cloud": "~6.15.
|
|
33
|
+
"@indigoai-us/hq-cloud": "~6.15.5",
|
|
33
34
|
"@indigoai-us/hq-onboarding": "^0.1.0",
|
|
34
35
|
"@sentry/node": "^10.49.0",
|
|
35
36
|
"@tobilu/qmd": "2.5.3",
|
|
@@ -52,6 +53,8 @@
|
|
|
52
53
|
"@vitest/coverage-v8": "4.1.6",
|
|
53
54
|
"aws-sdk-client-mock": "^4.1.0",
|
|
54
55
|
"eslint": "^10.5.0",
|
|
56
|
+
"husky": "^9.1.7",
|
|
57
|
+
"lint-staged": "^15.5.2",
|
|
55
58
|
"typescript": "^5.7.0",
|
|
56
59
|
"typescript-eslint": "^8.61.1",
|
|
57
60
|
"vitest": "^4.1.2"
|
|
@@ -77,5 +80,8 @@
|
|
|
77
80
|
"onlyBuiltDependencies": [
|
|
78
81
|
"better-sqlite3"
|
|
79
82
|
]
|
|
83
|
+
},
|
|
84
|
+
"lint-staged": {
|
|
85
|
+
"*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}": "eslint --cache --no-error-on-unmatched-pattern"
|
|
80
86
|
}
|
|
81
87
|
}
|
|
@@ -1,96 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* `hq outposts heartbeat` — publish this box's agent-session summary to the
|
|
3
|
-
* realtime fabric.
|
|
4
|
-
*
|
|
5
|
-
* Runs ON an Outpost, under systemd. It enumerates the box's local Claude Code
|
|
6
|
-
* and Codex sessions with cheap scandir + stat + bounded reads, projects them
|
|
7
|
-
* down to the compact secret-free `AgentSession[]` payload, and publishes to
|
|
8
|
-
* `hq/{personUid}/sessions` so Mission Control can see what the box is doing.
|
|
9
|
-
*
|
|
10
|
-
* ## Why the cadence lives in here
|
|
11
|
-
*
|
|
12
|
-
* The original box-side design was a bash `while` loop that, every 5 seconds,
|
|
13
|
-
* ran `hq auth refresh` and then `npx -y --package=@indigoai-us/hq-cloud@latest
|
|
14
|
-
* outpost-session-heartbeat-runner`. That is ~35k process launches a day, and
|
|
15
|
-
* every `npx` re-resolves the package and re-reads it off disk — on a t3 box
|
|
16
|
-
* whose EBS budget is the scarce resource, that is a meaningful, permanent tax
|
|
17
|
-
* for a job that should be nearly free.
|
|
18
|
-
*
|
|
19
|
-
* So the loop lives in-process instead:
|
|
20
|
-
* - identity is resolved ONCE per process, not once per tick;
|
|
21
|
-
* - the Cognito session refreshes itself on demand (`ensureCognitoToken`
|
|
22
|
-
* returns the cached token until it is close to expiry), so there is no
|
|
23
|
-
* separate per-tick `hq auth refresh`;
|
|
24
|
-
* - the IoT client is cached across ticks by the publisher and only rebuilt
|
|
25
|
-
* when its credentials near expiry.
|
|
26
|
-
*
|
|
27
|
-
* The loop is deliberately unkillable-by-transients: a failed publish, a failed
|
|
28
|
-
* enumeration, or a not-yet-warm session are all logged and retried on the next
|
|
29
|
-
* tick. Nothing here should ever take the service down, because a service that
|
|
30
|
-
* exits looks identical to a service that is quietly failing.
|
|
31
|
-
*/
|
|
32
|
-
import type { Command } from "commander";
|
|
33
|
-
import { type FileSystemPort, type PublishPort, type SessionsHeartbeatPayload } from "../outpost/session-heartbeat.js";
|
|
34
|
-
/** Everything the loop touches, injected so it is testable without a box. */
|
|
35
|
-
export interface HeartbeatLoopDeps {
|
|
36
|
-
fs: FileSystemPort;
|
|
37
|
-
publish: PublishPort;
|
|
38
|
-
/** Resolve the caller's canonical `prs_*` id. Called once per process. */
|
|
39
|
-
getPersonUid: () => Promise<string>;
|
|
40
|
-
/**
|
|
41
|
-
* Wait between ticks. MUST return early when `signal` aborts — otherwise a
|
|
42
|
-
* SIGTERM arriving mid-sleep is not noticed until the full interval elapses,
|
|
43
|
-
* and systemd force-kills the process instead of it stopping cooperatively.
|
|
44
|
-
*/
|
|
45
|
-
sleep: (ms: number, signal?: AbortSignal) => Promise<void>;
|
|
46
|
-
now: () => Date;
|
|
47
|
-
log: (line: string) => void;
|
|
48
|
-
/**
|
|
49
|
-
* Record that a heartbeat actually LANDED. Called only after a successful
|
|
50
|
-
* publish — never on failure, or the freshness signal it feeds would always
|
|
51
|
-
* read fresh and be worth nothing.
|
|
52
|
-
*/
|
|
53
|
-
onPublished?: (payload: SessionsHeartbeatPayload) => Promise<void>;
|
|
54
|
-
}
|
|
55
|
-
export interface HeartbeatLoopOptions {
|
|
56
|
-
/** Emit a single heartbeat and return. */
|
|
57
|
-
once?: boolean;
|
|
58
|
-
/** Cadence between ticks. Defaults to {@link DEFAULT_HEARTBEAT_INTERVAL_SECONDS}. */
|
|
59
|
-
intervalSeconds?: number;
|
|
60
|
-
/** Home directory to scan (defaults to the process HOME). */
|
|
61
|
-
home?: string;
|
|
62
|
-
/** Print each published payload as JSON. */
|
|
63
|
-
json?: boolean;
|
|
64
|
-
/** Stop the loop cooperatively. */
|
|
65
|
-
signal?: AbortSignal;
|
|
66
|
-
/** Bound the loop — used by tests; production runs until aborted. */
|
|
67
|
-
maxTicks?: number;
|
|
68
|
-
}
|
|
69
|
-
/**
|
|
70
|
-
* Resolve the cadence from the flag, then the environment, then the default.
|
|
71
|
-
* Floored at 1s: a sub-second cadence hammers the fabric for no benefit, and a
|
|
72
|
-
* zero/negative value would spin the loop hot.
|
|
73
|
-
*/
|
|
74
|
-
export declare function resolveIntervalSeconds(flag: string | undefined, env?: NodeJS.ProcessEnv): number;
|
|
75
|
-
/**
|
|
76
|
-
* Where the box records its last LANDED heartbeat. The post-provision audit
|
|
77
|
-
* reads this file's freshness, because "systemd says the unit is active" is not
|
|
78
|
-
* evidence that anything was published — that gap is what let a heartbeat fail
|
|
79
|
-
* every five seconds for nine days while every dashboard stayed green.
|
|
80
|
-
*
|
|
81
|
-
* Lives under the service user's home so the unit (User=ec2-user) can write it
|
|
82
|
-
* without extra tmpfiles.d wiring; the SSM collector reads it as root.
|
|
83
|
-
*/
|
|
84
|
-
export declare const DEFAULT_HEARTBEAT_STATE_FILE: string;
|
|
85
|
-
/** Persist the liveness marker the box audit reads. */
|
|
86
|
-
export declare function writeHeartbeatState(path: string, payload: SessionsHeartbeatPayload): Promise<void>;
|
|
87
|
-
/**
|
|
88
|
-
* Run the heartbeat loop. Returns the number of ticks performed.
|
|
89
|
-
*
|
|
90
|
-
* A "tick" is one attempt, whether or not it published — the count is the
|
|
91
|
-
* loop's liveness, not its success rate.
|
|
92
|
-
*/
|
|
93
|
-
export declare function runHeartbeatLoop(options: HeartbeatLoopOptions, deps: HeartbeatLoopDeps): Promise<number>;
|
|
94
|
-
/** Wire the production dependencies and register the subcommand. */
|
|
95
|
-
export declare function registerHeartbeatCommand(outposts: Command): void;
|
|
96
|
-
//# sourceMappingURL=outposts-heartbeat.d.ts.map
|