@indigoai-us/hq-cli 5.234.0 → 5.234.2
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 +18 -0
- package/dist/command-catalog.generated.d.ts +3 -0
- package/dist/command-catalog.generated.js +4 -0
- package/dist/commands/lanes.js +37 -6
- package/dist/lib/flag-registry.js +20 -105
- package/package.json +1 -1
- package/dist/lib/flag-registry-worker-mode-cache.d.ts +0 -20
- package/dist/lib/flag-registry-worker-mode-cache.js +0 -254
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,24 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [5.234.2] — 2026-09-27
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- The flag-registry worker now uses source embedded in the CLI, so package
|
|
10
|
+
updates can no longer cause startup failures by removing its script.
|
|
11
|
+
|
|
12
|
+
## [5.234.1] — 2026-09-27
|
|
13
|
+
|
|
14
|
+
### Fixed
|
|
15
|
+
|
|
16
|
+
- A primary resume now confirms and syncs an Outpost lane queued during
|
|
17
|
+
owner-side admission. (#926)
|
|
18
|
+
- Outpost messages and account moves for children of primary-hosted lanes now
|
|
19
|
+
carry the bounded hire-grant context required by the owner. (#926)
|
|
20
|
+
- Outpost message resumes, moves, and admission queues now sync the returned
|
|
21
|
+
owner lane record back to the primary. (#926)
|
|
22
|
+
|
|
5
23
|
## [5.234.0] — 2026-09-27
|
|
6
24
|
|
|
7
25
|
### Added
|
|
@@ -8005,6 +8005,9 @@ export declare const COMMAND_CATALOG: readonly [{
|
|
|
8005
8005
|
}, {
|
|
8006
8006
|
readonly flags: "--worker <profile>";
|
|
8007
8007
|
readonly description: "Attach a personal worker profile when this message resumes the lane";
|
|
8008
|
+
}, {
|
|
8009
|
+
readonly flags: "--grant-context <base64url>";
|
|
8010
|
+
readonly description: "";
|
|
8008
8011
|
}, {
|
|
8009
8012
|
readonly flags: "--no-compact";
|
|
8010
8013
|
readonly description: "Skip the resume-time compaction check for this message";
|
|
@@ -10353,6 +10353,10 @@ export const COMMAND_CATALOG = [
|
|
|
10353
10353
|
"flags": "--worker <profile>",
|
|
10354
10354
|
"description": "Attach a personal worker profile when this message resumes the lane"
|
|
10355
10355
|
},
|
|
10356
|
+
{
|
|
10357
|
+
"flags": "--grant-context <base64url>",
|
|
10358
|
+
"description": ""
|
|
10359
|
+
},
|
|
10356
10360
|
{
|
|
10357
10361
|
"flags": "--no-compact",
|
|
10358
10362
|
"description": "Skip the resume-time compaction check for this message"
|
package/dist/commands/lanes.js
CHANGED
|
@@ -3841,7 +3841,7 @@ async function dispatchOutpostLaneCommand(ctx, lane, action, options) {
|
|
|
3841
3841
|
grantHierarchy = hierarchy;
|
|
3842
3842
|
grantContext = createOutpostHireGrantContext(lane, hierarchy);
|
|
3843
3843
|
}
|
|
3844
|
-
else if (action === "resume" &&
|
|
3844
|
+
else if ((action === "resume" || action === "message") &&
|
|
3845
3845
|
lane.senior.kind === "lane") {
|
|
3846
3846
|
grantContext = createOutpostHireGrantContext(lane, loadAllLanes(ctx.hqRoot()));
|
|
3847
3847
|
}
|
|
@@ -3987,9 +3987,14 @@ async function dispatchOutpostLaneCommand(ctx, lane, action, options) {
|
|
|
3987
3987
|
: action === "reparent"
|
|
3988
3988
|
? remote.lane !== null && typeof remote.lane === "object" &&
|
|
3989
3989
|
Array.isArray(remote.changed_descendant_ids)
|
|
3990
|
-
: (remote.delivery_status === "worker_spawned" ||
|
|
3990
|
+
: (((remote.delivery_status === "worker_spawned" ||
|
|
3991
3991
|
remote.delivery_status === "queued_on_target_account") &&
|
|
3992
|
-
remote.lane !== undefined)
|
|
3992
|
+
remote.lane !== undefined) ||
|
|
3993
|
+
(remote.delivery_status === "queued_on_admission" &&
|
|
3994
|
+
remote.lane !== null &&
|
|
3995
|
+
typeof remote.lane === "object" &&
|
|
3996
|
+
!Array.isArray(remote.lane) &&
|
|
3997
|
+
remote.lane.state === "queued")));
|
|
3993
3998
|
if (!resultConfirmed) {
|
|
3994
3999
|
throw new LanesError("outpost_delivery_unconfirmed", `Outpost ${outpostId} completed exec without a confirmed ${action} result for lane ${lane.lane_id}`, {
|
|
3995
4000
|
lane_id: lane.lane_id,
|
|
@@ -4010,6 +4015,21 @@ async function dispatchOutpostLaneCommand(ctx, lane, action, options) {
|
|
|
4010
4015
|
});
|
|
4011
4016
|
return;
|
|
4012
4017
|
}
|
|
4018
|
+
if (action === "message" &&
|
|
4019
|
+
(remote.delivery_status === "worker_spawned" ||
|
|
4020
|
+
remote.delivery_status === "queued_on_target_account" ||
|
|
4021
|
+
remote.delivery_status === "queued_on_admission")) {
|
|
4022
|
+
const ownerLane = syncLaneRecordFromOwner(ctx.hqRoot(), lane.lane_id, outpostId, remote.lane, undefined, { allowNonTerminal: true });
|
|
4023
|
+
printJson({
|
|
4024
|
+
...remote,
|
|
4025
|
+
lane_id: ownerLane.lane_id,
|
|
4026
|
+
lane: ownerLane,
|
|
4027
|
+
outpost_id: outpostId,
|
|
4028
|
+
outpost_command_id: dispatched.commandId,
|
|
4029
|
+
outpost_transport: "outposts_exec",
|
|
4030
|
+
});
|
|
4031
|
+
return;
|
|
4032
|
+
}
|
|
4013
4033
|
if (action === "reparent" || action === "resume") {
|
|
4014
4034
|
const ownerLane = syncLaneRecordFromOwner(ctx.hqRoot(), lane.lane_id, outpostId, remote.lane, undefined, { allowNonTerminal: true });
|
|
4015
4035
|
const descendants = [];
|
|
@@ -5872,7 +5892,7 @@ async function prepareAccountMove(ctx, lane, input) {
|
|
|
5872
5892
|
next.queued_at = undefined;
|
|
5873
5893
|
}
|
|
5874
5894
|
return next;
|
|
5875
|
-
}));
|
|
5895
|
+
}), input.grantContext);
|
|
5876
5896
|
return { lane: moved, from, to: target, copy, queued };
|
|
5877
5897
|
}, ctx.sleep);
|
|
5878
5898
|
}
|
|
@@ -5990,6 +6010,14 @@ function assertCurrentResumeAccountAvailable(ctx, lane) {
|
|
|
5990
6010
|
}
|
|
5991
6011
|
async function runMessage(ctx, laneId, opts) {
|
|
5992
6012
|
const hqRoot = ctx.hqRoot();
|
|
6013
|
+
const hireGrantContext = opts.grantContext === undefined
|
|
6014
|
+
? undefined
|
|
6015
|
+
: (() => {
|
|
6016
|
+
if (!ctx.isSsmOwnerDispatch()) {
|
|
6017
|
+
throw new LanesError("hire_grant_dispatch_unauthorized", "Message grant context is only valid on an Outpost owner dispatch", { lane_id: laneId });
|
|
6018
|
+
}
|
|
6019
|
+
return parseOutpostHireGrantContext(opts.grantContext, laneId);
|
|
6020
|
+
})();
|
|
5993
6021
|
let accepted = false;
|
|
5994
6022
|
let claimedMessages = [];
|
|
5995
6023
|
const markAccepted = () => {
|
|
@@ -6066,7 +6094,7 @@ async function runMessage(ctx, laneId, opts) {
|
|
|
6066
6094
|
}
|
|
6067
6095
|
// Check before transcript recovery, account probing, or message writes.
|
|
6068
6096
|
// The final state transition repeats this check under the senior lock.
|
|
6069
|
-
await withResumeHireAdmission(ctx, lane, () => undefined);
|
|
6097
|
+
await withResumeHireAdmission(ctx, lane, () => undefined, hireGrantContext);
|
|
6070
6098
|
lane = laneWithRecoveredProviderSession(hqRoot, lane);
|
|
6071
6099
|
const keepCurrentAccount = opts.account !== undefined && accountRequestKeepsCurrentLane(lane, opts.account);
|
|
6072
6100
|
if (keepCurrentAccount)
|
|
@@ -6162,6 +6190,7 @@ async function runMessage(ctx, laneId, opts) {
|
|
|
6162
6190
|
cliModel: opts.model?.trim() || undefined,
|
|
6163
6191
|
...(resumeProfile !== undefined ? { workerProfile: resumeProfile } : {}),
|
|
6164
6192
|
...(resumeEffort !== undefined ? { effort: resumeEffort } : {}),
|
|
6193
|
+
...(hireGrantContext !== undefined ? { grantContext: hireGrantContext } : {}),
|
|
6165
6194
|
pending: {
|
|
6166
6195
|
trigger: "message",
|
|
6167
6196
|
text: deliveryText,
|
|
@@ -6186,6 +6215,7 @@ async function runMessage(ctx, laneId, opts) {
|
|
|
6186
6215
|
...(automatic.historyReason !== undefined ? { accountHistoryReason: automatic.historyReason } : {}),
|
|
6187
6216
|
...(resumeProfile !== undefined ? { workerProfile: resumeProfile } : {}),
|
|
6188
6217
|
...(resumeEffort !== undefined ? { effort: resumeEffort } : {}),
|
|
6218
|
+
...(hireGrantContext !== undefined ? { grantContext: hireGrantContext } : {}),
|
|
6189
6219
|
pending: {
|
|
6190
6220
|
trigger: "message",
|
|
6191
6221
|
text: deliveryText,
|
|
@@ -6300,7 +6330,7 @@ async function runMessage(ctx, laneId, opts) {
|
|
|
6300
6330
|
});
|
|
6301
6331
|
archiveResumeMessage(hqRoot, laneId, dropboxMessage);
|
|
6302
6332
|
return started;
|
|
6303
|
-
});
|
|
6333
|
+
}, hireGrantContext);
|
|
6304
6334
|
}, ctx.sleep);
|
|
6305
6335
|
},
|
|
6306
6336
|
}, mechanism);
|
|
@@ -9752,6 +9782,7 @@ export function registerLanesCommand(parent, deps = {}) {
|
|
|
9752
9782
|
.option("--model <id>", "Model for the moved lane (with --account only)")
|
|
9753
9783
|
.option("--effort <level>", "Reasoning effort for the resumed provider turn")
|
|
9754
9784
|
.option("--worker <profile>", "Attach a personal worker profile when this message resumes the lane")
|
|
9785
|
+
.addOption(new Option("--grant-context <base64url>").hideHelp())
|
|
9755
9786
|
.option("--no-compact", "Skip the resume-time compaction check for this message")
|
|
9756
9787
|
.action(wrap(async (laneId, opts) => {
|
|
9757
9788
|
await runMessage(ctx, laneId, opts);
|
|
@@ -12,9 +12,8 @@ import { readMachineCompanyBinding } from "./work-context/company.js";
|
|
|
12
12
|
import { readSessionState } from "./work-context/state.js";
|
|
13
13
|
import { workContextRoot } from "./work-context/paths.js";
|
|
14
14
|
import { readCompaniesManifestMap } from "./work-context/repo-remote.js";
|
|
15
|
-
import {
|
|
15
|
+
import { resolveDefaultHqRoot } from "../utils/cognito-session.js";
|
|
16
16
|
import { resolveFlagRegistryEndpoint, } from "./flag-registry-endpoint.js";
|
|
17
|
-
import { FLAG_REGISTRY_SELF_CONTAINED_WORKER_FLAG, flagRegistryWorkerModeCacheFailureContext, flagRegistryStateDir, flagRegistryWorkerModeCachePath, readCachedFlagRegistryWorkerMode, writeCachedFlagRegistryWorkerMode, } from "./flag-registry-worker-mode-cache.js";
|
|
18
17
|
import { FLAG_REGISTRY_WORKER_SOURCE } from "./flag-registry-worker-source.generated.js";
|
|
19
18
|
export { FLAG_REGISTRY_ENDPOINT_ENV } from "./flag-registry-endpoint.js";
|
|
20
19
|
const COMPANY_UID_RE = /^cmp_[A-Za-z0-9]{3,128}$/;
|
|
@@ -101,86 +100,6 @@ function processSnapshotReader(companyIdentifiers) {
|
|
|
101
100
|
},
|
|
102
101
|
};
|
|
103
102
|
}
|
|
104
|
-
function personSubjectForWorkerMode(idToken) {
|
|
105
|
-
if (typeof idToken !== "string")
|
|
106
|
-
return null;
|
|
107
|
-
const payload = idToken.split(".")[1];
|
|
108
|
-
if (!payload)
|
|
109
|
-
return null;
|
|
110
|
-
try {
|
|
111
|
-
const subject = JSON.parse(Buffer.from(payload, "base64url").toString("utf8")).sub;
|
|
112
|
-
return typeof subject === "string" && subject.trim() ? subject : null;
|
|
113
|
-
}
|
|
114
|
-
catch {
|
|
115
|
-
Sentry.captureMessage("Could not scope cached flag-registry worker mode because the cached ID token payload is invalid", { level: "warning", tags: { component: "flag-registry-worker-mode-cache" } });
|
|
116
|
-
return null;
|
|
117
|
-
}
|
|
118
|
-
}
|
|
119
|
-
/** Scope a persisted rollout value to the same identity that fetched it. */
|
|
120
|
-
function workerModeCacheScopes(company) {
|
|
121
|
-
const tokens = loadCachedTokens();
|
|
122
|
-
const personSubject = tokens ? personSubjectForWorkerMode(tokens.idToken) : null;
|
|
123
|
-
if (tokens && !personSubject)
|
|
124
|
-
return null;
|
|
125
|
-
return [
|
|
126
|
-
...(company.companyUid ? [`company:${company.companyUid}`] : []),
|
|
127
|
-
...(company.companySlug ? [`company-slug:${company.companySlug}`] : []),
|
|
128
|
-
...(personSubject ? [`person:${personSubject}`] : []),
|
|
129
|
-
];
|
|
130
|
-
}
|
|
131
|
-
function selfContainedWorkerEnabled(company) {
|
|
132
|
-
const localOverride = readLocalOverride(FLAG_REGISTRY_SELF_CONTAINED_WORKER_FLAG, process.env);
|
|
133
|
-
if (localOverride !== null)
|
|
134
|
-
return localOverride;
|
|
135
|
-
const scopes = workerModeCacheScopes(company);
|
|
136
|
-
if (!scopes)
|
|
137
|
-
return false;
|
|
138
|
-
const filePath = flagRegistryWorkerModeCachePath(flagRegistryStateDir(), scopes);
|
|
139
|
-
try {
|
|
140
|
-
return readCachedFlagRegistryWorkerMode(filePath)?.value ?? false;
|
|
141
|
-
}
|
|
142
|
-
catch (error) {
|
|
143
|
-
const failure = flagRegistryWorkerModeCacheFailureContext(error, "read", filePath);
|
|
144
|
-
Sentry.captureException(failure.exception, {
|
|
145
|
-
tags: {
|
|
146
|
-
component: "flag-registry-worker-mode-cache",
|
|
147
|
-
worker: "flag-registry",
|
|
148
|
-
...failure.tags,
|
|
149
|
-
},
|
|
150
|
-
});
|
|
151
|
-
return false;
|
|
152
|
-
}
|
|
153
|
-
}
|
|
154
|
-
function cacheWorkerModeFromSnapshot(snapshot, company) {
|
|
155
|
-
// A developer's local hq-flags override is process-local and must not become
|
|
156
|
-
// the value selected by a future process.
|
|
157
|
-
if (readLocalOverride(FLAG_REGISTRY_SELF_CONTAINED_WORKER_FLAG, process.env) !== null)
|
|
158
|
-
return;
|
|
159
|
-
const scopes = workerModeCacheScopes(company);
|
|
160
|
-
if (!scopes)
|
|
161
|
-
return;
|
|
162
|
-
const decision = resolveFlag({
|
|
163
|
-
flagKey: FLAG_REGISTRY_SELF_CONTAINED_WORKER_FLAG,
|
|
164
|
-
env: {},
|
|
165
|
-
snapshot,
|
|
166
|
-
companyIdentifiers: [company.companyUid, company.companySlug].filter((value) => Boolean(value)),
|
|
167
|
-
lookup: { fallback: false },
|
|
168
|
-
});
|
|
169
|
-
try {
|
|
170
|
-
writeCachedFlagRegistryWorkerMode(flagRegistryWorkerModeCachePath(flagRegistryStateDir(), scopes), { version: snapshot.version, value: decision.value });
|
|
171
|
-
}
|
|
172
|
-
catch (error) {
|
|
173
|
-
const filePath = flagRegistryWorkerModeCachePath(flagRegistryStateDir(), scopes);
|
|
174
|
-
const failure = flagRegistryWorkerModeCacheFailureContext(error, "write", filePath);
|
|
175
|
-
Sentry.captureException(failure.exception, {
|
|
176
|
-
tags: {
|
|
177
|
-
component: "flag-registry-worker-mode-cache",
|
|
178
|
-
worker: "flag-registry",
|
|
179
|
-
...failure.tags,
|
|
180
|
-
},
|
|
181
|
-
});
|
|
182
|
-
}
|
|
183
|
-
}
|
|
184
103
|
/**
|
|
185
104
|
* Begin one registry refresh without awaiting it or retaining the CLI process.
|
|
186
105
|
*
|
|
@@ -199,42 +118,38 @@ export function kickFlagRegistryReadiness(dependencies = {}) {
|
|
|
199
118
|
const companyIdentifiers = [companyUid, company.companySlug].filter((value) => Boolean(value));
|
|
200
119
|
processReader = processSnapshotReader(companyIdentifiers);
|
|
201
120
|
try {
|
|
202
|
-
|
|
203
|
-
|
|
121
|
+
const embeddedSource = dependencies.workerSource ?? FLAG_REGISTRY_WORKER_SOURCE;
|
|
122
|
+
if (embeddedSource.length === 0) {
|
|
123
|
+
(dependencies.captureWorkerFailure ?? ((error, tags) => {
|
|
124
|
+
Sentry.captureException(error, { tags });
|
|
125
|
+
}))(new Error("Self-contained flag-registry worker source is unavailable"), {
|
|
126
|
+
component: "flag-registry-worker-startup",
|
|
127
|
+
worker: "flag-registry",
|
|
128
|
+
flag_worker_failure: "embedded_source_unavailable",
|
|
129
|
+
});
|
|
130
|
+
return processReader;
|
|
131
|
+
}
|
|
132
|
+
// This source is embedded at build time, so it remains available while an
|
|
133
|
+
// updater replaces the installed package tree.
|
|
134
|
+
const workerOptions = {
|
|
135
|
+
workerData: {
|
|
204
136
|
endpoint,
|
|
205
137
|
...(companyUid ? { companyUid } : {}),
|
|
206
138
|
...(company.companySlug ? { companyIdentifiers: [company.companySlug] } : {}),
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
// build time. It is already resident before company, identity, and cache
|
|
211
|
-
// reads can yield to an updater replacing this package tree.
|
|
212
|
-
const embeddedSource = dependencies.workerSource ?? FLAG_REGISTRY_WORKER_SOURCE;
|
|
213
|
-
if (embeddedSource.length === 0) {
|
|
214
|
-
(dependencies.captureWorkerFailure ?? ((error, tags) => {
|
|
215
|
-
Sentry.captureException(error, { tags });
|
|
216
|
-
}))(new Error("Self-contained flag-registry worker source is unavailable"), {
|
|
217
|
-
component: "flag-registry-worker-startup",
|
|
218
|
-
worker: "flag-registry",
|
|
219
|
-
flag_worker_failure: "embedded_source_unavailable",
|
|
220
|
-
});
|
|
221
|
-
return processReader;
|
|
222
|
-
}
|
|
223
|
-
workerSource = embeddedSource;
|
|
224
|
-
workerOptions = { ...workerOptions, eval: true };
|
|
225
|
-
}
|
|
139
|
+
},
|
|
140
|
+
eval: true,
|
|
141
|
+
};
|
|
226
142
|
// The SDK's child-process integration captures every Worker error unless
|
|
227
143
|
// its process-wide captureWorkerErrors option is disabled. Scope only this
|
|
228
144
|
// worker so the reporting boundary can classify its startup errors without
|
|
229
145
|
// changing capture for unrelated workers.
|
|
230
146
|
const worker = Sentry.withScope((scope) => {
|
|
231
147
|
scope.setTag("worker", "flag-registry");
|
|
232
|
-
return (dependencies.createWorker ?? ((filename, options) => new Worker(filename, options)))(
|
|
148
|
+
return (dependencies.createWorker ?? ((filename, options) => new Worker(filename, options)))(embeddedSource, workerOptions);
|
|
233
149
|
});
|
|
234
150
|
worker.on("message", (value) => {
|
|
235
151
|
if (isFlagSnapshot(value)) {
|
|
236
152
|
heldSnapshot = value;
|
|
237
|
-
cacheWorkerModeFromSnapshot(value, company);
|
|
238
153
|
}
|
|
239
154
|
});
|
|
240
155
|
// A failed background refresh must stay invisible to an offline CLI.
|
package/package.json
CHANGED
|
@@ -1,20 +0,0 @@
|
|
|
1
|
-
export declare const FLAG_REGISTRY_SELF_CONTAINED_WORKER_FLAG = "cli.flag-worker-self-contained";
|
|
2
|
-
interface CachedWorkerMode {
|
|
3
|
-
version: number;
|
|
4
|
-
value: boolean;
|
|
5
|
-
}
|
|
6
|
-
export type FlagRegistryWorkerModeCacheOperation = "read" | "write";
|
|
7
|
-
export type FlagRegistryWorkerModeCacheStep = "mkdir" | "write_temp" | "link" | "unlink" | "prune" | "readdir" | "read";
|
|
8
|
-
/** Build bounded filesystem diagnostics without exposing the state directory. */
|
|
9
|
-
export declare function flagRegistryWorkerModeCacheFailureContext(error: unknown, operation: FlagRegistryWorkerModeCacheOperation, filePath: string, env?: NodeJS.ProcessEnv): {
|
|
10
|
-
exception: unknown;
|
|
11
|
-
tags: Record<string, string>;
|
|
12
|
-
};
|
|
13
|
-
/** Keep the last server snapshot decision for this one startup-mode flag. */
|
|
14
|
-
export declare function flagRegistryWorkerModeCachePath(stateDir: string, scopeIdentifiers: readonly string[]): string;
|
|
15
|
-
/** A missing, invalid, or inaccessible cache never enables the rollout. */
|
|
16
|
-
export declare function readCachedFlagRegistryWorkerMode(filePath: string): CachedWorkerMode | null;
|
|
17
|
-
export declare function writeCachedFlagRegistryWorkerMode(filePath: string, snapshot: CachedWorkerMode): void;
|
|
18
|
-
export declare function flagRegistryStateDir(env?: NodeJS.ProcessEnv): string;
|
|
19
|
-
export {};
|
|
20
|
-
//# sourceMappingURL=flag-registry-worker-mode-cache.d.ts.map
|
|
@@ -1,254 +0,0 @@
|
|
|
1
|
-
import { execFileSync } from "node:child_process";
|
|
2
|
-
import { createHash, randomUUID } from "node:crypto";
|
|
3
|
-
import * as fs from "node:fs";
|
|
4
|
-
import * as os from "node:os";
|
|
5
|
-
import * as path from "node:path";
|
|
6
|
-
export const FLAG_REGISTRY_SELF_CONTAINED_WORKER_FLAG = "cli.flag-worker-self-contained";
|
|
7
|
-
class FlagRegistryWorkerModeCacheStepError extends Error {
|
|
8
|
-
cacheStep;
|
|
9
|
-
originalError;
|
|
10
|
-
constructor(cacheStep, originalError) {
|
|
11
|
-
super(`Flag registry worker-mode cache failed at ${cacheStep}`);
|
|
12
|
-
this.cacheStep = cacheStep;
|
|
13
|
-
this.originalError = originalError;
|
|
14
|
-
this.name = "FlagRegistryWorkerModeCacheStepError";
|
|
15
|
-
}
|
|
16
|
-
}
|
|
17
|
-
const KNOWN_ERRNOS = new Set([
|
|
18
|
-
"EACCES",
|
|
19
|
-
"EBUSY",
|
|
20
|
-
"EEXIST",
|
|
21
|
-
"EIO",
|
|
22
|
-
"EISDIR",
|
|
23
|
-
"EMFILE",
|
|
24
|
-
"ENFILE",
|
|
25
|
-
"ENOENT",
|
|
26
|
-
"ENOSPC",
|
|
27
|
-
"ENOTDIR",
|
|
28
|
-
"EPERM",
|
|
29
|
-
"EROFS",
|
|
30
|
-
]);
|
|
31
|
-
function withCacheFsStep(cacheStep, operation) {
|
|
32
|
-
try {
|
|
33
|
-
return operation();
|
|
34
|
-
}
|
|
35
|
-
catch (error) {
|
|
36
|
-
if (error instanceof FlagRegistryWorkerModeCacheStepError)
|
|
37
|
-
throw error;
|
|
38
|
-
throw new FlagRegistryWorkerModeCacheStepError(cacheStep, error);
|
|
39
|
-
}
|
|
40
|
-
}
|
|
41
|
-
function originalCacheError(error) {
|
|
42
|
-
return error instanceof FlagRegistryWorkerModeCacheStepError
|
|
43
|
-
? error.originalError
|
|
44
|
-
: error;
|
|
45
|
-
}
|
|
46
|
-
function cacheErrorCode(error) {
|
|
47
|
-
const original = originalCacheError(error);
|
|
48
|
-
return typeof original === "object" && original !== null && "code" in original
|
|
49
|
-
? original.code
|
|
50
|
-
: undefined;
|
|
51
|
-
}
|
|
52
|
-
function directoryHasUchg(directory) {
|
|
53
|
-
if (process.platform !== "darwin")
|
|
54
|
-
return "unknown";
|
|
55
|
-
try {
|
|
56
|
-
const rawFlags = execFileSync("/usr/bin/stat", ["-f", "%f", directory], { timeout: 1000 }).toString("utf8").trim();
|
|
57
|
-
if (!/^\d+$/.test(rawFlags))
|
|
58
|
-
return "unknown";
|
|
59
|
-
const flags = Number(rawFlags);
|
|
60
|
-
if (!Number.isSafeInteger(flags))
|
|
61
|
-
return "unknown";
|
|
62
|
-
return String((flags & 0x00000002) !== 0 || (flags & 0x00020000) !== 0);
|
|
63
|
-
}
|
|
64
|
-
catch {
|
|
65
|
-
// This bounded diagnostic is best-effort; preserve unknown and the original cache error.
|
|
66
|
-
return "unknown";
|
|
67
|
-
}
|
|
68
|
-
}
|
|
69
|
-
/** Build bounded filesystem diagnostics without exposing the state directory. */
|
|
70
|
-
export function flagRegistryWorkerModeCacheFailureContext(error, operation, filePath, env = process.env) {
|
|
71
|
-
const cacheError = error instanceof FlagRegistryWorkerModeCacheStepError
|
|
72
|
-
? error
|
|
73
|
-
: undefined;
|
|
74
|
-
const directory = path.dirname(filePath);
|
|
75
|
-
let dirExists = "unknown";
|
|
76
|
-
let stats;
|
|
77
|
-
try {
|
|
78
|
-
stats = fs.statSync(directory);
|
|
79
|
-
dirExists = "true";
|
|
80
|
-
}
|
|
81
|
-
catch (metadataError) {
|
|
82
|
-
if (cacheErrorCode(metadataError) === "ENOENT")
|
|
83
|
-
dirExists = "false";
|
|
84
|
-
// Keep inaccessible metadata unknown; diagnostics must not hide the cache error.
|
|
85
|
-
}
|
|
86
|
-
const uid = typeof process.getuid === "function" ? process.getuid() : undefined;
|
|
87
|
-
const ownerMatches = typeof uid === "number" && typeof stats?.uid === "number"
|
|
88
|
-
? String(stats.uid === uid)
|
|
89
|
-
: "unknown";
|
|
90
|
-
const dirMode = typeof stats?.mode === "number"
|
|
91
|
-
? (stats.mode & 0o777).toString(8).padStart(3, "0")
|
|
92
|
-
: "unknown";
|
|
93
|
-
const dirHasUchg = directoryHasUchg(directory);
|
|
94
|
-
const rawErrno = cacheErrorCode(error);
|
|
95
|
-
const errno = typeof rawErrno === "string" && KNOWN_ERRNOS.has(rawErrno)
|
|
96
|
-
? rawErrno
|
|
97
|
-
: "UNKNOWN";
|
|
98
|
-
const stateDirSource = env.HQ_STATE_DIR?.trim() ? "env" : "home";
|
|
99
|
-
return {
|
|
100
|
-
exception: originalCacheError(error),
|
|
101
|
-
tags: {
|
|
102
|
-
cache_op: operation,
|
|
103
|
-
cache_step: cacheError?.cacheStep ?? (operation === "read" ? "read" : "write_temp"),
|
|
104
|
-
errno,
|
|
105
|
-
state_dir_source: stateDirSource,
|
|
106
|
-
dir_exists: dirExists,
|
|
107
|
-
dir_owner_matches_uid: ownerMatches,
|
|
108
|
-
dir_mode: dirMode,
|
|
109
|
-
dir_has_uchg: dirHasUchg,
|
|
110
|
-
cache_path_kind: "<stateDir>/flags/<basename>",
|
|
111
|
-
},
|
|
112
|
-
};
|
|
113
|
-
}
|
|
114
|
-
/** Keep the last server snapshot decision for this one startup-mode flag. */
|
|
115
|
-
export function flagRegistryWorkerModeCachePath(stateDir, scopeIdentifiers) {
|
|
116
|
-
const basename = scopeIdentifiers.length === 0
|
|
117
|
-
? "worker-mode-default.json"
|
|
118
|
-
: `worker-mode-${createHash("sha256")
|
|
119
|
-
.update([...scopeIdentifiers].sort().join("\0"))
|
|
120
|
-
.digest("hex")
|
|
121
|
-
.slice(0, 24)}.json`;
|
|
122
|
-
return path.join(stateDir, "flags", basename);
|
|
123
|
-
}
|
|
124
|
-
/** A missing, invalid, or inaccessible cache never enables the rollout. */
|
|
125
|
-
export function readCachedFlagRegistryWorkerMode(filePath) {
|
|
126
|
-
let latest = readCacheRecordIfPresent(filePath);
|
|
127
|
-
const directory = path.dirname(filePath);
|
|
128
|
-
if (!withCacheFsStep("read", () => fs.existsSync(directory)))
|
|
129
|
-
return latest;
|
|
130
|
-
const basename = path.basename(filePath);
|
|
131
|
-
const versionedPrefix = `${basename}.v`;
|
|
132
|
-
for (const entry of withCacheFsStep("readdir", () => fs.readdirSync(directory))) {
|
|
133
|
-
if (!entry.startsWith(versionedPrefix) || !entry.endsWith(".json"))
|
|
134
|
-
continue;
|
|
135
|
-
const versionedPath = path.join(directory, entry);
|
|
136
|
-
const record = readCacheRecordIfPresent(versionedPath);
|
|
137
|
-
if (record)
|
|
138
|
-
latest = newerCacheRecord(latest, record);
|
|
139
|
-
}
|
|
140
|
-
return latest;
|
|
141
|
-
}
|
|
142
|
-
export function writeCachedFlagRegistryWorkerMode(filePath, snapshot) {
|
|
143
|
-
withCacheFsStep("mkdir", () => fs.mkdirSync(path.dirname(filePath), { recursive: true, mode: 0o700 }));
|
|
144
|
-
const existing = readCachedFlagRegistryWorkerMode(filePath);
|
|
145
|
-
if (existing && snapshot.version < existing.version)
|
|
146
|
-
return;
|
|
147
|
-
// Each registry version gets its own immutable destination. Concurrent
|
|
148
|
-
// processes can publish v7 and v8 in either order without an older value
|
|
149
|
-
// replacing the newer record.
|
|
150
|
-
const versionedPath = `${filePath}.v${snapshot.version}.json`;
|
|
151
|
-
const tempPath = `${filePath}.${process.pid}.${randomUUID()}.tmp`;
|
|
152
|
-
try {
|
|
153
|
-
withCacheFsStep("write_temp", () => fs.writeFileSync(tempPath, `${JSON.stringify(snapshot)}\n`, {
|
|
154
|
-
encoding: "utf8",
|
|
155
|
-
mode: 0o600,
|
|
156
|
-
flag: "wx",
|
|
157
|
-
}));
|
|
158
|
-
try {
|
|
159
|
-
withCacheFsStep("link", () => fs.linkSync(tempPath, versionedPath));
|
|
160
|
-
}
|
|
161
|
-
catch (error) {
|
|
162
|
-
if (!isAlreadyExistsError(error))
|
|
163
|
-
throw error;
|
|
164
|
-
const existingVersion = readCacheRecord(versionedPath);
|
|
165
|
-
if (existingVersion.value !== snapshot.value) {
|
|
166
|
-
throw new FlagRegistryWorkerModeCacheStepError("link", new Error("Conflicting flag-registry worker cache values share a version"));
|
|
167
|
-
}
|
|
168
|
-
}
|
|
169
|
-
}
|
|
170
|
-
finally {
|
|
171
|
-
if (withCacheFsStep("unlink", () => fs.existsSync(tempPath))) {
|
|
172
|
-
withCacheFsStep("unlink", () => fs.unlinkSync(tempPath));
|
|
173
|
-
}
|
|
174
|
-
}
|
|
175
|
-
pruneOlderCacheRecords(filePath);
|
|
176
|
-
}
|
|
177
|
-
export function flagRegistryStateDir(env = process.env) {
|
|
178
|
-
const override = env.HQ_STATE_DIR?.trim();
|
|
179
|
-
return override ? override : path.join(os.homedir(), ".hq");
|
|
180
|
-
}
|
|
181
|
-
function isCachedWorkerMode(value) {
|
|
182
|
-
return typeof value === "object" && value !== null &&
|
|
183
|
-
"version" in value && typeof value.version === "number" &&
|
|
184
|
-
Number.isSafeInteger(value.version) && value.version >= 0 &&
|
|
185
|
-
"value" in value && typeof value.value === "boolean";
|
|
186
|
-
}
|
|
187
|
-
function readCacheRecord(filePath) {
|
|
188
|
-
const contents = withCacheFsStep("read", () => fs.readFileSync(filePath, "utf8"));
|
|
189
|
-
const value = JSON.parse(contents);
|
|
190
|
-
if (!isCachedWorkerMode(value)) {
|
|
191
|
-
throw new Error("Cached flag-registry worker mode has an invalid shape");
|
|
192
|
-
}
|
|
193
|
-
return value;
|
|
194
|
-
}
|
|
195
|
-
function newerCacheRecord(current, candidate) {
|
|
196
|
-
if (!current || candidate.version > current.version)
|
|
197
|
-
return candidate;
|
|
198
|
-
if (candidate.version === current.version && candidate.value !== current.value) {
|
|
199
|
-
throw new Error("Conflicting flag-registry worker cache values share a version");
|
|
200
|
-
}
|
|
201
|
-
return current;
|
|
202
|
-
}
|
|
203
|
-
function pruneOlderCacheRecords(filePath) {
|
|
204
|
-
const latest = readCachedFlagRegistryWorkerMode(filePath);
|
|
205
|
-
if (!latest)
|
|
206
|
-
return;
|
|
207
|
-
const directory = path.dirname(filePath);
|
|
208
|
-
const basename = path.basename(filePath);
|
|
209
|
-
const versionedPrefix = `${basename}.v`;
|
|
210
|
-
for (const entry of withCacheFsStep("readdir", () => fs.readdirSync(directory))) {
|
|
211
|
-
if (!entry.startsWith(versionedPrefix) || !entry.endsWith(".json"))
|
|
212
|
-
continue;
|
|
213
|
-
const recordPath = path.join(directory, entry);
|
|
214
|
-
const record = readCacheRecordIfPresent(recordPath);
|
|
215
|
-
if (record && record.version < latest.version) {
|
|
216
|
-
try {
|
|
217
|
-
withCacheFsStep("prune", () => fs.unlinkSync(recordPath));
|
|
218
|
-
}
|
|
219
|
-
catch (error) {
|
|
220
|
-
if (!isNotFoundError(error))
|
|
221
|
-
throw error;
|
|
222
|
-
}
|
|
223
|
-
}
|
|
224
|
-
}
|
|
225
|
-
if (withCacheFsStep("prune", () => fs.existsSync(filePath))) {
|
|
226
|
-
const legacyRecord = readCacheRecordIfPresent(filePath);
|
|
227
|
-
if (legacyRecord && legacyRecord.version < latest.version) {
|
|
228
|
-
try {
|
|
229
|
-
withCacheFsStep("prune", () => fs.unlinkSync(filePath));
|
|
230
|
-
}
|
|
231
|
-
catch (error) {
|
|
232
|
-
if (!isNotFoundError(error))
|
|
233
|
-
throw error;
|
|
234
|
-
}
|
|
235
|
-
}
|
|
236
|
-
}
|
|
237
|
-
}
|
|
238
|
-
function readCacheRecordIfPresent(filePath) {
|
|
239
|
-
try {
|
|
240
|
-
return readCacheRecord(filePath);
|
|
241
|
-
}
|
|
242
|
-
catch (error) {
|
|
243
|
-
if (isNotFoundError(error))
|
|
244
|
-
return null;
|
|
245
|
-
throw error;
|
|
246
|
-
}
|
|
247
|
-
}
|
|
248
|
-
function isAlreadyExistsError(error) {
|
|
249
|
-
return cacheErrorCode(error) === "EEXIST";
|
|
250
|
-
}
|
|
251
|
-
function isNotFoundError(error) {
|
|
252
|
-
return cacheErrorCode(error) === "ENOENT";
|
|
253
|
-
}
|
|
254
|
-
//# sourceMappingURL=flag-registry-worker-mode-cache.js.map
|