@indigoai-us/hq-cli 5.345.152 → 5.345.154
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 +96 -0
- package/dist/bin/hq-auth-refresh.d.ts +4 -0
- package/dist/bin/hq-auth-refresh.js +9 -1
- package/dist/command-catalog.generated.d.ts +185 -0
- package/dist/command-catalog.generated.js +227 -0
- package/dist/command-registration-plan.d.ts +6 -0
- package/dist/command-registration-plan.js +1 -0
- package/dist/commands/agent-enroll.d.ts +12 -0
- package/dist/commands/agent-enroll.js +37 -4
- package/dist/commands/cloud.d.ts +17 -0
- package/dist/commands/cloud.js +48 -1
- package/dist/commands/core-policy.js +4 -4
- package/dist/commands/core-session-project-native.js +19 -4
- package/dist/commands/decide.d.ts +13 -0
- package/dist/commands/decide.js +74 -0
- package/dist/commands/integrations-connect.js +24 -1
- package/dist/commands/integrations-core.d.ts +14 -0
- package/dist/commands/integrations-core.js +19 -1
- package/dist/commands/integrations-import.js +22 -1
- package/dist/commands/integrations.js +43 -2
- package/dist/commands/lanes-message.js +4 -11
- package/dist/commands/lanes-questions.js +3 -0
- package/dist/commands/lanes-resume.js +4 -11
- package/dist/commands/lanes-stories.js +3 -0
- package/dist/commands/lanes.js +7 -1
- package/dist/commands/run.js +14 -5
- package/dist/lib/agent-kit/adopt-identity.d.ts +7 -1
- package/dist/lib/agent-kit/adopt-identity.js +18 -3
- package/dist/lib/agent-kit/services.d.ts +3 -1
- package/dist/lib/agent-kit/services.js +5 -1
- package/dist/lib/daemon/ops/policy.d.ts +9 -1
- package/dist/lib/daemon/ops/policy.js +37 -2
- package/dist/lib/decisions/client.d.ts +67 -0
- package/dist/lib/decisions/client.js +120 -0
- package/dist/lib/decisions/expiry.d.ts +2 -0
- package/dist/lib/decisions/expiry.js +12 -0
- package/dist/lib/decisions/fixtures/us001.d.ts +137 -0
- package/dist/lib/decisions/fixtures/us001.js +21 -0
- package/dist/lib/decisions/import-lanes.d.ts +16 -0
- package/dist/lib/decisions/import-lanes.js +152 -0
- package/dist/lib/decisions/principal.d.ts +11 -0
- package/dist/lib/decisions/principal.js +52 -0
- package/dist/lib/doctor/checks/claude-wiring.d.ts +2 -2
- package/dist/lib/doctor/checks/claude-wiring.js +21 -10
- package/dist/lib/doctor/checks/work-context.js +7 -2
- package/dist/lib/doctor/fix/apply.d.ts +3 -2
- package/dist/lib/doctor/fix/apply.js +19 -15
- package/dist/lib/doctor/fix/refresh-sync-progress.d.ts +14 -0
- package/dist/lib/doctor/fix/refresh-sync-progress.js +30 -2
- package/dist/lib/doctor/fix/remediation.js +4 -2
- package/dist/lib/doctor/hook-gate-profiles.d.ts +19 -0
- package/dist/lib/doctor/hook-gate-profiles.js +34 -0
- package/dist/lib/doctor/types.d.ts +5 -0
- package/dist/lib/install/provider-wrappers.js +5 -1
- package/dist/lib/lanes/autopilot.js +4 -1
- package/dist/lib/lanes/imported-question.d.ts +5 -0
- package/dist/lib/lanes/imported-question.js +17 -0
- package/dist/lib/lanes/questions.d.ts +0 -5
- package/dist/lib/lanes/questions.js +2 -0
- package/dist/lib/lanes/reconcile.js +21 -10
- package/dist/lib/lanes/store.d.ts +0 -8
- package/dist/lib/lanes/store.js +2 -1
- package/dist/lib/lanes/stories.d.ts +15 -0
- package/dist/lib/lanes/stories.js +25 -0
- package/dist/lib/mesh/client.d.ts +8 -0
- package/dist/lib/mesh/client.js +44 -3
- package/dist/lib/mesh/denial-codes.js +1 -1
- package/dist/lib/mesh/live/daemon/run.d.ts +9 -1
- package/dist/lib/mesh/live/daemon/run.js +30 -1
- package/dist/lib/mesh/live/flush.d.ts +7 -0
- package/dist/lib/mesh/live/flush.js +53 -10
- package/dist/lib/registry/registry.js +6 -0
- package/dist/lib/scan-packages/index.js +7 -3
- package/dist/lib/sync/read-only-local-only.d.ts +45 -0
- package/dist/lib/sync/read-only-local-only.js +122 -0
- package/dist/lib/work-context/organize.d.ts +2 -0
- package/dist/lib/work-context/organize.js +22 -4
- package/dist/lib/workers-registry/index.d.ts +12 -1
- package/dist/lib/workers-registry/index.js +39 -1
- package/dist/utils/cognito-session.d.ts +4 -1
- package/dist/utils/cognito-session.js +11 -1
- package/package.json +7 -7
|
@@ -49,13 +49,41 @@ export function readRefreshSyncProgress(hqRoot) {
|
|
|
49
49
|
}
|
|
50
50
|
return record;
|
|
51
51
|
}
|
|
52
|
-
function writeProgressFile(hqRoot, progress) {
|
|
53
|
-
const target =
|
|
52
|
+
function writeProgressFile(hqRoot, progress, relativePath = REFRESH_SYNC_PROGRESS_RELATIVE_PATH) {
|
|
53
|
+
const target = path.join(hqRoot, relativePath);
|
|
54
54
|
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
55
55
|
const temporaryPath = `${target}.${process.pid}.tmp`;
|
|
56
56
|
fs.writeFileSync(temporaryPath, `${JSON.stringify(progress)}\n`, { mode: 0o600 });
|
|
57
57
|
fs.renameSync(temporaryPath, target);
|
|
58
58
|
}
|
|
59
|
+
/**
|
|
60
|
+
* `--fix` manifest-upload cursor. Kept in its own file so the refresh-sync
|
|
61
|
+
* progress format, which doctor JSON reads, does not change. Written before
|
|
62
|
+
* each upload, like the pull cursor, so the next run starts after the last
|
|
63
|
+
* company attempted and one failing company cannot hold the front of the line.
|
|
64
|
+
*/
|
|
65
|
+
export const REFRESH_MANIFEST_PROGRESS_RELATIVE_PATH = path.join("workspace", "doctor", "refresh-manifest-progress.json");
|
|
66
|
+
/** The last manifest scope `--fix` attempted, or null when absent or unreadable. */
|
|
67
|
+
export function readRefreshManifestCursor(hqRoot) {
|
|
68
|
+
let parsed;
|
|
69
|
+
try {
|
|
70
|
+
parsed = JSON.parse(fs.readFileSync(path.join(hqRoot, REFRESH_MANIFEST_PROGRESS_RELATIVE_PATH), "utf8"));
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
// The cursor only orders retries. A missing or damaged file restarts the
|
|
74
|
+
// rotation from the first scope instead of failing the repair run.
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
if (typeof parsed !== "object" || parsed === null)
|
|
78
|
+
return null;
|
|
79
|
+
const record = parsed;
|
|
80
|
+
if (record.version !== 1 || typeof record.lastAttemptedScope !== "string")
|
|
81
|
+
return null;
|
|
82
|
+
return record.lastAttemptedScope;
|
|
83
|
+
}
|
|
84
|
+
export function writeRefreshManifestCursor(hqRoot, scope) {
|
|
85
|
+
writeProgressFile(hqRoot, { version: 1, lastAttemptedScope: scope }, REFRESH_MANIFEST_PROGRESS_RELATIVE_PATH);
|
|
86
|
+
}
|
|
59
87
|
/** Persist the resume cursor without claiming this run finished. */
|
|
60
88
|
export function writeRefreshSyncCursor(hqRoot, slug) {
|
|
61
89
|
const existing = readRefreshSyncProgress(hqRoot);
|
|
@@ -87,8 +87,10 @@ export function deriveRemediation(result) {
|
|
|
87
87
|
command: `chmod +x "${chmodPath}"`,
|
|
88
88
|
};
|
|
89
89
|
}
|
|
90
|
-
// gate-profile — a gated hook id
|
|
91
|
-
if (checkId.endsWith(".gate-profiles") &&
|
|
90
|
+
// gate-profile — a gated hook id missing from a profile its tier requires.
|
|
91
|
+
if (checkId.endsWith(".gate-profiles") &&
|
|
92
|
+
(result.status === "FAIL" || result.status === "WARN") &&
|
|
93
|
+
target) {
|
|
92
94
|
return {
|
|
93
95
|
autoFixable: true,
|
|
94
96
|
fixClass: "gate-profile",
|
|
@@ -52,4 +52,23 @@ export declare function parseHookGateProfiles(source: string): HookGateProfiles;
|
|
|
52
52
|
* profile allowlists key on the hook id, not the script filename.
|
|
53
53
|
*/
|
|
54
54
|
export declare function gateMembership(hookId: string, profiles: HookGateProfiles): GateMembership;
|
|
55
|
+
/**
|
|
56
|
+
* The profiles `hookId` is REQUIRED in but missing from.
|
|
57
|
+
*
|
|
58
|
+
* The shipped gate declares the profiles as nested tiers: minimal is
|
|
59
|
+
* "critical safety hooks only", standard is "all minimal + ...", strict is
|
|
60
|
+
* "all standard + ...". A hook's lowest listed profile is its declared tier,
|
|
61
|
+
* and it must be listed in every profile from that tier up. Absence from a
|
|
62
|
+
* LOWER profile is an intentional exclusion, not a defect (feedback 299: a
|
|
63
|
+
* standard hook absent from minimal is by design). The tier is capped at
|
|
64
|
+
* standard, so an id listed only in strict is still required in standard. An
|
|
65
|
+
* id listed in no profile is required everywhere.
|
|
66
|
+
*/
|
|
67
|
+
export declare function requiredMissingProfiles(hookId: string, profiles: HookGateProfiles): GateProfile[];
|
|
68
|
+
/**
|
|
69
|
+
* Resolve the runtime gate profile the way hook-gate.sh does:
|
|
70
|
+
* `HQ_HOOK_PROFILE`, default `standard`. An unrecognised value falls back to
|
|
71
|
+
* `standard` here (the gate itself errors on it).
|
|
72
|
+
*/
|
|
73
|
+
export declare function activeGateProfile(value?: string | null): GateProfile;
|
|
55
74
|
//# sourceMappingURL=hook-gate-profiles.d.ts.map
|
|
@@ -104,4 +104,38 @@ function profileFunctionBody(source, profile) {
|
|
|
104
104
|
const close = rest.search(/\n\s*\}/);
|
|
105
105
|
return close >= 0 ? rest.slice(0, close) : rest;
|
|
106
106
|
}
|
|
107
|
+
/**
|
|
108
|
+
* The profiles `hookId` is REQUIRED in but missing from.
|
|
109
|
+
*
|
|
110
|
+
* The shipped gate declares the profiles as nested tiers: minimal is
|
|
111
|
+
* "critical safety hooks only", standard is "all minimal + ...", strict is
|
|
112
|
+
* "all standard + ...". A hook's lowest listed profile is its declared tier,
|
|
113
|
+
* and it must be listed in every profile from that tier up. Absence from a
|
|
114
|
+
* LOWER profile is an intentional exclusion, not a defect (feedback 299: a
|
|
115
|
+
* standard hook absent from minimal is by design). The tier is capped at
|
|
116
|
+
* standard, so an id listed only in strict is still required in standard. An
|
|
117
|
+
* id listed in no profile is required everywhere.
|
|
118
|
+
*/
|
|
119
|
+
export function requiredMissingProfiles(hookId, profiles) {
|
|
120
|
+
const { present, missing } = gateMembership(hookId, profiles);
|
|
121
|
+
if (present.length === 0)
|
|
122
|
+
return missing;
|
|
123
|
+
// The inferred tier is capped at standard, the default runtime profile. The
|
|
124
|
+
// strict list is reserved for future hooks and currently equals standard, so
|
|
125
|
+
// a strict-only id is a missed allowlist edit, not a strict tier: it never
|
|
126
|
+
// runs under the default profile and must still be required in standard.
|
|
127
|
+
const lowest = Math.min(GATE_PROFILES.indexOf(present[0]), GATE_PROFILES.indexOf("standard"));
|
|
128
|
+
return missing.filter((p) => GATE_PROFILES.indexOf(p) >= lowest);
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Resolve the runtime gate profile the way hook-gate.sh does:
|
|
132
|
+
* `HQ_HOOK_PROFILE`, default `standard`. An unrecognised value falls back to
|
|
133
|
+
* `standard` here (the gate itself errors on it).
|
|
134
|
+
*/
|
|
135
|
+
export function activeGateProfile(value) {
|
|
136
|
+
const v = (value ?? "").trim();
|
|
137
|
+
return GATE_PROFILES.includes(v)
|
|
138
|
+
? v
|
|
139
|
+
: "standard";
|
|
140
|
+
}
|
|
107
141
|
//# sourceMappingURL=hook-gate-profiles.js.map
|
|
@@ -79,6 +79,11 @@ export interface CheckContext {
|
|
|
79
79
|
company?: string;
|
|
80
80
|
/** Internal test seam; production `hq doctor` always enables integrations. */
|
|
81
81
|
integrations?: boolean;
|
|
82
|
+
/**
|
|
83
|
+
* The runtime hook-gate profile (minimal|standard|strict) to judge gate
|
|
84
|
+
* membership against. Absent reads `HQ_HOOK_PROFILE`, default `standard`.
|
|
85
|
+
*/
|
|
86
|
+
hookProfile?: string;
|
|
82
87
|
}
|
|
83
88
|
/**
|
|
84
89
|
* A check family: an id, a human title, and an async `run` returning per-item
|
|
@@ -94,7 +94,11 @@ function wrapperSource(provider) {
|
|
|
94
94
|
'log_file="$log_dir/provider-auth.log"',
|
|
95
95
|
'tmp=$(mktemp -d "${TMPDIR:-/tmp}/hq-provider.XXXXXX") || exit 1',
|
|
96
96
|
'trap \'rm -rf "$tmp"\' EXIT HUP INT TERM',
|
|
97
|
-
|
|
97
|
+
// macOS ships no timeout(1), so the perl alarm fallback runs there. When the
|
|
98
|
+
// alarm fires the shell itself reports "Alarm clock: 14 perl ..." on its own
|
|
99
|
+
// stderr (the command's 2>&1 does not cover it); the brace group's 2>/dev/null
|
|
100
|
+
// keeps that report off the user's terminal. The exit status (142) is unchanged.
|
|
101
|
+
`if command -v timeout >/dev/null 2>&1; then timeout 5 "$real" ${statusArgs} >"$tmp/status" 2>&1; auth_status=$?; elif command -v perl >/dev/null 2>&1; then { perl -e 'alarm 5; exec @ARGV' "$real" ${statusArgs} >"$tmp/status" 2>&1; } 2>/dev/null; auth_status=$?; else auth_status=124; fi`,
|
|
98
102
|
'case "$auth_status" in 124|142) : >"$tmp/status-timeout" ;; esac',
|
|
99
103
|
'if [ "$auth_status" -ne 0 ]; then cat "$tmp/status" >>"$log_file" 2>/dev/null || :; fi',
|
|
100
104
|
`if [ ! -e "$tmp/status-timeout" ] && grep -Eiq '${authFailure}' "$tmp/status"; then`,
|
|
@@ -1265,7 +1265,10 @@ export async function runLeadAutopilotPass(input) {
|
|
|
1265
1265
|
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
1266
1266
|
try {
|
|
1267
1267
|
lockFd = fs.openSync(lockPath, "wx", 0o600);
|
|
1268
|
-
|
|
1268
|
+
// The created-at stamp is compared with Date.now() by reclaimStaleLock
|
|
1269
|
+
// (the only liveness check where /proc is absent, e.g. macOS), so it must
|
|
1270
|
+
// be wall-clock time, not the caller's injected logical `now`.
|
|
1271
|
+
fs.writeFileSync(lockFd, `${process.pid}\n${new Date().toISOString()}\n${processStartIdentity(process.pid) ?? ""}\n`, "utf8");
|
|
1269
1272
|
break;
|
|
1270
1273
|
}
|
|
1271
1274
|
catch (error) {
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export declare function importedQuestionMarker(root: string, id: string): string;
|
|
2
|
+
/** A pending transfer is also read-only: its remote POST may already have succeeded. */
|
|
3
|
+
export declare function assertQuestionNotImported(root: string, id: string): void;
|
|
4
|
+
export declare function isQuestionImported(root: string, id: string): boolean;
|
|
5
|
+
//# sourceMappingURL=imported-question.d.ts.map
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
4
|
+
import { LanesError } from "./errors.js";
|
|
5
|
+
export function importedQuestionMarker(root, id) {
|
|
6
|
+
return path.join(root, "workspace", "decisions", "legacy-read-only", `${createHash("sha256").update(id).digest("hex")}.json`);
|
|
7
|
+
}
|
|
8
|
+
/** A pending transfer is also read-only: its remote POST may already have succeeded. */
|
|
9
|
+
export function assertQuestionNotImported(root, id) {
|
|
10
|
+
if (isQuestionImported(root, id)) {
|
|
11
|
+
throw new LanesError("question_imported_read_only", "This question was transferred to hq decide and is read-only in hq lanes. Use hq decide list to find and answer the decision.", { question_id: id });
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
export function isQuestionImported(root, id) {
|
|
15
|
+
return fs.existsSync(importedQuestionMarker(root, id));
|
|
16
|
+
}
|
|
17
|
+
//# sourceMappingURL=imported-question.js.map
|
|
@@ -1,8 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Raise a lane question (spec §6.1). One function for the CLI `hq lanes ask`
|
|
3
|
-
* path and for envelope `decision: "ask"` ingestion. Two writers here would
|
|
4
|
-
* drift on hop, origin, raised_via, body store, and routing_history.
|
|
5
|
-
*/
|
|
6
1
|
import { type LaneEnvelope, type LaneRecord, type LaneCompanyRef, type QuestionBodyStore, type QuestionKind, type QuestionPriority, type QuestionRecord, type RaisedVia } from "./types.js";
|
|
7
2
|
export interface QuestionBody {
|
|
8
3
|
question: string;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { assertQuestionNotImported } from "./imported-question.js";
|
|
1
2
|
/**
|
|
2
3
|
* Raise a lane question (spec §6.1). One function for the CLI `hq lanes ask`
|
|
3
4
|
* path and for envelope `decision: "ask"` ingestion. Two writers here would
|
|
@@ -143,6 +144,7 @@ export function questionBodyAbsPath(hqRoot, question) {
|
|
|
143
144
|
return path.join(hqRoot, "personal", "lanes", "questions", `${question.question_id}.json`);
|
|
144
145
|
}
|
|
145
146
|
export function writeQuestionBody(hqRoot, question, body) {
|
|
147
|
+
assertQuestionNotImported(hqRoot, question.question_id);
|
|
146
148
|
assertQuestionBodySecretSafe(body);
|
|
147
149
|
const store = parseQuestionBodyStore(question.body.store);
|
|
148
150
|
const filePath = questionBodyAbsPath(hqRoot, question);
|
|
@@ -17,6 +17,7 @@ import { loadLanesConfig, providerQuotaDefaultHoldMs } from "./config.js";
|
|
|
17
17
|
import { MAX_LANE_DEPTH } from "./constants.js";
|
|
18
18
|
import { laneDepthCap } from "./depth-cap.js";
|
|
19
19
|
import { AccountResolutionError, LanesError, isLaneVersionConflict } from "./errors.js";
|
|
20
|
+
import { isQuestionImported } from "./imported-question.js";
|
|
20
21
|
import { loadWorkerProfile } from "./profile.js";
|
|
21
22
|
import { terminalChildResumeHireGrantError, withHireGrantAdmission, } from "./grants.js";
|
|
22
23
|
import { classifySessionLiveness, defaultLivenessDeps, loadLivenessIndex, readDaemonPresenceConnected, unindexedClaudeSessionLiveness, } from "./liveness.js";
|
|
@@ -650,16 +651,26 @@ async function closeInterruptedDoneStoryWorkers(hqRoot, lanes, questions, summar
|
|
|
650
651
|
for (const question of questions) {
|
|
651
652
|
if (question.origin.id !== lane.lane_id || question.status !== "pending")
|
|
652
653
|
continue;
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
654
|
+
if (isQuestionImported(hqRoot, question.question_id))
|
|
655
|
+
continue;
|
|
656
|
+
try {
|
|
657
|
+
casQuestionIf(hqRoot, question.question_id, (current) => current.status === "pending", (current) => ({
|
|
658
|
+
...current,
|
|
659
|
+
status: "cancelled",
|
|
660
|
+
routing_history: appendCappedHistory(current.routing_history, {
|
|
661
|
+
hop: current.hop,
|
|
662
|
+
audience: { kind: "user" },
|
|
663
|
+
at,
|
|
664
|
+
reason: "cancelled",
|
|
665
|
+
}),
|
|
666
|
+
}), false);
|
|
667
|
+
}
|
|
668
|
+
catch (error) {
|
|
669
|
+
// Import may acquire the record lock after our initial check. The
|
|
670
|
+
// decision service owns that question; continue closing this lane.
|
|
671
|
+
if (!(error instanceof LanesError) || error.error !== "question_imported_read_only")
|
|
672
|
+
throw error;
|
|
673
|
+
}
|
|
663
674
|
}
|
|
664
675
|
appendWatcherEvent(watcherEventsPath(hqRoot, lane.lane_id), {
|
|
665
676
|
event: "story_closed",
|
|
@@ -1,11 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* File-backed lane / question record store (spec §10, §10.1).
|
|
3
|
-
*
|
|
4
|
-
* Root: workspace/lanes/ under the HQ root. One stable path per record;
|
|
5
|
-
* `status` is the pending/answered discriminator — files are never moved
|
|
6
|
-
* between directories. Every mutation is temp-file-plus-rename and a
|
|
7
|
-
* compare-and-set on `version`. Histories are capped at 50 at the append site.
|
|
8
|
-
*/
|
|
9
1
|
import * as fs from "node:fs";
|
|
10
2
|
import { type OutpostIdentity } from "./admission-host.js";
|
|
11
3
|
import { type AttributionHistoryEntry, type LaneRecord, type LaneWriter, type QuestionRecord, type RoutingHistoryEntry, type SeniorHistoryEntry } from "./types.js";
|
package/dist/lib/lanes/store.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { assertQuestionNotImported } from "./imported-question.js";
|
|
1
2
|
/**
|
|
2
3
|
* File-backed lane / question record store (spec §10, §10.1).
|
|
3
4
|
*
|
|
@@ -2013,7 +2014,7 @@ export function createQuestion(hqRoot, init, deps = defaultStoreDeps()) {
|
|
|
2013
2014
|
return created;
|
|
2014
2015
|
}
|
|
2015
2016
|
export function updateQuestion(hqRoot, questionId, expectedVersion, updater, deps = defaultStoreDeps()) {
|
|
2016
|
-
const next = casWrite(questionRecordPath(hqRoot, questionId), expectedVersion, parseQuestionRecord, mergeQuestionRecords, updater, deps);
|
|
2017
|
+
const next = casWrite(questionRecordPath(hqRoot, questionId), expectedVersion, parseQuestionRecord, mergeQuestionRecords, (current) => { assertQuestionNotImported(hqRoot, questionId); return updater(current); }, deps);
|
|
2017
2018
|
syncPendingMarker(hqRoot, next, deps);
|
|
2018
2019
|
syncLaneQuestionReference(hqRoot, next, deps);
|
|
2019
2020
|
return next;
|
|
@@ -20,4 +20,19 @@ export declare function storyStatusForEnvelope(status: string): "in_progress" |
|
|
|
20
20
|
* and explicit story commands own those transitions.
|
|
21
21
|
*/
|
|
22
22
|
export declare function terminalEnvelopeStoryStatus(lane: LaneRecord, decision: "done" | "blocked" | "ask", envelopeStatus?: string): LaneStoryStatus | undefined;
|
|
23
|
+
/**
|
|
24
|
+
* The stories array after attributing a lane to projectId/storyId.
|
|
25
|
+
*
|
|
26
|
+
* A lane with no story (empty story_id plus no_story_reason) carries no story
|
|
27
|
+
* rows, so attributing it to another project without a story keeps the rows
|
|
28
|
+
* unchanged. This is the single write site for attribution-driven appends:
|
|
29
|
+
* it never produces a row with an empty story_id, which the lane schema
|
|
30
|
+
* rejects (and which would roll back the whole write).
|
|
31
|
+
*/
|
|
32
|
+
export declare function storiesAfterAttribution(lane: LaneRecord, projectId: string, storyId: string, at: string): LaneStory[];
|
|
33
|
+
/**
|
|
34
|
+
* The row attribution would append, or undefined when nothing is appended
|
|
35
|
+
* (the story is already carried, or there is no story id).
|
|
36
|
+
*/
|
|
37
|
+
export declare function attributedStoryRow(lane: LaneRecord, projectId: string, storyId: string, at: string): LaneStory | undefined;
|
|
23
38
|
//# sourceMappingURL=stories.d.ts.map
|
|
@@ -89,4 +89,29 @@ export function terminalEnvelopeStoryStatus(lane, decision, envelopeStatus = "in
|
|
|
89
89
|
return "review";
|
|
90
90
|
return storyStatusForEnvelope(envelopeStatus);
|
|
91
91
|
}
|
|
92
|
+
/**
|
|
93
|
+
* The stories array after attributing a lane to projectId/storyId.
|
|
94
|
+
*
|
|
95
|
+
* A lane with no story (empty story_id plus no_story_reason) carries no story
|
|
96
|
+
* rows, so attributing it to another project without a story keeps the rows
|
|
97
|
+
* unchanged. This is the single write site for attribution-driven appends:
|
|
98
|
+
* it never produces a row with an empty story_id, which the lane schema
|
|
99
|
+
* rejects (and which would roll back the whole write).
|
|
100
|
+
*/
|
|
101
|
+
export function storiesAfterAttribution(lane, projectId, storyId, at) {
|
|
102
|
+
const current = laneStories(lane);
|
|
103
|
+
const added = attributedStoryRow(lane, projectId, storyId, at);
|
|
104
|
+
return added ? [...current, added] : current;
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* The row attribution would append, or undefined when nothing is appended
|
|
108
|
+
* (the story is already carried, or there is no story id).
|
|
109
|
+
*/
|
|
110
|
+
export function attributedStoryRow(lane, projectId, storyId, at) {
|
|
111
|
+
if (!storyId.trim())
|
|
112
|
+
return undefined;
|
|
113
|
+
if (laneStory(lane, projectId, storyId))
|
|
114
|
+
return undefined;
|
|
115
|
+
return { project_id: projectId, story_id: storyId, status: "in_progress", added_at: at, updated_at: at };
|
|
116
|
+
}
|
|
92
117
|
//# sourceMappingURL=stories.js.map
|
|
@@ -54,6 +54,14 @@ export declare function createWorkSessionDeliverer(opts: {
|
|
|
54
54
|
body: unknown;
|
|
55
55
|
}>;
|
|
56
56
|
}): DeliverFn;
|
|
57
|
+
/**
|
|
58
|
+
* Outbox error code for a 401/403 register refusal. Only real auth / identity
|
|
59
|
+
* refusals are AUTH_DENIED. A company whose work registry is off
|
|
60
|
+
* (WORK_REGISTRY_DISABLED) or an unknown company (INVALID_COMPANY) is not an
|
|
61
|
+
* auth problem, so those keep their own code and doctor does not point at
|
|
62
|
+
* login (Shepherd issue 2726, feedback_6a792ab4).
|
|
63
|
+
*/
|
|
64
|
+
export declare function registerDenialOutboxCode(serverCode: string | undefined): string;
|
|
57
65
|
/**
|
|
58
66
|
* Probe whether the server advertises migration capability (US-017A).
|
|
59
67
|
* Failures return false (default-company stays locked).
|
package/dist/lib/mesh/client.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* vault HTTP + receipt-shaped responses (US-007A / US-007B).
|
|
5
5
|
*/
|
|
6
6
|
import { listActiveMembershipCompanies, meshJson, requireToken, resolveMeshCompany, } from "./api.js";
|
|
7
|
-
import { isWorkSessionDenialCode } from "./denial-codes.js";
|
|
7
|
+
import { isWorkSessionDenialCode, WORK_SESSION_DENIAL_CODES, } from "./denial-codes.js";
|
|
8
8
|
export { meshJson, requireToken, resolveMeshCompany, listActiveMembershipCompanies };
|
|
9
9
|
export async function openMeshTransport(opts) {
|
|
10
10
|
const token = await requireToken();
|
|
@@ -63,11 +63,11 @@ export function createWorkSessionDeliverer(opts) {
|
|
|
63
63
|
if (status === 401 || status === 403) {
|
|
64
64
|
const serverCode = isWorkSessionDenialCode(errorFields.code)
|
|
65
65
|
? errorFields.code
|
|
66
|
-
:
|
|
66
|
+
: denialCodeFromMessage(message);
|
|
67
67
|
return {
|
|
68
68
|
ok: false,
|
|
69
69
|
retryable: false,
|
|
70
|
-
code:
|
|
70
|
+
code: registerDenialOutboxCode(serverCode),
|
|
71
71
|
httpStatus: status,
|
|
72
72
|
...(serverCode ? { serverCode } : {}),
|
|
73
73
|
};
|
|
@@ -98,6 +98,15 @@ export function createWorkSessionDeliverer(opts) {
|
|
|
98
98
|
return { ok: false, retryable: false, code: `HTTP_${status}` };
|
|
99
99
|
}
|
|
100
100
|
}
|
|
101
|
+
const messageCode = denialCodeFromMessage(message);
|
|
102
|
+
if (messageCode && registerDenialOutboxCode(messageCode) !== "AUTH_DENIED") {
|
|
103
|
+
return {
|
|
104
|
+
ok: false,
|
|
105
|
+
retryable: false,
|
|
106
|
+
code: registerDenialOutboxCode(messageCode),
|
|
107
|
+
serverCode: messageCode,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
101
110
|
if (/unauthorized|forbidden|auth/i.test(message)) {
|
|
102
111
|
return { ok: false, retryable: false, code: "AUTH_DENIED" };
|
|
103
112
|
}
|
|
@@ -112,6 +121,27 @@ export function createWorkSessionDeliverer(opts) {
|
|
|
112
121
|
}
|
|
113
122
|
};
|
|
114
123
|
}
|
|
124
|
+
/**
|
|
125
|
+
* Outbox error code for a 401/403 register refusal. Only real auth / identity
|
|
126
|
+
* refusals are AUTH_DENIED. A company whose work registry is off
|
|
127
|
+
* (WORK_REGISTRY_DISABLED) or an unknown company (INVALID_COMPANY) is not an
|
|
128
|
+
* auth problem, so those keep their own code and doctor does not point at
|
|
129
|
+
* login (Shepherd issue 2726, feedback_6a792ab4).
|
|
130
|
+
*/
|
|
131
|
+
export function registerDenialOutboxCode(serverCode) {
|
|
132
|
+
if (serverCode === "WORK_REGISTRY_DISABLED" || serverCode === "INVALID_COMPANY") {
|
|
133
|
+
return serverCode;
|
|
134
|
+
}
|
|
135
|
+
return "AUTH_DENIED";
|
|
136
|
+
}
|
|
137
|
+
/** Find an allowlisted denial code in an error message when the error has no code field. */
|
|
138
|
+
function denialCodeFromMessage(message) {
|
|
139
|
+
for (const code of WORK_SESSION_DENIAL_CODES) {
|
|
140
|
+
if (new RegExp(`\\b${code}\\b`).test(message))
|
|
141
|
+
return code;
|
|
142
|
+
}
|
|
143
|
+
return undefined;
|
|
144
|
+
}
|
|
115
145
|
function mapRegisterResponse(status, body, operationId) {
|
|
116
146
|
if (status >= 200 && status < 300) {
|
|
117
147
|
const rec = body && typeof body === "object" ? body : {};
|
|
@@ -124,6 +154,17 @@ function mapRegisterResponse(status, body, operationId) {
|
|
|
124
154
|
if (status >= 500 || status === 429) {
|
|
125
155
|
return { ok: false, retryable: true, code: `HTTP_${status}` };
|
|
126
156
|
}
|
|
157
|
+
if (status === 401 || status === 403) {
|
|
158
|
+
const rec = body && typeof body === "object" ? body : {};
|
|
159
|
+
const serverCode = isWorkSessionDenialCode(rec.code) ? rec.code : undefined;
|
|
160
|
+
return {
|
|
161
|
+
ok: false,
|
|
162
|
+
retryable: false,
|
|
163
|
+
code: registerDenialOutboxCode(serverCode),
|
|
164
|
+
httpStatus: status,
|
|
165
|
+
...(serverCode ? { serverCode } : {}),
|
|
166
|
+
};
|
|
167
|
+
}
|
|
127
168
|
if (status >= 400 && status < 500) {
|
|
128
169
|
return { ok: false, retryable: false, code: `HTTP_${status}` };
|
|
129
170
|
}
|
|
@@ -26,7 +26,7 @@ export function isWorkSessionDenialCode(value) {
|
|
|
26
26
|
}
|
|
27
27
|
/** Plain-language reasons for denial codes the user can act on. */
|
|
28
28
|
const WORK_SESSION_DENIAL_REASONS = {
|
|
29
|
-
WORK_REGISTRY_DISABLED: "Work
|
|
29
|
+
WORK_REGISTRY_DISABLED: "Work registry is disabled for this company; ask a company admin to enable it with: hq company --company <slug> settings set --work-registry-enabled true. This is not a login problem.",
|
|
30
30
|
FEATURE_DISABLED: "Live work tracking is turned off for this company. The company owner can turn it on with: hq company --company <slug> settings set --work-mesh-live-enabled true",
|
|
31
31
|
};
|
|
32
32
|
/** Plain reason for a server denial code, or undefined when there is none. */
|
|
@@ -24,6 +24,12 @@ export declare const SPOOL_DEBOUNCE_MS = 2000;
|
|
|
24
24
|
export declare const FLUSH_INTERVAL_MS = 10000;
|
|
25
25
|
/** Keep startup recovery bounded: the next daemon start continues the backlog. */
|
|
26
26
|
export declare const HELD_BACKFILL_STARTUP_BATCH_SIZE = 100;
|
|
27
|
+
/**
|
|
28
|
+
* A long-running daemon re-runs the held backfill this often while held
|
|
29
|
+
* events remain, so a backlog larger than one batch keeps draining after a
|
|
30
|
+
* reinstall instead of waiting for the next daemon start (Shepherd 2361).
|
|
31
|
+
*/
|
|
32
|
+
export declare const HELD_BACKFILL_INTERVAL_MS: number;
|
|
27
33
|
export declare const DEVICE_DEFAULT_MEMBERSHIP_TIMEOUT_MS = 2000;
|
|
28
34
|
/** Skip further membership probes after a transport failure so a network drop
|
|
29
35
|
* cannot fan out one hanging lookup per held session. */
|
|
@@ -60,10 +66,12 @@ export interface DaemonRunDeps {
|
|
|
60
66
|
quarantined: number;
|
|
61
67
|
skipped?: number;
|
|
62
68
|
}>;
|
|
63
|
-
/** Injected held-session recovery (tests). Runs
|
|
69
|
+
/** Injected held-session recovery (tests). Runs before the startup flush and then periodically while held events remain. */
|
|
64
70
|
backfillHeld?: () => Promise<BackfillHeldResult>;
|
|
65
71
|
/** Maximum distinct held sessions reconciled during one daemon startup. */
|
|
66
72
|
heldBackfillBatchSize?: number;
|
|
73
|
+
/** Minimum gap between periodic held backfills. Default HELD_BACKFILL_INTERVAL_MS. */
|
|
74
|
+
heldBackfillIntervalMs?: number;
|
|
67
75
|
/** Injected board refresh (tests). */
|
|
68
76
|
refreshBoards?: () => Promise<{
|
|
69
77
|
refreshed: number;
|
|
@@ -46,6 +46,12 @@ export const SPOOL_DEBOUNCE_MS = 2_000;
|
|
|
46
46
|
export const FLUSH_INTERVAL_MS = 10_000;
|
|
47
47
|
/** Keep startup recovery bounded: the next daemon start continues the backlog. */
|
|
48
48
|
export const HELD_BACKFILL_STARTUP_BATCH_SIZE = 100;
|
|
49
|
+
/**
|
|
50
|
+
* A long-running daemon re-runs the held backfill this often while held
|
|
51
|
+
* events remain, so a backlog larger than one batch keeps draining after a
|
|
52
|
+
* reinstall instead of waiting for the next daemon start (Shepherd 2361).
|
|
53
|
+
*/
|
|
54
|
+
export const HELD_BACKFILL_INTERVAL_MS = 15 * 60 * 1_000;
|
|
49
55
|
export const DEVICE_DEFAULT_MEMBERSHIP_TIMEOUT_MS = 2_000;
|
|
50
56
|
/** Skip further membership probes after a transport failure so a network drop
|
|
51
57
|
* cannot fan out one hanging lookup per held session. */
|
|
@@ -350,6 +356,19 @@ export async function runMeshDaemon(deps = {}) {
|
|
|
350
356
|
};
|
|
351
357
|
/** In-memory held re-attempt throttle (survives watch-triggered flushes). */
|
|
352
358
|
let lastHeldRetryAtMs = 0;
|
|
359
|
+
/** Epoch ms of the last held backfill (startup or periodic). */
|
|
360
|
+
let lastHeldBackfillAtMs = 0;
|
|
361
|
+
const heldBackfillIntervalMs = deps.heldBackfillIntervalMs ?? HELD_BACKFILL_INTERVAL_MS;
|
|
362
|
+
const heldBackfillDue = (atMs) => {
|
|
363
|
+
if (atMs - lastHeldBackfillAtMs < heldBackfillIntervalMs)
|
|
364
|
+
return false;
|
|
365
|
+
try {
|
|
366
|
+
return fs.statSync(workMeshHeldPath(meshRoot)).size > 0;
|
|
367
|
+
}
|
|
368
|
+
catch {
|
|
369
|
+
return false;
|
|
370
|
+
}
|
|
371
|
+
};
|
|
353
372
|
const shutdownController = new AbortController();
|
|
354
373
|
const flushFn = deps.flush ??
|
|
355
374
|
(async (signal) => {
|
|
@@ -380,6 +399,12 @@ export async function runMeshDaemon(deps = {}) {
|
|
|
380
399
|
return "unavailable";
|
|
381
400
|
return authorization.status === "authorized";
|
|
382
401
|
},
|
|
402
|
+
// Held sessions whose local evidence only names a slug need the
|
|
403
|
+
// membership slug -> uid lookup, or they stay NEEDS_COMPANY forever.
|
|
404
|
+
resolveCompanyUid: async (slug) => {
|
|
405
|
+
const authorization = await tenantGate.authorize({ companySlug: slug, deviceDefault: false });
|
|
406
|
+
return authorization.status === "authorized" ? authorization.uid : null;
|
|
407
|
+
},
|
|
383
408
|
});
|
|
384
409
|
});
|
|
385
410
|
const replayFn = deps.replayOutbox ??
|
|
@@ -674,7 +699,11 @@ export async function runMeshDaemon(deps = {}) {
|
|
|
674
699
|
// A held session has already ended, so no later hook will naturally
|
|
675
700
|
// revisit its context. Repair a bounded batch before the first flush;
|
|
676
701
|
// recovered state is then retried immediately in this same cycle.
|
|
677
|
-
|
|
702
|
+
// A long-running daemon repeats the bounded batch on an interval
|
|
703
|
+
// while held events remain, so the cursor keeps rotating through a
|
|
704
|
+
// backlog larger than one batch.
|
|
705
|
+
if (reason === "start" || heldBackfillDue(now().getTime())) {
|
|
706
|
+
lastHeldBackfillAtMs = now().getTime();
|
|
678
707
|
try {
|
|
679
708
|
const recovered = await backfillFn();
|
|
680
709
|
if (recovered.reconciled > 0 ||
|
|
@@ -58,6 +58,13 @@ export interface FlushDeps {
|
|
|
58
58
|
* stripped. Defaults to a per-flush cached git resolver (tests inject).
|
|
59
59
|
*/
|
|
60
60
|
resolveWorkLocation?: WorkLocationResolver;
|
|
61
|
+
/**
|
|
62
|
+
* Membership-validated slug -> companyUid lookup for held sessions whose
|
|
63
|
+
* local evidence names a company slug but no uid. Return the uid, or null
|
|
64
|
+
* when the caller is not a member or the lookup is unavailable (the events
|
|
65
|
+
* stay held and are retried). Called at most once per slug per flush.
|
|
66
|
+
*/
|
|
67
|
+
resolveCompanyUid?: (slug: string) => Promise<string | null>;
|
|
61
68
|
}
|
|
62
69
|
export interface FlushSummary {
|
|
63
70
|
claimed: number;
|