@indigoai-us/hq-cli 5.345.161 → 5.345.162
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 +12 -0
- package/dist/command-catalog.generated.d.ts +44 -0
- package/dist/command-catalog.generated.js +53 -0
- package/dist/command-registration-plan.d.ts +6 -0
- package/dist/command-registration-plan.js +1 -0
- package/dist/commands/agent-inbox.js +6 -2
- package/dist/commands/lanes-loop.js +7 -7
- package/dist/commands/lanes-questions.js +54 -4
- package/dist/commands/lanes.js +2 -1
- package/dist/commands/sync-held.d.ts +87 -0
- package/dist/commands/sync-held.js +191 -0
- package/dist/hq-cloud-sync-runner/bin/sync-runner.js +3095 -2769
- package/dist/hq-cloud-sync-runner/package.json +1 -1
- package/dist/lib/agent-kit/inbox-state.d.ts +5 -4
- package/dist/lib/agent-kit/inbox-state.js +24 -3
- package/dist/lib/flag-registry-worker-source.generated.d.ts +1 -1
- package/dist/lib/flag-registry-worker-source.generated.js +1 -1
- package/dist/lib/lanes/loop.d.ts +17 -0
- package/dist/lib/lanes/loop.js +235 -5
- package/dist/lib/lanes/questions.d.ts +1 -0
- package/package.json +8 -8
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,18 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [5.345.162] — 2026-10-23
|
|
6
|
+
- `hq agent inbox` no longer reports malformed server responses as a verified empty inbox. Invalid JSON or a missing/non-array `messages` field keeps `source: "local-mirror"` and a privacy-safe warning. Mixed responses retain usable messages with `source: "server-partial"` and an incomplete-result warning; only `source: "server"` certifies completeness. Human output also says when an empty result is unverified. Reads still never send replies or acknowledgments.
|
|
7
|
+
- The bundled sync engine now uses hq-cloud 6.18.100, the current npm `latest`.
|
|
8
|
+
After an account switch, HQ Sync no longer pushes files left behind by the
|
|
9
|
+
previous person (hq-cloud #1040).
|
|
10
|
+
- Park a loop when its phase handoff remains malformed after repair, preserving
|
|
11
|
+
worker evidence and later queued work. The existing question flow can accept a
|
|
12
|
+
validated repaired handoff without rerunning the phase, explicitly retry with
|
|
13
|
+
an external-effects warning, or leave the phase held. Durable holds survive
|
|
14
|
+
controller restart, and explicit interruption and stop remain available.
|
|
15
|
+
- New `hq sync held` and `hq sync release-held`. After someone else signs in on the same computer, HQ sync holds every upload and cloud delete for the personal vault and each company until the signed-in person releases them. `hq sync held` lists what is held, with a count and sample paths. `hq sync release-held` shows the signed-in person's held scopes, asks "Are these files yours to upload?" and releases them (`--company <slug>`, `--personal`, `--yes`, `--json`). It identifies the person from the current session token, so a session without an email works, and it releases nothing if the signed-in account changes during the command. Both commands need an HQ sync engine with this hold; on an older one they say to update HQ. Feedback 3bf773c5-320e-433d-b84b-0bab13147ff5.
|
|
16
|
+
|
|
5
17
|
## [5.345.161] — 2026-10-23
|
|
6
18
|
- The bundled sync engine now uses hq-cloud 6.18.99, the current npm `latest`.
|
|
7
19
|
HQ Sync follows the server's telemetry decision for free-plan accounts,
|
|
@@ -1175,6 +1175,50 @@ export declare const COMMAND_CATALOG: readonly [{
|
|
|
1175
1175
|
readonly defaultValue: "<runtime-path>";
|
|
1176
1176
|
}];
|
|
1177
1177
|
readonly subcommands: readonly [];
|
|
1178
|
+
}, {
|
|
1179
|
+
readonly name: "held";
|
|
1180
|
+
readonly description: "Show uploads held because someone else was signed in on this computer";
|
|
1181
|
+
readonly aliases: readonly [];
|
|
1182
|
+
readonly hidden: false;
|
|
1183
|
+
readonly helpOption: true;
|
|
1184
|
+
readonly helpCommand: false;
|
|
1185
|
+
readonly usage: "[options]";
|
|
1186
|
+
readonly helpText: readonly [];
|
|
1187
|
+
readonly arguments: readonly [];
|
|
1188
|
+
readonly options: readonly [{
|
|
1189
|
+
readonly flags: "--json";
|
|
1190
|
+
readonly description: "Print the held scopes as JSON";
|
|
1191
|
+
readonly hidden: false;
|
|
1192
|
+
}];
|
|
1193
|
+
readonly subcommands: readonly [];
|
|
1194
|
+
}, {
|
|
1195
|
+
readonly name: "release-held";
|
|
1196
|
+
readonly description: "Confirm held files are yours and let sync upload them";
|
|
1197
|
+
readonly aliases: readonly [];
|
|
1198
|
+
readonly hidden: false;
|
|
1199
|
+
readonly helpOption: true;
|
|
1200
|
+
readonly helpCommand: false;
|
|
1201
|
+
readonly usage: "[options]";
|
|
1202
|
+
readonly helpText: readonly [];
|
|
1203
|
+
readonly arguments: readonly [];
|
|
1204
|
+
readonly options: readonly [{
|
|
1205
|
+
readonly flags: "--company <slug>";
|
|
1206
|
+
readonly description: "Release only this company";
|
|
1207
|
+
readonly hidden: false;
|
|
1208
|
+
}, {
|
|
1209
|
+
readonly flags: "--personal";
|
|
1210
|
+
readonly description: "Release only the personal vault";
|
|
1211
|
+
readonly hidden: false;
|
|
1212
|
+
}, {
|
|
1213
|
+
readonly flags: "--yes";
|
|
1214
|
+
readonly description: "Do not ask for confirmation";
|
|
1215
|
+
readonly hidden: false;
|
|
1216
|
+
}, {
|
|
1217
|
+
readonly flags: "--json";
|
|
1218
|
+
readonly description: "Print the result as JSON";
|
|
1219
|
+
readonly hidden: false;
|
|
1220
|
+
}];
|
|
1221
|
+
readonly subcommands: readonly [];
|
|
1178
1222
|
}];
|
|
1179
1223
|
}, {
|
|
1180
1224
|
readonly name: "cloud";
|
|
@@ -1412,6 +1412,59 @@ export const COMMAND_CATALOG = [
|
|
|
1412
1412
|
}
|
|
1413
1413
|
],
|
|
1414
1414
|
"subcommands": []
|
|
1415
|
+
},
|
|
1416
|
+
{
|
|
1417
|
+
"name": "held",
|
|
1418
|
+
"description": "Show uploads held because someone else was signed in on this computer",
|
|
1419
|
+
"aliases": [],
|
|
1420
|
+
"hidden": false,
|
|
1421
|
+
"helpOption": true,
|
|
1422
|
+
"helpCommand": false,
|
|
1423
|
+
"usage": "[options]",
|
|
1424
|
+
"helpText": [],
|
|
1425
|
+
"arguments": [],
|
|
1426
|
+
"options": [
|
|
1427
|
+
{
|
|
1428
|
+
"flags": "--json",
|
|
1429
|
+
"description": "Print the held scopes as JSON",
|
|
1430
|
+
"hidden": false
|
|
1431
|
+
}
|
|
1432
|
+
],
|
|
1433
|
+
"subcommands": []
|
|
1434
|
+
},
|
|
1435
|
+
{
|
|
1436
|
+
"name": "release-held",
|
|
1437
|
+
"description": "Confirm held files are yours and let sync upload them",
|
|
1438
|
+
"aliases": [],
|
|
1439
|
+
"hidden": false,
|
|
1440
|
+
"helpOption": true,
|
|
1441
|
+
"helpCommand": false,
|
|
1442
|
+
"usage": "[options]",
|
|
1443
|
+
"helpText": [],
|
|
1444
|
+
"arguments": [],
|
|
1445
|
+
"options": [
|
|
1446
|
+
{
|
|
1447
|
+
"flags": "--company <slug>",
|
|
1448
|
+
"description": "Release only this company",
|
|
1449
|
+
"hidden": false
|
|
1450
|
+
},
|
|
1451
|
+
{
|
|
1452
|
+
"flags": "--personal",
|
|
1453
|
+
"description": "Release only the personal vault",
|
|
1454
|
+
"hidden": false
|
|
1455
|
+
},
|
|
1456
|
+
{
|
|
1457
|
+
"flags": "--yes",
|
|
1458
|
+
"description": "Do not ask for confirmation",
|
|
1459
|
+
"hidden": false
|
|
1460
|
+
},
|
|
1461
|
+
{
|
|
1462
|
+
"flags": "--json",
|
|
1463
|
+
"description": "Print the result as JSON",
|
|
1464
|
+
"hidden": false
|
|
1465
|
+
}
|
|
1466
|
+
],
|
|
1467
|
+
"subcommands": []
|
|
1415
1468
|
}
|
|
1416
1469
|
]
|
|
1417
1470
|
},
|
|
@@ -135,6 +135,12 @@ export declare const REGISTRATION_PLAN: readonly [{
|
|
|
135
135
|
readonly parent: "sync";
|
|
136
136
|
readonly module: "./commands/sync-manifest.js";
|
|
137
137
|
readonly exportName: "registerSyncManifestCommand";
|
|
138
|
+
}, {
|
|
139
|
+
readonly type: "registrar";
|
|
140
|
+
readonly root: "sync";
|
|
141
|
+
readonly parent: "sync";
|
|
142
|
+
readonly module: "./commands/sync-held.js";
|
|
143
|
+
readonly exportName: "registerSyncHeldCommands";
|
|
138
144
|
}, {
|
|
139
145
|
readonly type: "group";
|
|
140
146
|
readonly root: "cloud";
|
|
@@ -25,6 +25,7 @@ export const REGISTRATION_PLAN = [
|
|
|
25
25
|
{ type: "registrar", root: "sync", parent: "sync", module: "./commands/sync-mode.js", exportName: "registerSyncModeCommand" },
|
|
26
26
|
{ type: "registrar", root: "sync", parent: "sync", module: "./commands/sync-narrow.js", exportName: "registerSyncNarrowCommand" },
|
|
27
27
|
{ type: "registrar", root: "sync", parent: "sync", module: "./commands/sync-manifest.js", exportName: "registerSyncManifestCommand" },
|
|
28
|
+
{ type: "registrar", root: "sync", parent: "sync", module: "./commands/sync-held.js", exportName: "registerSyncHeldCommands" },
|
|
28
29
|
{ type: "group", root: "cloud", id: "cloud", name: "cloud", description: "Cloud commands — provision entities and manage cloud-backed companies" },
|
|
29
30
|
{ type: "registrar", root: "cloud", parent: "cloud", module: "./commands/cloud-provision.js", exportName: "registerCloudProvisionCommands" },
|
|
30
31
|
{ type: "registrar", root: "cloud", parent: "cloud", module: "./commands/cloud-retire.js", exportName: "registerCloudRetireCommands" },
|
|
@@ -33,7 +33,9 @@ export function registerAgentInboxCommand(agent) {
|
|
|
33
33
|
baseUrl: creds.apiBaseUrl,
|
|
34
34
|
path: `/v1/agents/${encodeURIComponent(creds.entityUid)}/inbox`,
|
|
35
35
|
});
|
|
36
|
-
|
|
36
|
+
// Preserve a decode failure as an invalid body for mergeAgentInbox to
|
|
37
|
+
// classify. Parser errors can contain response bytes: do not echo them.
|
|
38
|
+
return { status: res.status, body: await res.json().catch(() => undefined) };
|
|
37
39
|
});
|
|
38
40
|
const rows = entries
|
|
39
41
|
.filter((e) => opts.all || !done.has(e.id))
|
|
@@ -45,7 +47,9 @@ export function registerAgentInboxCommand(agent) {
|
|
|
45
47
|
if (note)
|
|
46
48
|
console.error(chalk.yellow(note));
|
|
47
49
|
if (rows.length === 0) {
|
|
48
|
-
console.log(
|
|
50
|
+
console.log(note
|
|
51
|
+
? "No matching messages can be confirmed; the server inbox could not be read completely."
|
|
52
|
+
: opts.all ? "Inbox is empty." : "No pending messages.");
|
|
49
53
|
return;
|
|
50
54
|
}
|
|
51
55
|
for (const r of rows) {
|
|
@@ -7,7 +7,7 @@ import { readLatestEnvelopeRecord } from "../lib/lanes/watcher.js";
|
|
|
7
7
|
import { spawnLaneWorker } from "../lib/lanes/spawn.js";
|
|
8
8
|
import { raiseLaneQuestion } from "../lib/lanes/questions.js";
|
|
9
9
|
import { isTerminalLaneState } from "../lib/lanes/types.js";
|
|
10
|
-
import { clearLoopControllerPid, loopAdmissionPath, runLoopLane, setLoopControllerPid } from "../lib/lanes/loop.js";
|
|
10
|
+
import { HANDOFF_HOLD_CHOICES, clearLoopControllerPid, loopAdmissionPath, runLoopLane, setLoopControllerPid } from "../lib/lanes/loop.js";
|
|
11
11
|
import { laneStories } from "../lib/lanes/stories.js";
|
|
12
12
|
import { readLanesRegistryFlagFailOpen } from "../lib/lanes/registry-flag.js";
|
|
13
13
|
import { LANE_LOOP_HANDOFF_REPAIR_FLAG } from "../lib/lanes/flag-keys.js";
|
|
@@ -255,7 +255,7 @@ export async function runLaneLoopController(ctx, laneId, deps) {
|
|
|
255
255
|
signal.removeEventListener("abort", abortPhase);
|
|
256
256
|
}
|
|
257
257
|
},
|
|
258
|
-
raiseQuestion: ({ envelope, envelopeId, attempt, error, questionId }) => {
|
|
258
|
+
raiseQuestion: ({ envelope, envelopeId, attempt, error, questionId, reason }) => {
|
|
259
259
|
const current = readLane(hqRoot, laneId);
|
|
260
260
|
const data = envelope;
|
|
261
261
|
raiseLaneQuestion({
|
|
@@ -265,11 +265,11 @@ export async function runLaneLoopController(ctx, laneId, deps) {
|
|
|
265
265
|
raisedVia: "cli",
|
|
266
266
|
at: ctx.now().toISOString(),
|
|
267
267
|
body: {
|
|
268
|
-
question: `Loop phase ${String(data.phase ?? envelopeId)} stalled twice. How should it proceed?`,
|
|
269
|
-
context: `${error}\nThe phase is parked; later envelopes remain queued. Stall attempt: ${attempt}.`,
|
|
270
|
-
options: ["Retry with a longer deadline", "Reroute to another worker", "Drop this phase and block the story"],
|
|
271
|
-
recommended: "Retry with a longer deadline",
|
|
272
|
-
loop_stall: { lane_id: laneId, envelope_id: envelopeId, attempt },
|
|
268
|
+
question: reason === "handoff_needs_repair" ? `Loop phase ${String(data.phase ?? envelopeId)} has an invalid handoff. How should it proceed?` : `Loop phase ${String(data.phase ?? envelopeId)} stalled twice. How should it proceed?`,
|
|
269
|
+
context: reason === "handoff_needs_repair" ? `${error}\nThe worker file is preserved and the phase is parked; later envelopes remain queued. Repair the handoff file before accepting it. Retrying can repeat external effects.` : `${error}\nThe phase is parked; later envelopes remain queued. Stall attempt: ${attempt}.`,
|
|
270
|
+
options: reason === "handoff_needs_repair" ? [...HANDOFF_HOLD_CHOICES] : ["Retry with a longer deadline", "Reroute to another worker", "Drop this phase and block the story"],
|
|
271
|
+
recommended: reason === "handoff_needs_repair" ? HANDOFF_HOLD_CHOICES[0] : "Retry with a longer deadline",
|
|
272
|
+
loop_stall: { lane_id: laneId, envelope_id: envelopeId, attempt, ...(reason ? { reason } : {}) },
|
|
273
273
|
},
|
|
274
274
|
});
|
|
275
275
|
},
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import * as path from "node:path";
|
|
2
|
+
import { readSessionCompanyLock } from "../lib/session-company-lock.js";
|
|
2
3
|
import { isQuestionImported } from "../lib/lanes/imported-question.js";
|
|
3
4
|
import { buildOutpostQuestionAnswerCommand, buildOutpostQuestionCancelCommand, buildOutpostQuestionShowCommand, dispatchErrorMessage } from "../lib/lanes/outpost-dispatch.js";
|
|
4
5
|
import { assertNoSecretText } from "../lib/lanes/text-scan.js";
|
|
@@ -6,7 +7,7 @@ import { sessionIdFromEnv } from "../lib/mesh/live/session-identity.js";
|
|
|
6
7
|
import { appendCappedHistory, createQuestion, expandLocalLaneIdPrefix, isLanesError, listSessionLiveness, parseLivenessState, parseQuestionRecord, parseQuestionStatus, readDaemonPresenceConnected, readLane, readQuestion, wakeAnsweredLaneNow, sessionLiveness, appendDropboxMessage, answerDropboxMessage, laneAcceptsDropboxDelivery, laneIdForAnswerDelivery, writeQuestionBody } from "../lib/lanes/index.js";
|
|
7
8
|
import { resolveQuestionCompanyWithFallback } from "../lib/lanes/questions.js";
|
|
8
9
|
import { LanesError } from "../lib/lanes/errors.js";
|
|
9
|
-
import { applyLoopStallAnswer, findLoopStallResult } from "../lib/lanes/loop.js";
|
|
10
|
+
import { HANDOFF_HOLD_CHOICES, applyLoopStallAnswer, findLoopStallResult, loopHandoffHoldIdentity } from "../lib/lanes/loop.js";
|
|
10
11
|
export function resolveAnswerChoice(choice, options, freeform) {
|
|
11
12
|
const trimmed = choice.trim();
|
|
12
13
|
if (/^[0-9]+$/.test(trimmed)) {
|
|
@@ -847,11 +848,48 @@ export function createLaneQuestionsHandlers(deps) {
|
|
|
847
848
|
const retryingSameLoopAnswer = isLoopStallQuestion && question.status === "answered" && question.answer?.choice === choice &&
|
|
848
849
|
question.answer.answer_kind === incomingAnswerKind && incomingNotesMatch &&
|
|
849
850
|
deps.sameQuestionAnswer(question.answer, { ...question.answer });
|
|
850
|
-
if (question.status !== "pending" && !retryingSameLoopAnswer)
|
|
851
|
-
throwIfQuestionNotPending(question);
|
|
852
851
|
const loopStallResult = isLoopStallQuestion
|
|
853
852
|
? findLoopStallResult(path.join(hqRoot, "workspace", "lanes-runs", askingLane.lane_id), questionId)
|
|
854
853
|
: undefined;
|
|
854
|
+
if (loopStallResult?.hold_reason === "handoff_needs_repair" && !HANDOFF_HOLD_CHOICES.includes(choice)) {
|
|
855
|
+
throw new LanesError("invalid_handoff_disposition", "Choose an explicit handoff repair disposition", { question_id: questionId });
|
|
856
|
+
}
|
|
857
|
+
const handoffIdentity = askingLane.loop_mode ? loopHandoffHoldIdentity(path.join(hqRoot, "workspace", "lanes-runs", askingLane.lane_id)) : undefined;
|
|
858
|
+
const changingHeldAnswer = body.loop_stall?.reason === "handoff_needs_repair" && isLoopStallQuestion &&
|
|
859
|
+
question.status === "answered" && question.answer?.choice === HANDOFF_HOLD_CHOICES[2] &&
|
|
860
|
+
choice !== HANDOFF_HOLD_CHOICES[2] && handoffIdentity?.unresolved === true &&
|
|
861
|
+
handoffIdentity.question_id === questionId && handoffIdentity.envelope_id === body.loop_stall.envelope_id &&
|
|
862
|
+
loopStallResult?.hold_reason === "handoff_needs_repair";
|
|
863
|
+
if (question.status !== "pending" && !retryingSameLoopAnswer && !changingHeldAnswer)
|
|
864
|
+
throwIfQuestionNotPending(question);
|
|
865
|
+
const isHandoffQuestion = body.loop_stall?.reason === "handoff_needs_repair" || loopStallResult?.hold_reason === "handoff_needs_repair" || handoffIdentity !== undefined;
|
|
866
|
+
if (isHandoffQuestion) {
|
|
867
|
+
assertQuestionAnswerAuthorized(ctx, askingLane);
|
|
868
|
+
assertLaneRunsHereForOperatorMutation(ctx, askingLane, "resolve a handoff repair hold on");
|
|
869
|
+
const targetCompany = questionCompanyForLane(question, askingLane, hqRoot);
|
|
870
|
+
const lock = readSessionCompanyLock({ ...ctx.env, HQ_ROOT: hqRoot });
|
|
871
|
+
const actorLaneId = ctx.env.HQ_LANE_ID?.trim();
|
|
872
|
+
if (actorLaneId) {
|
|
873
|
+
const actorLane = readLane(hqRoot, actorLaneId);
|
|
874
|
+
const actorCompany = actorLane.company ?? actorLane.mesh_company;
|
|
875
|
+
if (!targetCompany || !actorCompany || !sameQuestionCompany(actorCompany, targetCompany))
|
|
876
|
+
throw new LanesError("handoff_answer_company_mismatch", "Caller lane does not own this handoff company", { question_id: questionId });
|
|
877
|
+
}
|
|
878
|
+
// Lane-only callers may use their owned lane identity; a present session lock
|
|
879
|
+
// always constrains that identity and may never be widened by HQ_LANE_ID.
|
|
880
|
+
if ((lock || !actorLaneId) && (!lock || !targetCompany || !lock.companies.includes(targetCompany.slug))) {
|
|
881
|
+
throw new LanesError("handoff_answer_company_mismatch", "The current session is not bound to this handoff company", { question_id: questionId });
|
|
882
|
+
}
|
|
883
|
+
if (!isLoopStallQuestion || body.loop_stall?.reason !== "handoff_needs_repair" ||
|
|
884
|
+
(handoffIdentity && (handoffIdentity.question_id !== questionId || handoffIdentity.envelope_id !== body.loop_stall.envelope_id)) ||
|
|
885
|
+
(loopStallResult && loopStallResult.envelope_id !== body.loop_stall.envelope_id)) {
|
|
886
|
+
throw new LanesError("handoff_question_identity_mismatch", "The persisted handoff question and hold identities do not match", { question_id: questionId });
|
|
887
|
+
}
|
|
888
|
+
if (!HANDOFF_HOLD_CHOICES.includes(choice))
|
|
889
|
+
throw new LanesError("invalid_handoff_disposition", "Choose an explicit handoff repair disposition", { question_id: questionId });
|
|
890
|
+
if (!loopStallResult && question.status === "pending")
|
|
891
|
+
throw new LanesError("handoff_result_missing", "The handoff hold result is missing; no disposition was applied", { question_id: questionId });
|
|
892
|
+
}
|
|
855
893
|
const at = ctx.now().toISOString();
|
|
856
894
|
const answeredBy = sessionIdFromEnv(ctx.env)
|
|
857
895
|
? `session:${sessionIdFromEnv(ctx.env)}`
|
|
@@ -860,7 +898,14 @@ export function createLaneQuestionsHandlers(deps) {
|
|
|
860
898
|
if (retryingSameLoopAnswer && current.status === "answered" && current.answer?.choice === choice &&
|
|
861
899
|
deps.sameQuestionAnswer(current.answer, question.answer))
|
|
862
900
|
return current;
|
|
863
|
-
|
|
901
|
+
const currentHold = changingHeldAnswer ? loopHandoffHoldIdentity(path.join(hqRoot, "workspace", "lanes-runs", askingLane.lane_id)) : undefined;
|
|
902
|
+
const stillHeld = changingHeldAnswer && current.status === "answered" &&
|
|
903
|
+
current.answer?.choice === HANDOFF_HOLD_CHOICES[2] &&
|
|
904
|
+
deps.sameQuestionAnswer(current.answer, question.answer) &&
|
|
905
|
+
currentHold?.unresolved === true && currentHold.question_id === questionId &&
|
|
906
|
+
currentHold.envelope_id === body.loop_stall?.envelope_id;
|
|
907
|
+
if (!stillHeld)
|
|
908
|
+
throwIfQuestionNotPending(current);
|
|
864
909
|
assertQuestionCompanyAccess(current, askingLane, requestedCompany, hqRoot);
|
|
865
910
|
const recordedCompany = current.company ?? questionCompany;
|
|
866
911
|
const currentForBody = current.company === null
|
|
@@ -882,6 +927,7 @@ export function createLaneQuestionsHandlers(deps) {
|
|
|
882
927
|
body: nextBody,
|
|
883
928
|
status: "answered",
|
|
884
929
|
delivered_at: null,
|
|
930
|
+
...(stillHeld && current.answer ? { answer_history: [...(current.answer_history ?? []), current.answer] } : {}),
|
|
885
931
|
answer: {
|
|
886
932
|
choice,
|
|
887
933
|
answer_kind: freeform ? "freeform" : "option",
|
|
@@ -911,6 +957,10 @@ export function createLaneQuestionsHandlers(deps) {
|
|
|
911
957
|
printJson({ ok: true, action: "questions.answer", lane_id: askingLane.lane_id, question: updated, loop_action: resolved.action });
|
|
912
958
|
return;
|
|
913
959
|
}
|
|
960
|
+
if (isHandoffQuestion && resolved.action === "parked") {
|
|
961
|
+
printJson({ ok: true, action: "questions.answer", lane_id: askingLane.lane_id, question: updated, loop_action: "parked" });
|
|
962
|
+
return;
|
|
963
|
+
}
|
|
914
964
|
const controllerPid = await deps.restartLoopController(ctx, askingLane.lane_id);
|
|
915
965
|
printJson({ ok: true, action: "questions.answer", lane_id: askingLane.lane_id, question: updated, loop_action: resolved.action, controller_pid: controllerPid });
|
|
916
966
|
return;
|
package/dist/commands/lanes.js
CHANGED
|
@@ -717,7 +717,8 @@ export function parseQuestionBody(raw) {
|
|
|
717
717
|
typeof obj.loop_stall.envelope_id !== "string" ||
|
|
718
718
|
typeof obj.loop_stall.attempt !== "number" ||
|
|
719
719
|
!Number.isSafeInteger(obj.loop_stall.attempt) ||
|
|
720
|
-
obj.loop_stall.attempt < 1
|
|
720
|
+
obj.loop_stall.attempt < 1 ||
|
|
721
|
+
(obj.loop_stall.reason !== undefined && obj.loop_stall.reason !== "handoff_needs_repair")))
|
|
721
722
|
return null;
|
|
722
723
|
const options = obj.options.filter((item) => typeof item === "string");
|
|
723
724
|
const body = {
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `hq sync held` and `hq sync release-held` (feedback_3bf773c5).
|
|
3
|
+
*
|
|
4
|
+
* After a different person signs in on the same computer, hq-cloud holds every
|
|
5
|
+
* upload and cloud delete for each sync scope (the personal vault and each
|
|
6
|
+
* company) until the signed-in person reviews what is held and releases it.
|
|
7
|
+
* These commands are the person's way to see the hold and make that decision.
|
|
8
|
+
*
|
|
9
|
+
* The hq-cloud exports (`listPushIdentityFences`, `releasePushIdentityFences`)
|
|
10
|
+
* ship after the hq-cloud version this CLI pins, so they are feature-detected
|
|
11
|
+
* on the module namespace, the same way `lib/hq-cloud-manifest.ts` does it. A
|
|
12
|
+
* static named import of a missing ESM export would stop every command that
|
|
13
|
+
* loads this file.
|
|
14
|
+
*/
|
|
15
|
+
import { Command } from "commander";
|
|
16
|
+
export interface HeldScope {
|
|
17
|
+
slug: string;
|
|
18
|
+
uid: string;
|
|
19
|
+
sinceMs: number;
|
|
20
|
+
held?: {
|
|
21
|
+
count: number;
|
|
22
|
+
samplePaths: string[];
|
|
23
|
+
at: string;
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
interface FenceExports {
|
|
27
|
+
listPushIdentityFences: (stateDir: string) => HeldScope[];
|
|
28
|
+
releasePushIdentityFences: (stateDir: string, identities: readonly string[], slugs?: readonly string[]) => string[];
|
|
29
|
+
}
|
|
30
|
+
export declare const HELD_UNAVAILABLE_MESSAGE = "this HQ build cannot list or release held uploads yet; update HQ and try again";
|
|
31
|
+
export declare function fenceExports(mod?: Record<string, unknown>): FenceExports | undefined;
|
|
32
|
+
/**
|
|
33
|
+
* One authenticated snapshot of who is signed in: the Cognito sub of the
|
|
34
|
+
* session token and, when asked for, the personal vault uid resolved with that
|
|
35
|
+
* same token. The sub never comes from the upload-author label, which needs an
|
|
36
|
+
* email; hq-cloud#1040 identifies email-less sessions by sub alone.
|
|
37
|
+
*/
|
|
38
|
+
export interface HeldIdentity {
|
|
39
|
+
sub: string;
|
|
40
|
+
personalUid?: string;
|
|
41
|
+
}
|
|
42
|
+
export interface SyncHeldDeps {
|
|
43
|
+
exports: FenceExports | undefined;
|
|
44
|
+
stateDir: string;
|
|
45
|
+
/**
|
|
46
|
+
* Resolve the signed-in identity from one token. `interactive` allows a
|
|
47
|
+
* sign-in or refresh; the result is taken after it, never mixed with an
|
|
48
|
+
* identity read before it.
|
|
49
|
+
*/
|
|
50
|
+
identity: (options: {
|
|
51
|
+
needPersonal: boolean;
|
|
52
|
+
interactive: boolean;
|
|
53
|
+
}) => Promise<HeldIdentity>;
|
|
54
|
+
confirm: (question: string) => Promise<boolean>;
|
|
55
|
+
log: (line: string) => void;
|
|
56
|
+
}
|
|
57
|
+
export declare function scopeLabel(slug: string): string;
|
|
58
|
+
export declare function runSyncHeld(deps: SyncHeldDeps, json?: boolean): Promise<number>;
|
|
59
|
+
export interface ReleaseHeldOptions {
|
|
60
|
+
company?: string;
|
|
61
|
+
personal?: boolean;
|
|
62
|
+
yes?: boolean;
|
|
63
|
+
json?: boolean;
|
|
64
|
+
}
|
|
65
|
+
export declare function runReleaseHeld(deps: SyncHeldDeps, options: ReleaseHeldOptions): Promise<number>;
|
|
66
|
+
/**
|
|
67
|
+
* The signed-in identity from the session token `ensureCognitoToken` selects
|
|
68
|
+
* (person or machine, the same choice every vault call makes). The personal
|
|
69
|
+
* vault uid is resolved with that same token: an agent principal uses its own
|
|
70
|
+
* entity uid, as the sync runner does; a person uses the canonical person
|
|
71
|
+
* entity.
|
|
72
|
+
*/
|
|
73
|
+
export declare function resolveHeldIdentity(options: {
|
|
74
|
+
needPersonal: boolean;
|
|
75
|
+
interactive: boolean;
|
|
76
|
+
}, io: {
|
|
77
|
+
ensureToken: (interactive: boolean) => Promise<string>;
|
|
78
|
+
listPersons: (token: string) => Promise<Array<{
|
|
79
|
+
uid: string;
|
|
80
|
+
type: string;
|
|
81
|
+
createdAt: string;
|
|
82
|
+
}>>;
|
|
83
|
+
}): Promise<HeldIdentity>;
|
|
84
|
+
export declare function createDefaultSyncHeldDeps(): SyncHeldDeps;
|
|
85
|
+
export declare function registerSyncHeldCommands(syncCmd: Command): void;
|
|
86
|
+
export {};
|
|
87
|
+
//# sourceMappingURL=sync-held.d.ts.map
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `hq sync held` and `hq sync release-held` (feedback_3bf773c5).
|
|
3
|
+
*
|
|
4
|
+
* After a different person signs in on the same computer, hq-cloud holds every
|
|
5
|
+
* upload and cloud delete for each sync scope (the personal vault and each
|
|
6
|
+
* company) until the signed-in person reviews what is held and releases it.
|
|
7
|
+
* These commands are the person's way to see the hold and make that decision.
|
|
8
|
+
*
|
|
9
|
+
* The hq-cloud exports (`listPushIdentityFences`, `releasePushIdentityFences`)
|
|
10
|
+
* ship after the hq-cloud version this CLI pins, so they are feature-detected
|
|
11
|
+
* on the module namespace, the same way `lib/hq-cloud-manifest.ts` does it. A
|
|
12
|
+
* static named import of a missing ESM export would stop every command that
|
|
13
|
+
* loads this file.
|
|
14
|
+
*/
|
|
15
|
+
import * as hqCloud from "@indigoai-us/hq-cloud";
|
|
16
|
+
import chalk from "chalk";
|
|
17
|
+
import * as readline from "node:readline/promises";
|
|
18
|
+
import { VaultClient, PERSONAL_VAULT_JOURNAL_SLUG, getStateDir } from "@indigoai-us/hq-cloud";
|
|
19
|
+
import { buildVaultConfig, cognitoSubjectFromToken, ensureCognitoToken } from "../utils/cognito-session.js";
|
|
20
|
+
import { decodeJwtPayload } from "../utils/person-token-cache.js";
|
|
21
|
+
import { resolveCanonicalPersonUid } from "./cloud.js";
|
|
22
|
+
export const HELD_UNAVAILABLE_MESSAGE = "this HQ build cannot list or release held uploads yet; update HQ and try again";
|
|
23
|
+
export function fenceExports(mod = hqCloud) {
|
|
24
|
+
const list = mod.listPushIdentityFences;
|
|
25
|
+
const release = mod.releasePushIdentityFences;
|
|
26
|
+
if (typeof list !== "function" || typeof release !== "function")
|
|
27
|
+
return undefined;
|
|
28
|
+
return {
|
|
29
|
+
listPushIdentityFences: list,
|
|
30
|
+
releasePushIdentityFences: release,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
export function scopeLabel(slug) {
|
|
34
|
+
return slug === PERSONAL_VAULT_JOURNAL_SLUG || slug === "personal" ? "personal vault" : `company ${slug}`;
|
|
35
|
+
}
|
|
36
|
+
function describe(scope) {
|
|
37
|
+
const since = new Date(scope.sinceMs).toISOString();
|
|
38
|
+
const lines = [` ${scopeLabel(scope.slug)}: held since ${since}`];
|
|
39
|
+
if (scope.held) {
|
|
40
|
+
lines.push(` ${scope.held.count} file${scope.held.count === 1 ? "" : "s"} waiting to upload (last checked ${scope.held.at})`);
|
|
41
|
+
for (const sample of scope.held.samplePaths.slice(0, 20))
|
|
42
|
+
lines.push(` ${sample}`);
|
|
43
|
+
if (scope.held.count > Math.min(20, scope.held.samplePaths.length))
|
|
44
|
+
lines.push(" ...");
|
|
45
|
+
}
|
|
46
|
+
return lines;
|
|
47
|
+
}
|
|
48
|
+
export async function runSyncHeld(deps, json = false) {
|
|
49
|
+
if (!deps.exports) {
|
|
50
|
+
deps.log(`${chalk.red("✗")} ${HELD_UNAVAILABLE_MESSAGE}`);
|
|
51
|
+
return 1;
|
|
52
|
+
}
|
|
53
|
+
const scopes = deps.exports.listPushIdentityFences(deps.stateDir);
|
|
54
|
+
if (json) {
|
|
55
|
+
deps.log(JSON.stringify({ scopes }));
|
|
56
|
+
return 0;
|
|
57
|
+
}
|
|
58
|
+
if (scopes.length === 0) {
|
|
59
|
+
deps.log("No uploads are held on this computer.");
|
|
60
|
+
return 0;
|
|
61
|
+
}
|
|
62
|
+
deps.log("Someone else was signed in on this computer, so these uploads are held:");
|
|
63
|
+
for (const scope of scopes)
|
|
64
|
+
for (const line of describe(scope))
|
|
65
|
+
deps.log(line);
|
|
66
|
+
deps.log("");
|
|
67
|
+
deps.log("Nothing has been uploaded or deleted in the cloud. If these files are yours, run `hq sync release-held`.");
|
|
68
|
+
return 0;
|
|
69
|
+
}
|
|
70
|
+
export async function runReleaseHeld(deps, options) {
|
|
71
|
+
if (!deps.exports) {
|
|
72
|
+
deps.log(`${chalk.red("✗")} ${HELD_UNAVAILABLE_MESSAGE}`);
|
|
73
|
+
return 1;
|
|
74
|
+
}
|
|
75
|
+
let scopes = deps.exports.listPushIdentityFences(deps.stateDir);
|
|
76
|
+
if (options.personal)
|
|
77
|
+
scopes = scopes.filter((scope) => scope.slug === PERSONAL_VAULT_JOURNAL_SLUG || scope.slug === "personal");
|
|
78
|
+
if (options.company)
|
|
79
|
+
scopes = scopes.filter((scope) => scope.slug === options.company);
|
|
80
|
+
if (scopes.length === 0) {
|
|
81
|
+
deps.log(options.json ? JSON.stringify({ released: [] }) : "No held uploads match.");
|
|
82
|
+
return 0;
|
|
83
|
+
}
|
|
84
|
+
const needPersonal = scopes.some((scope) => scope.slug === PERSONAL_VAULT_JOURNAL_SLUG || scope.slug === "personal");
|
|
85
|
+
const snapshot = await deps.identity({ needPersonal, interactive: true });
|
|
86
|
+
const identities = [snapshot.sub, ...(snapshot.personalUid ? [snapshot.personalUid] : [])];
|
|
87
|
+
const mine = scopes.filter((scope) => identities.includes(scope.uid));
|
|
88
|
+
if (mine.length === 0) {
|
|
89
|
+
deps.log(options.json
|
|
90
|
+
? JSON.stringify({ released: [], reason: "not-yours" })
|
|
91
|
+
: "These held uploads belong to a different signed-in person. Sign in as that person to release them.");
|
|
92
|
+
return 1;
|
|
93
|
+
}
|
|
94
|
+
if (!options.json) {
|
|
95
|
+
deps.log("Releasing these uploads sends the files below to the cloud as yours:");
|
|
96
|
+
for (const scope of mine)
|
|
97
|
+
for (const line of describe(scope))
|
|
98
|
+
deps.log(line);
|
|
99
|
+
}
|
|
100
|
+
if (!options.yes) {
|
|
101
|
+
const ok = await deps.confirm("Are these files yours to upload? [y/N] ");
|
|
102
|
+
if (!ok) {
|
|
103
|
+
deps.log(options.json ? JSON.stringify({ released: [] }) : "Nothing released.");
|
|
104
|
+
return 1;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
// Re-read the signed-in identity right before releasing, without a sign-in.
|
|
108
|
+
// If the account changed since the snapshot (including during the prompt),
|
|
109
|
+
// release nothing: the scopes were judged against the earlier account.
|
|
110
|
+
const current = await deps.identity({ needPersonal, interactive: false });
|
|
111
|
+
if (current.sub !== snapshot.sub || current.personalUid !== snapshot.personalUid) {
|
|
112
|
+
deps.log(options.json
|
|
113
|
+
? JSON.stringify({ released: [], reason: "identity-changed" })
|
|
114
|
+
: "The signed-in account changed while releasing. Nothing was released; run the command again.");
|
|
115
|
+
return 1;
|
|
116
|
+
}
|
|
117
|
+
const released = deps.exports.releasePushIdentityFences(deps.stateDir, identities, mine.map((scope) => scope.slug));
|
|
118
|
+
deps.log(options.json
|
|
119
|
+
? JSON.stringify({ released })
|
|
120
|
+
: `Released ${released.map(scopeLabel).join(", ")}. The next sync uploads these files.`);
|
|
121
|
+
return 0;
|
|
122
|
+
}
|
|
123
|
+
async function confirmOnTty(question) {
|
|
124
|
+
if (process.stdin.isTTY !== true || process.stdout.isTTY !== true)
|
|
125
|
+
return false;
|
|
126
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
127
|
+
try {
|
|
128
|
+
const answer = await rl.question(question);
|
|
129
|
+
return /^y(es)?$/i.test(answer.trim());
|
|
130
|
+
}
|
|
131
|
+
finally {
|
|
132
|
+
rl.close();
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* The signed-in identity from the session token `ensureCognitoToken` selects
|
|
137
|
+
* (person or machine, the same choice every vault call makes). The personal
|
|
138
|
+
* vault uid is resolved with that same token: an agent principal uses its own
|
|
139
|
+
* entity uid, as the sync runner does; a person uses the canonical person
|
|
140
|
+
* entity.
|
|
141
|
+
*/
|
|
142
|
+
export async function resolveHeldIdentity(options, io) {
|
|
143
|
+
const token = await io.ensureToken(options.interactive);
|
|
144
|
+
const { sub, entityUid } = cognitoSubjectFromToken(token);
|
|
145
|
+
if (!sub)
|
|
146
|
+
throw new Error("could not read the signed-in account from the session; run `hq login` and try again");
|
|
147
|
+
if (!options.needPersonal)
|
|
148
|
+
return { sub };
|
|
149
|
+
const entityType = decodeJwtPayload(token)?.["custom:entityType"];
|
|
150
|
+
if (entityType === "agent" && entityUid)
|
|
151
|
+
return { sub, personalUid: entityUid };
|
|
152
|
+
const personalUid = await resolveCanonicalPersonUid({
|
|
153
|
+
listMyMemberships: async () => [],
|
|
154
|
+
listPersonEntities: () => io.listPersons(token),
|
|
155
|
+
getEntity: async () => null,
|
|
156
|
+
});
|
|
157
|
+
return { sub, personalUid };
|
|
158
|
+
}
|
|
159
|
+
export function createDefaultSyncHeldDeps() {
|
|
160
|
+
const tty = process.stdin.isTTY === true && process.stdout.isTTY === true;
|
|
161
|
+
return {
|
|
162
|
+
exports: fenceExports(),
|
|
163
|
+
stateDir: getStateDir(),
|
|
164
|
+
identity: (options) => resolveHeldIdentity({ needPersonal: options.needPersonal, interactive: options.interactive && tty }, {
|
|
165
|
+
ensureToken: (interactive) => ensureCognitoToken({ interactive }),
|
|
166
|
+
listPersons: async (token) => (await new VaultClient(buildVaultConfig(token)).entity.listByType("person")),
|
|
167
|
+
}),
|
|
168
|
+
confirm: confirmOnTty,
|
|
169
|
+
log: (line) => console.log(line),
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
export function registerSyncHeldCommands(syncCmd) {
|
|
173
|
+
syncCmd
|
|
174
|
+
.command("held")
|
|
175
|
+
.description("Show uploads held because someone else was signed in on this computer")
|
|
176
|
+
.option("--json", "Print the held scopes as JSON")
|
|
177
|
+
.action(async (options) => {
|
|
178
|
+
process.exitCode = await runSyncHeld(createDefaultSyncHeldDeps(), options.json === true);
|
|
179
|
+
});
|
|
180
|
+
syncCmd
|
|
181
|
+
.command("release-held")
|
|
182
|
+
.description("Confirm held files are yours and let sync upload them")
|
|
183
|
+
.option("--company <slug>", "Release only this company")
|
|
184
|
+
.option("--personal", "Release only the personal vault")
|
|
185
|
+
.option("--yes", "Do not ask for confirmation")
|
|
186
|
+
.option("--json", "Print the result as JSON")
|
|
187
|
+
.action(async (options) => {
|
|
188
|
+
process.exitCode = await runReleaseHeld(createDefaultSyncHeldDeps(), options);
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
//# sourceMappingURL=sync-held.js.map
|