@indigoai-us/hq-cli 5.345.134 → 5.345.136
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 +48 -0
- package/assets/scaffold/core/scripts/worktree-gc.sh +98 -8
- package/dist/command-catalog.generated.d.ts +62 -6
- package/dist/command-catalog.generated.js +79 -7
- package/dist/command-registration-plan.d.ts +6 -0
- package/dist/command-registration-plan.js +1 -0
- package/dist/commands/core-bucket-c-handoff-knowledge-native.d.ts +3 -1
- package/dist/commands/core-bucket-c-handoff-knowledge-native.js +8 -4
- package/dist/commands/core-checkpoint-native.d.ts +10 -0
- package/dist/commands/core-checkpoint-native.js +60 -15
- package/dist/commands/core-handoff.d.ts +2 -0
- package/dist/commands/core-handoff.js +3 -1
- package/dist/commands/core-pipeline-workflow-runner-native.js +7 -5
- package/dist/commands/daemon.js +3 -1
- package/dist/commands/fingerprint.d.ts +10 -0
- package/dist/commands/fingerprint.js +191 -0
- package/dist/commands/integrations-core.d.ts +22 -0
- package/dist/commands/integrations-core.js +25 -0
- package/dist/commands/integrations-manage.d.ts +24 -1
- package/dist/commands/integrations-manage.js +92 -17
- package/dist/commands/integrations.d.ts +11 -0
- package/dist/commands/integrations.js +100 -19
- package/dist/commands/lanes-loop.d.ts +9 -0
- package/dist/commands/lanes-loop.js +9 -1
- package/dist/commands/lanes.js +78 -55
- package/dist/commands/meetings.d.ts +2 -0
- package/dist/commands/meetings.js +57 -14
- package/dist/commands/mesh.d.ts +19 -1
- package/dist/commands/mesh.js +158 -70
- package/dist/commands/reindex.js +80 -5
- package/dist/commands/storage-offload.d.ts +93 -28
- package/dist/commands/storage-offload.js +290 -34
- package/dist/commands/storage.js +60 -7
- package/dist/hq-cloud-sync-runner/bin/sync-runner.js +2550 -1748
- package/dist/hq-cloud-sync-runner/package.json +1 -1
- package/dist/index.js +4 -0
- package/dist/lib/capacity/route.js +24 -0
- package/dist/lib/core-utils/session-company.d.ts +11 -0
- package/dist/lib/core-utils/session-company.js +34 -0
- package/dist/lib/core-utils/worktree-resume.js +44 -4
- package/dist/lib/core-utils/worktree-session-owner.d.ts +27 -0
- package/dist/lib/core-utils/worktree-session-owner.js +157 -0
- package/dist/lib/daemon/catalog.d.ts +2 -0
- package/dist/lib/daemon/catalog.js +3 -3
- package/dist/lib/daemon/hq-child-bootstrap-stage.d.ts +5 -0
- package/dist/lib/daemon/hq-child-bootstrap-stage.js +3 -11
- package/dist/lib/daemon/server.js +10 -1
- package/dist/lib/daemon/supervisor.js +6 -0
- package/dist/lib/daemon/sync-runner-bundle.d.ts +5 -0
- package/dist/lib/daemon/sync-runner-bundle.js +4 -12
- package/dist/lib/doctor/checks/claude-wiring.js +2 -1
- package/dist/lib/doctor/fix/apply.d.ts +20 -0
- package/dist/lib/doctor/fix/apply.js +63 -3
- package/dist/lib/doctor/fix/remediation.d.ts +1 -1
- package/dist/lib/doctor/fix/remediation.js +16 -0
- package/dist/lib/doctor/shared.d.ts +11 -1
- package/dist/lib/doctor/shared.js +16 -2
- package/dist/lib/fingerprint/fixtures.d.ts +19 -0
- package/dist/lib/fingerprint/fixtures.js +61 -0
- package/dist/lib/fingerprint/format.d.ts +50 -0
- package/dist/lib/fingerprint/format.js +105 -0
- package/dist/lib/fingerprint/offline.d.ts +16 -0
- package/dist/lib/fingerprint/offline.js +44 -0
- package/dist/lib/fingerprint/registry-client.d.ts +74 -0
- package/dist/lib/fingerprint/registry-client.js +141 -0
- package/dist/lib/fingerprint/resolve-local.d.ts +14 -0
- package/dist/lib/fingerprint/resolve-local.js +67 -0
- package/dist/lib/fingerprint/self-core.d.ts +139 -0
- package/dist/lib/fingerprint/self-core.js +371 -0
- package/dist/lib/fingerprint/self.d.ts +44 -0
- package/dist/lib/fingerprint/self.js +191 -0
- 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/monitor.js +14 -2
- package/dist/lib/lanes/spawn.d.ts +19 -1
- package/dist/lib/lanes/spawn.js +50 -6
- package/dist/lib/lanes/story-ledger.d.ts +1 -0
- package/dist/lib/lanes/story-ledger.js +3 -1
- package/dist/lib/lanes/types.d.ts +12 -0
- package/dist/lib/lanes/types.js +12 -1
- package/dist/lib/mesh/live/daemon/install.d.ts +12 -0
- package/dist/lib/mesh/live/daemon/install.js +22 -0
- package/dist/lib/mesh/live/flush.js +106 -3
- package/dist/lib/mesh/live/session-events-client.d.ts +9 -0
- package/dist/lib/mesh/live/session-events-client.js +15 -0
- package/dist/lib/work-context/state.d.ts +10 -0
- package/dist/lib/work-context/state.js +5 -0
- package/dist/utils/cli-network-lifecycle.d.ts +2 -0
- package/dist/utils/cli-network-lifecycle.js +5 -1
- package/dist/utils/cognito-session.d.ts +2 -21
- package/dist/utils/cognito-session.js +3 -84
- package/dist/utils/fsync-directory.d.ts +17 -0
- package/dist/utils/fsync-directory.js +27 -0
- package/dist/utils/person-token-cache.d.ts +32 -0
- package/dist/utils/person-token-cache.js +95 -0
- package/dist/utils/vault-api.d.ts +21 -1
- package/dist/utils/vault-api.js +54 -5
- package/dist/utils/windows-hide-default.d.ts +42 -0
- package/dist/utils/windows-hide-default.js +98 -0
- package/package.json +2 -2
package/dist/commands/mesh.js
CHANGED
|
@@ -174,6 +174,42 @@ async function requireAssignableSelf(token, verb) {
|
|
|
174
174
|
}
|
|
175
175
|
return resolved;
|
|
176
176
|
}
|
|
177
|
+
/** 400 PROJECT_VIEW_INVALID "Unknown story ..." from a story PATCH. */
|
|
178
|
+
export function isUnknownBoardStory(error) {
|
|
179
|
+
if (!error || typeof error !== "object")
|
|
180
|
+
return false;
|
|
181
|
+
const candidate = error;
|
|
182
|
+
return (httpStatus(error) === 400 &&
|
|
183
|
+
(candidate.code === "PROJECT_VIEW_INVALID" ||
|
|
184
|
+
(typeof candidate.message === "string" && candidate.message.includes("PROJECT_VIEW_INVALID"))) &&
|
|
185
|
+
typeof candidate.message === "string" &&
|
|
186
|
+
candidate.message.includes("Unknown story"));
|
|
187
|
+
}
|
|
188
|
+
/** The local PRD for this project when it lists `storyId`; null otherwise. */
|
|
189
|
+
export function localPrdWithStory(company, projectId, storyId, hqRoot = process.env.HQ_ROOT?.trim() || findHqRoot() || undefined) {
|
|
190
|
+
const prdPath = findLocalPrd(hqRoot, company.companySlug, projectId);
|
|
191
|
+
if (!prdPath)
|
|
192
|
+
return null;
|
|
193
|
+
let source;
|
|
194
|
+
try {
|
|
195
|
+
source = parseProjectPlan(readFileSync(prdPath, "utf8"), prdPath, { requireUserStories: true });
|
|
196
|
+
}
|
|
197
|
+
catch {
|
|
198
|
+
return null;
|
|
199
|
+
}
|
|
200
|
+
return source.stories.some((story) => story.id === storyId) ? { prdPath, source } : null;
|
|
201
|
+
}
|
|
202
|
+
function unknownStoryHint(error, company, projectId, storyId, prdPath) {
|
|
203
|
+
const base = error instanceof Error ? error.message : String(error);
|
|
204
|
+
const command = `hq mesh project ensure ${projectId} --company ${company.companySlug ?? company.companyUid} --stories-file ${prdPath ?? "<prd.json>"}`;
|
|
205
|
+
const hint = prdPath
|
|
206
|
+
? `${storyId} is in ${prdPath} but not on the Board. Register it with: ${command}`
|
|
207
|
+
: `${storyId} is not on the ${projectId} Board. Add it to the project PRD, then run: ${command}`;
|
|
208
|
+
return Object.assign(new Error(`${base}\n${hint}`), {
|
|
209
|
+
status: httpStatus(error),
|
|
210
|
+
code: error?.code,
|
|
211
|
+
});
|
|
212
|
+
}
|
|
177
213
|
async function runStory(opts) {
|
|
178
214
|
const storyId = opts.story?.trim();
|
|
179
215
|
const status = opts.status?.trim();
|
|
@@ -203,14 +239,46 @@ async function runStory(opts) {
|
|
|
203
239
|
}
|
|
204
240
|
assigneeUid = assignee;
|
|
205
241
|
}
|
|
242
|
+
const patch = {
|
|
243
|
+
...(status ? { status } : {}),
|
|
244
|
+
...(assigneeUid ? { assigneeUid } : {}),
|
|
245
|
+
...(clearAssignee ? { clearAssignee: true } : {}),
|
|
246
|
+
...(opts.force ? { force: true } : {}),
|
|
247
|
+
};
|
|
206
248
|
let patched;
|
|
249
|
+
let registeredFrom;
|
|
207
250
|
try {
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
251
|
+
try {
|
|
252
|
+
patched = await patchStory(token, company.companyUid, projectId, storyId, patch);
|
|
253
|
+
}
|
|
254
|
+
catch (err) {
|
|
255
|
+
if (!isUnknownBoardStory(err))
|
|
256
|
+
throw err;
|
|
257
|
+
// The story is in the local PRD but not on the Board yet: register the
|
|
258
|
+
// PRD (the same merge `hq mesh project ensure` does), then retry once.
|
|
259
|
+
const recovery = localPrdWithStory(company, projectId, storyId);
|
|
260
|
+
if (!recovery)
|
|
261
|
+
throw unknownStoryHint(err, company, projectId, storyId);
|
|
262
|
+
if (opts.ensure === false) {
|
|
263
|
+
throw unknownStoryHint(err, company, projectId, storyId, recovery.prdPath);
|
|
264
|
+
}
|
|
265
|
+
await ensureProjectFromSource({
|
|
266
|
+
token,
|
|
267
|
+
company,
|
|
268
|
+
projectId,
|
|
269
|
+
source: recovery.source,
|
|
270
|
+
current: await getProjectView(token, company.companyUid, projectId).catch((viewErr) => {
|
|
271
|
+
if (httpStatus(viewErr) === 404)
|
|
272
|
+
return undefined;
|
|
273
|
+
throw viewErr;
|
|
274
|
+
}),
|
|
275
|
+
});
|
|
276
|
+
registeredFrom = recovery.prdPath;
|
|
277
|
+
if (!opts.json) {
|
|
278
|
+
console.error(`Work mesh: ${storyId} was not on the ${projectId} Board; registered the project from ${recovery.prdPath}.`);
|
|
279
|
+
}
|
|
280
|
+
patched = await patchStory(token, company.companyUid, projectId, storyId, patch);
|
|
281
|
+
}
|
|
214
282
|
}
|
|
215
283
|
catch (err) {
|
|
216
284
|
const conflict = storyConflictFromError(err);
|
|
@@ -219,7 +287,14 @@ async function runStory(opts) {
|
|
|
219
287
|
throw err;
|
|
220
288
|
}
|
|
221
289
|
if (opts.json) {
|
|
222
|
-
console.log(JSON.stringify({
|
|
290
|
+
console.log(JSON.stringify({
|
|
291
|
+
ok: true,
|
|
292
|
+
action: "story",
|
|
293
|
+
company,
|
|
294
|
+
...patched,
|
|
295
|
+
storyId,
|
|
296
|
+
...(registeredFrom ? { registeredFrom } : {}),
|
|
297
|
+
}, null, 2));
|
|
223
298
|
return;
|
|
224
299
|
}
|
|
225
300
|
const assignment = assignee === "none"
|
|
@@ -590,67 +665,13 @@ async function runProjectMemberAdd(projectId, personUid, opts) {
|
|
|
590
665
|
? `Work mesh: added ${personUid} to project ${projectId}`
|
|
591
666
|
: `Work mesh: ${personUid} is already a member of project ${projectId}`);
|
|
592
667
|
}
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
}
|
|
600
|
-
catch (err) {
|
|
601
|
-
if (!(err instanceof RejectedProjectStoryStatesError) || !opts.json)
|
|
602
|
-
throw err;
|
|
603
|
-
failProjectStoryStates(err.rejected, true);
|
|
604
|
-
return;
|
|
605
|
-
}
|
|
606
|
-
const { token, company } = await withCompany(opts);
|
|
607
|
-
let current;
|
|
608
|
-
try {
|
|
609
|
-
current = await getProjectView(token, company.companyUid, projectId);
|
|
610
|
-
}
|
|
611
|
-
catch (err) {
|
|
612
|
-
if (httpStatus(err) !== 404)
|
|
613
|
-
throw err;
|
|
614
|
-
}
|
|
615
|
-
if (opts.dryRun) {
|
|
616
|
-
if (current && (current.companyUid !== company.companyUid || current.projectId !== projectId)) {
|
|
617
|
-
fail("Work mesh project ensure dry-run: Board company or project id did not match the request.");
|
|
618
|
-
}
|
|
619
|
-
const stories = current ? mergeProjectStories(current.stories, source.stories) : source.stories;
|
|
620
|
-
const sourceStatuses = source.stories.map((story) => ({
|
|
621
|
-
id: story.id,
|
|
622
|
-
title: story.title,
|
|
623
|
-
status: boardStoryStatus(story),
|
|
624
|
-
}));
|
|
625
|
-
const boardStatuses = stories.map((story) => ({
|
|
626
|
-
id: story.id,
|
|
627
|
-
title: story.title,
|
|
628
|
-
status: boardStoryStatus(story),
|
|
629
|
-
}));
|
|
630
|
-
if (opts.json) {
|
|
631
|
-
console.log(JSON.stringify({
|
|
632
|
-
ok: source.rejectedStories.length === 0,
|
|
633
|
-
action: "project.ensure.dry-run",
|
|
634
|
-
projectId,
|
|
635
|
-
companyUid: company.companyUid,
|
|
636
|
-
sourceStatuses,
|
|
637
|
-
stories: boardStatuses,
|
|
638
|
-
...(source.rejectedStories.length ? { errors: projectStateErrors(source.rejectedStories) } : {}),
|
|
639
|
-
}, null, 2));
|
|
640
|
-
}
|
|
641
|
-
else {
|
|
642
|
-
console.log(`Work mesh: project ${projectId} dry run (${sourceStatuses.length} source stories)`);
|
|
643
|
-
console.log("Source statuses:");
|
|
644
|
-
for (const story of sourceStatuses)
|
|
645
|
-
console.log(`${story.id}\t${story.status}`);
|
|
646
|
-
console.log("Merged Board statuses:");
|
|
647
|
-
for (const story of boardStatuses)
|
|
648
|
-
console.log(`${story.id}\t${story.status}`);
|
|
649
|
-
}
|
|
650
|
-
if (source.rejectedStories.length)
|
|
651
|
-
process.exitCode = 1;
|
|
652
|
-
return;
|
|
653
|
-
}
|
|
668
|
+
/**
|
|
669
|
+
* Create or merge the project Board view from a parsed PRD, register the
|
|
670
|
+
* project, and verify every PRD story and repo is on the Board. Shared by
|
|
671
|
+
* `hq mesh project ensure` and the `hq mesh story` unknown-story recovery.
|
|
672
|
+
*/
|
|
673
|
+
async function ensureProjectFromSource(input) {
|
|
674
|
+
const { token, company, projectId, source, current } = input;
|
|
654
675
|
// Genesis thread, then the view, then register. Register returns 403
|
|
655
676
|
// PROJECT_NOT_IN_COMPANY until both exist. A missing view uses that chain
|
|
656
677
|
// from ensureProjectBoard; an existing view is merged, then registered.
|
|
@@ -726,12 +747,78 @@ async function runProjectEnsure(projectId, opts) {
|
|
|
726
747
|
if (verified.version === undefined) {
|
|
727
748
|
fail("Work mesh project ensure: verified view has no version.");
|
|
728
749
|
}
|
|
729
|
-
|
|
750
|
+
return {
|
|
730
751
|
projectId: verified.projectId,
|
|
731
752
|
threadId: registration.threadId,
|
|
732
753
|
channelId: registration.channelId,
|
|
733
754
|
version: verified.version,
|
|
734
755
|
stories: verified.stories,
|
|
756
|
+
};
|
|
757
|
+
}
|
|
758
|
+
async function runProjectEnsure(projectId, opts) {
|
|
759
|
+
if (!opts.storiesFile?.trim())
|
|
760
|
+
fail("`--stories-file <prd.json>` is required.");
|
|
761
|
+
let source;
|
|
762
|
+
try {
|
|
763
|
+
source = parseProjectPlan(readFileSync(opts.storiesFile, "utf8"), opts.storiesFile, { requireUserStories: true });
|
|
764
|
+
}
|
|
765
|
+
catch (err) {
|
|
766
|
+
if (!(err instanceof RejectedProjectStoryStatesError) || !opts.json)
|
|
767
|
+
throw err;
|
|
768
|
+
failProjectStoryStates(err.rejected, true);
|
|
769
|
+
return;
|
|
770
|
+
}
|
|
771
|
+
const { token, company } = await withCompany(opts);
|
|
772
|
+
let current;
|
|
773
|
+
try {
|
|
774
|
+
current = await getProjectView(token, company.companyUid, projectId);
|
|
775
|
+
}
|
|
776
|
+
catch (err) {
|
|
777
|
+
if (httpStatus(err) !== 404)
|
|
778
|
+
throw err;
|
|
779
|
+
}
|
|
780
|
+
if (opts.dryRun) {
|
|
781
|
+
if (current && (current.companyUid !== company.companyUid || current.projectId !== projectId)) {
|
|
782
|
+
fail("Work mesh project ensure dry-run: Board company or project id did not match the request.");
|
|
783
|
+
}
|
|
784
|
+
const stories = current ? mergeProjectStories(current.stories, source.stories) : source.stories;
|
|
785
|
+
const sourceStatuses = source.stories.map((story) => ({
|
|
786
|
+
id: story.id,
|
|
787
|
+
title: story.title,
|
|
788
|
+
status: boardStoryStatus(story),
|
|
789
|
+
}));
|
|
790
|
+
const boardStatuses = stories.map((story) => ({
|
|
791
|
+
id: story.id,
|
|
792
|
+
title: story.title,
|
|
793
|
+
status: boardStoryStatus(story),
|
|
794
|
+
}));
|
|
795
|
+
if (opts.json) {
|
|
796
|
+
console.log(JSON.stringify({
|
|
797
|
+
ok: source.rejectedStories.length === 0,
|
|
798
|
+
action: "project.ensure.dry-run",
|
|
799
|
+
projectId,
|
|
800
|
+
companyUid: company.companyUid,
|
|
801
|
+
sourceStatuses,
|
|
802
|
+
stories: boardStatuses,
|
|
803
|
+
...(source.rejectedStories.length ? { errors: projectStateErrors(source.rejectedStories) } : {}),
|
|
804
|
+
}, null, 2));
|
|
805
|
+
}
|
|
806
|
+
else {
|
|
807
|
+
console.log(`Work mesh: project ${projectId} dry run (${sourceStatuses.length} source stories)`);
|
|
808
|
+
console.log("Source statuses:");
|
|
809
|
+
for (const story of sourceStatuses)
|
|
810
|
+
console.log(`${story.id}\t${story.status}`);
|
|
811
|
+
console.log("Merged Board statuses:");
|
|
812
|
+
for (const story of boardStatuses)
|
|
813
|
+
console.log(`${story.id}\t${story.status}`);
|
|
814
|
+
}
|
|
815
|
+
if (source.rejectedStories.length)
|
|
816
|
+
process.exitCode = 1;
|
|
817
|
+
return;
|
|
818
|
+
}
|
|
819
|
+
const ensured = await ensureProjectFromSource({ token, company, projectId, source, current });
|
|
820
|
+
const payload = {
|
|
821
|
+
...ensured,
|
|
735
822
|
...(source.rejectedStories.length ? { ok: false, errors: projectStateErrors(source.rejectedStories) } : {}),
|
|
736
823
|
};
|
|
737
824
|
noteCallingSessionBound(payload.projectId, company);
|
|
@@ -2520,7 +2607,8 @@ export function registerMeshCommand(program, deps = {}) {
|
|
|
2520
2607
|
.requiredOption("--story <id>", "Story id, e.g. US-001")
|
|
2521
2608
|
.option("--status <status>", "queued|in_progress|review|done")
|
|
2522
2609
|
.option("--assignee <uid|me|none>", "Assign a person or agent uid, yourself, or clear")
|
|
2523
|
-
.option("--force", "Override another holder's assignment")
|
|
2610
|
+
.option("--force", "Override another holder's assignment")
|
|
2611
|
+
.option("--no-ensure", "Do not register the project from its local prd.json when the story is not on the Board")).action((opts) => wrap(() => runStory(opts))());
|
|
2524
2612
|
addSharedFlags(mesh
|
|
2525
2613
|
.command("claim")
|
|
2526
2614
|
.description("Assign a story to yourself and move it to in_progress")
|
package/dist/commands/reindex.js
CHANGED
|
@@ -33,6 +33,7 @@ import { reindex, rescue } from '@indigoai-us/hq-cloud';
|
|
|
33
33
|
import { trustHqRuntimeHooks } from '../utils/hook-trust.js';
|
|
34
34
|
import { findHqRoot } from '../utils/manifest.js';
|
|
35
35
|
import { guardLargeFiles } from '../utils/large-file-guard.js';
|
|
36
|
+
import { liveProcessCwds, recentTranscriptCwds, sessionOwnerReason, } from '../lib/core-utils/worktree-session-owner.js';
|
|
36
37
|
const HOOK_EVENTS = ['SessionStart', 'UserPromptSubmit', 'PreToolUse'];
|
|
37
38
|
const BYPASS_MODE = 'bypassPermissions';
|
|
38
39
|
const HOOK_CHECK_RELATIVE_PATH = path.join('core', 'scripts', 'check-hq-hooks.sh');
|
|
@@ -296,6 +297,34 @@ function hasRecentWorktreeActivity(worktree, gitDir, cutoff, deadline = Number.P
|
|
|
296
297
|
});
|
|
297
298
|
return latestContentChange(dirtyPaths) >= cutoff;
|
|
298
299
|
}
|
|
300
|
+
/**
|
|
301
|
+
* Why removing this worktree could lose work, or undefined when it is clean
|
|
302
|
+
* and every commit on HEAD is on its upstream and on a remote. A Git failure
|
|
303
|
+
* (including a deadline hit) is a reason too, so the worktree is retained.
|
|
304
|
+
*/
|
|
305
|
+
function unpreservedWorkReason(worktree, deadline) {
|
|
306
|
+
const status = runGit(worktree, ['status', '--porcelain', '--untracked-files=all', '--ignored=no'], deadline);
|
|
307
|
+
if (status?.status !== 0)
|
|
308
|
+
return 'git-error';
|
|
309
|
+
if (status.stdout.trim() !== '')
|
|
310
|
+
return 'dirty';
|
|
311
|
+
const upstream = runGit(worktree, ['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}'], deadline);
|
|
312
|
+
if (upstream === undefined)
|
|
313
|
+
return 'git-error';
|
|
314
|
+
if (upstream.status !== 0 || !upstream.stdout.trim())
|
|
315
|
+
return 'no-upstream';
|
|
316
|
+
const ahead = runGit(worktree, ['rev-list', '--count', '@{u}..HEAD'], deadline);
|
|
317
|
+
if (ahead?.status !== 0)
|
|
318
|
+
return 'git-error';
|
|
319
|
+
if (ahead.stdout.trim() !== '0')
|
|
320
|
+
return 'unpushed';
|
|
321
|
+
const offRemote = runGit(worktree, ['rev-list', '--count', 'HEAD', '--not', '--remotes'], deadline);
|
|
322
|
+
if (offRemote?.status !== 0)
|
|
323
|
+
return 'git-error';
|
|
324
|
+
if (offRemote.stdout.trim() !== '0')
|
|
325
|
+
return 'unpushed';
|
|
326
|
+
return undefined;
|
|
327
|
+
}
|
|
299
328
|
/**
|
|
300
329
|
* Remove inactive worktrees created by `hq worktree` without ever deleting
|
|
301
330
|
* directories directly. This runs on the hook path, so it uses a shallow
|
|
@@ -305,6 +334,11 @@ export function removeStaleHqWorktrees(hqRoot, now = Date.now(), deadline = Numb
|
|
|
305
334
|
if (!isHqRoot(hqRoot))
|
|
306
335
|
return;
|
|
307
336
|
const cutoff = now - WORKTREE_STALE_AFTER_MS;
|
|
337
|
+
const kept = (worktree, reason) => {
|
|
338
|
+
console.log(`reindex: kept stale worktree ${worktree} (${reason})`);
|
|
339
|
+
};
|
|
340
|
+
// Pass 1: cheap local checks. Only clean, pushed, idle worktrees go on.
|
|
341
|
+
const eligible = [];
|
|
308
342
|
for (const worktree of managedWorktreeCandidates(hqRoot)) {
|
|
309
343
|
if (Date.now() >= deadline)
|
|
310
344
|
break;
|
|
@@ -313,18 +347,59 @@ export function removeStaleHqWorktrees(hqRoot, now = Date.now(), deadline = Numb
|
|
|
313
347
|
const gitDir = linkedWorktreeGitDir(worktree, deadline);
|
|
314
348
|
if (!gitDir || hasRecentWorktreeActivity(worktree, gitDir, cutoff, deadline))
|
|
315
349
|
continue;
|
|
350
|
+
const unsafe = unpreservedWorkReason(worktree, deadline);
|
|
351
|
+
if (unsafe) {
|
|
352
|
+
kept(worktree, unsafe);
|
|
353
|
+
continue;
|
|
354
|
+
}
|
|
355
|
+
eligible.push(worktree);
|
|
356
|
+
}
|
|
357
|
+
if (eligible.length === 0)
|
|
358
|
+
return;
|
|
359
|
+
// Pass 2: session ownership. A session working in the worktree may have no
|
|
360
|
+
// process parked there between tool calls, so recent transcripts count too.
|
|
361
|
+
// A probe that cannot finish inside the budget retains everything.
|
|
362
|
+
const processCwds = liveProcessCwds(Math.max(1, Math.min(WORKTREE_GIT_TIMEOUT_MS, deadline - Date.now())));
|
|
363
|
+
const transcriptCwds = processCwds === undefined
|
|
364
|
+
? undefined
|
|
365
|
+
: recentTranscriptCwds({ nowMs: now, deadline });
|
|
366
|
+
if (processCwds === undefined || transcriptCwds === undefined) {
|
|
367
|
+
for (const worktree of eligible)
|
|
368
|
+
kept(worktree, 'session-check-incomplete');
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
371
|
+
for (const worktree of eligible) {
|
|
372
|
+
if (Date.now() >= deadline)
|
|
373
|
+
break;
|
|
374
|
+
let real = worktree;
|
|
375
|
+
try {
|
|
376
|
+
real = fs.realpathSync(worktree);
|
|
377
|
+
}
|
|
378
|
+
catch { /* compare the literal spelling */ }
|
|
379
|
+
const owner = sessionOwnerReason(processCwds, transcriptCwds, worktree, real);
|
|
380
|
+
if (owner) {
|
|
381
|
+
kept(worktree, owner);
|
|
382
|
+
continue;
|
|
383
|
+
}
|
|
316
384
|
// Narrow the destructive TOCTOU window: re-resolve the linked-worktree
|
|
317
|
-
// identity and re-measure activity immediately before
|
|
318
|
-
// it. Git remains the only component that deletes the
|
|
385
|
+
// identity and re-measure activity and preservation immediately before
|
|
386
|
+
// asking Git to remove it. Git remains the only component that deletes the
|
|
387
|
+
// directory.
|
|
319
388
|
const confirmedGitDir = linkedWorktreeGitDir(worktree, deadline);
|
|
320
389
|
if (!confirmedGitDir || hasRecentWorktreeActivity(worktree, confirmedGitDir, cutoff, deadline))
|
|
321
390
|
continue;
|
|
391
|
+
const confirmedUnsafe = unpreservedWorkReason(worktree, deadline);
|
|
392
|
+
if (confirmedUnsafe) {
|
|
393
|
+
kept(worktree, confirmedUnsafe);
|
|
394
|
+
continue;
|
|
395
|
+
}
|
|
322
396
|
// Recursive removal is not transactional. Only start it inside the sweep
|
|
323
397
|
// budget, then let Git finish so a timeout cannot leave a registered,
|
|
324
|
-
// partially deleted worktree behind.
|
|
325
|
-
|
|
398
|
+
// partially deleted worktree behind. No --force: Git itself refuses a
|
|
399
|
+
// worktree that gained changes since the checks above.
|
|
400
|
+
const removal = runGit(worktree, ['worktree', 'remove', worktree], deadline, true);
|
|
326
401
|
if (removal?.status === 0) {
|
|
327
|
-
console.log(`reindex: removed stale worktree ${worktree} (
|
|
402
|
+
console.log(`reindex: removed stale worktree ${worktree} (clean and pushed; branch kept)`);
|
|
328
403
|
continue;
|
|
329
404
|
}
|
|
330
405
|
const reason = removal?.stderr.trim() || removal?.stdout.trim() || 'Git could not remove it';
|
|
@@ -8,13 +8,18 @@
|
|
|
8
8
|
* uploads each blob, verifies size + sha256 + git blob id, then hands only
|
|
9
9
|
* verified blobs to core `hq-storage.sh strip-large`, which rewrites history
|
|
10
10
|
* to the pointer text. Working-tree files are never touched.
|
|
11
|
-
* - Current files (idle big files in the working
|
|
12
|
-
* the
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
11
|
+
* - Current files (`--current [paths...]`, idle big files in the working
|
|
12
|
+
* tree). Each file goes to the vault that owns its path. A file under
|
|
13
|
+
* `companies/<slug>/` is never uploaded: when HQ sync already stored this
|
|
14
|
+
* exact version in that company's vault (journal hash + size, and the vault
|
|
15
|
+
* object still has the journal's ETag), the file is replaced by a
|
|
16
|
+
* placeholder pointing at that object. Otherwise it is skipped with "run
|
|
17
|
+
* hq sync first", so sync stays the only writer of company vault keys. Any
|
|
18
|
+
* other file points at its personal-vault sync object the same way, or is
|
|
19
|
+
* uploaded to the personal vault's `.hq-offload/blobs/`. Current-file
|
|
20
|
+
* offload runs only when the installed HQ sync holds every `.hqcloud`
|
|
21
|
+
* sibling (hq-cloud CLOUD_PLACEHOLDER_HOLDS_ANY_SIBLING) and skips
|
|
22
|
+
* `.hq-offload/`; otherwise it reports "update HQ sync".
|
|
18
23
|
*
|
|
19
24
|
* Fetch also reads placeholders that HQ sync rewrote after a teammate changed
|
|
20
25
|
* the file (`vault: company`, the company vault key, `sha256: unknown`,
|
|
@@ -24,9 +29,10 @@
|
|
|
24
29
|
* which writes the caller's own personal-vault copy to a file outside HQ so
|
|
25
30
|
* it can never sync back.
|
|
26
31
|
*
|
|
27
|
-
* Tenant isolation:
|
|
28
|
-
* personal store refuses a company vault bucket
|
|
29
|
-
*
|
|
32
|
+
* Tenant isolation: uploads only ever go to the caller's personal vault, and
|
|
33
|
+
* the personal store refuses a company vault bucket. A company vault is opened
|
|
34
|
+
* read-only (HEAD and ETag-pinned GET), and only for a company the caller is
|
|
35
|
+
* an active member of.
|
|
30
36
|
*/
|
|
31
37
|
import { GetObjectCommand, HeadObjectCommand, PutObjectCommand } from "@aws-sdk/client-s3";
|
|
32
38
|
import type { ScriptRunner } from "./storage.js";
|
|
@@ -37,14 +43,10 @@ export declare const POINTER_HEADER = "HQ-CLOUD-FILE v1";
|
|
|
37
43
|
export declare const POINTER_FOOTER = "This file is stored in HQ cloud storage. Open it in HQ, or run the command above to download it.";
|
|
38
44
|
export declare const DEFAULT_MIN_BYTES: number;
|
|
39
45
|
export declare const DEFAULT_IDLE_DAYS = 14;
|
|
40
|
-
export declare const CURRENT_BLOCKED_REASON = "Offloading current files is not available yet: HQ sync would treat the replaced file as deleted.";
|
|
41
|
-
/**
|
|
42
|
-
* Gate for `--current`. Stays false until the owner enables current-file
|
|
43
|
-
* offload on top of hq-cloud's cloud-only placeholders (PR #919, 6.18.61).
|
|
44
|
-
*/
|
|
45
|
-
export declare const CURRENT_OFFLOAD_ENABLED = false;
|
|
46
46
|
export declare const ORPHANED_MESSAGE = "This file was deleted by a teammate. It's no longer in the cloud for your team.";
|
|
47
47
|
export declare const UPDATE_SYNC = "update HQ sync";
|
|
48
|
+
/** Reason on company files HQ sync has not stored in the company vault yet. */
|
|
49
|
+
export declare const RUN_SYNC_FIRST = "run hq sync first";
|
|
48
50
|
/** Top-level HQ-root folder that holds offloaded blobs in the personal vault. */
|
|
49
51
|
export declare const OFFLOAD_TOP_LEVEL = ".hq-offload";
|
|
50
52
|
/**
|
|
@@ -56,7 +58,7 @@ export declare const OFFLOAD_TOP_LEVEL = ".hq-offload";
|
|
|
56
58
|
* @indigoai-us/hq-cloud 6.18.61 (PR #919) lists ".hq-offload" in
|
|
57
59
|
* PERSONAL_VAULT_EXCLUDED_TOP_LEVEL; storage.ts passes that export here. The
|
|
58
60
|
* check stays at runtime so an older bundled HQ sync still reports
|
|
59
|
-
* "update HQ sync".
|
|
61
|
+
* "update HQ sync". Current-file offload also needs syncHoldsPlaceholders.
|
|
60
62
|
*/
|
|
61
63
|
export declare function syncExcludesOffload(excludedTopLevel: readonly string[] | undefined): boolean;
|
|
62
64
|
export declare function offloadKey(gitBlobSha: string): string;
|
|
@@ -118,11 +120,16 @@ export interface OffloadS3Client {
|
|
|
118
120
|
export declare function s3BlobStore(bucket: string, s3: OffloadS3Client): BlobStore;
|
|
119
121
|
/** Read-only access to vault objects at their sync keys, pinned to an ETag. */
|
|
120
122
|
export interface VaultObjectStore {
|
|
123
|
+
/** Size and unquoted ETag of `key`, or null when it is not there. */
|
|
124
|
+
head(key: string): Promise<{
|
|
125
|
+
size: number;
|
|
126
|
+
etag: string;
|
|
127
|
+
} | null>;
|
|
121
128
|
/** Download `key` to `dest` only if its ETag is `etag`; returns the served ETag. */
|
|
122
129
|
download(key: string, dest: string, etag: string): Promise<string>;
|
|
123
130
|
}
|
|
124
131
|
export interface VaultObjectS3Client {
|
|
125
|
-
send(cmd: GetObjectCommand): Promise<unknown>;
|
|
132
|
+
send(cmd: GetObjectCommand | HeadObjectCommand): Promise<unknown>;
|
|
126
133
|
}
|
|
127
134
|
export declare function s3VaultObjectStore(bucket: string, s3: VaultObjectS3Client): VaultObjectStore;
|
|
128
135
|
export interface OffloadDeps {
|
|
@@ -152,7 +159,40 @@ export interface OffloadDeps {
|
|
|
152
159
|
vault: "company";
|
|
153
160
|
slug: string;
|
|
154
161
|
}) => Promise<VaultObjectStore>;
|
|
162
|
+
/**
|
|
163
|
+
* Whether the installed HQ sync treats every `x.hqcloud` sibling as a hold
|
|
164
|
+
* on `x` in push and pull (hq-cloud CLOUD_PLACEHOLDER_HOLDS_ANY_SIBLING).
|
|
165
|
+
* Current-file offload stays off unless this returns true.
|
|
166
|
+
*/
|
|
167
|
+
syncHoldsPlaceholders?: () => boolean;
|
|
168
|
+
/** What HQ sync last recorded for `target` (its journal entry), or null. */
|
|
169
|
+
syncRecord?: (target: OffloadTarget) => Promise<SyncRecord | null>;
|
|
170
|
+
/** File operations for current-file offload (tests inject to see the order). */
|
|
171
|
+
placeholderFs?: PlaceholderFs;
|
|
155
172
|
}
|
|
173
|
+
/** The vault that owns a working-tree path, and the object key there. */
|
|
174
|
+
export type OffloadTarget = {
|
|
175
|
+
vault: "personal";
|
|
176
|
+
key: string;
|
|
177
|
+
} | {
|
|
178
|
+
vault: "company";
|
|
179
|
+
slug: string;
|
|
180
|
+
key: string;
|
|
181
|
+
};
|
|
182
|
+
/** A sync journal entry: the version HQ sync last pushed or pulled. */
|
|
183
|
+
export interface SyncRecord {
|
|
184
|
+
/** Hex sha256 of the synced bytes. */
|
|
185
|
+
hash?: string;
|
|
186
|
+
size: number;
|
|
187
|
+
remoteEtag?: string;
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* `companies/<slug>/<rest>` belongs to that company's vault at key `<rest>`.
|
|
191
|
+
* Every other path belongs to the personal vault at its HQ-root-relative path.
|
|
192
|
+
*/
|
|
193
|
+
export declare function resolveOffloadTarget(rel: string): OffloadTarget;
|
|
194
|
+
/** Whether current-file offload can run with the installed HQ sync. */
|
|
195
|
+
export declare function currentSyncReady(deps: Pick<OffloadDeps, "syncExcludesOffload" | "syncHoldsPlaceholders">): boolean;
|
|
156
196
|
export declare function storageScriptPath(hqRoot: string): string;
|
|
157
197
|
/** Whether the installed core script has a `case` arm for `sub`. */
|
|
158
198
|
export declare function scriptSupports(hqRoot: string, sub: "large" | "strip-large"): boolean;
|
|
@@ -215,17 +255,34 @@ export interface PlaceholderFs {
|
|
|
215
255
|
}
|
|
216
256
|
export declare const durablePlaceholderFs: PlaceholderFs;
|
|
217
257
|
/**
|
|
218
|
-
* Offload one current file: upload and verify,
|
|
219
|
-
* and only then remove `x`. With the placeholder
|
|
220
|
-
* watcher never sees `x` missing without its
|
|
221
|
-
*
|
|
222
|
-
*
|
|
223
|
-
* Not called while CURRENT_OFFLOAD_ENABLED is false.
|
|
258
|
+
* Offload one personal-scope current file by upload: upload and verify,
|
|
259
|
+
* write `x.hqcloud` (fsynced), and only then remove `x`. With the placeholder
|
|
260
|
+
* written first, a sync watcher never sees `x` missing without its
|
|
261
|
+
* placeholder. Company files never take this path (see pointCurrentFileAtVault).
|
|
224
262
|
*/
|
|
225
263
|
export declare function offloadCurrentFile(rel: string, store: BlobStore, deps: Pick<OffloadDeps, "hqRoot" | "now">, fsOps?: PlaceholderFs): Promise<{
|
|
226
264
|
path: string;
|
|
227
265
|
bytes: number;
|
|
228
266
|
}>;
|
|
267
|
+
/** One current file in a `--current` plan (dry run and real run). */
|
|
268
|
+
export interface CurrentFilePlan {
|
|
269
|
+
path: string;
|
|
270
|
+
bytes: number;
|
|
271
|
+
idle_days: number;
|
|
272
|
+
vault: "personal" | "company";
|
|
273
|
+
company?: string;
|
|
274
|
+
/** HQ sync already stored this exact version in the owning vault. */
|
|
275
|
+
synced: boolean;
|
|
276
|
+
action: "point-at-vault" | "upload" | "skip";
|
|
277
|
+
reason?: string;
|
|
278
|
+
}
|
|
279
|
+
export interface CurrentOffloaded {
|
|
280
|
+
path: string;
|
|
281
|
+
bytes: number;
|
|
282
|
+
vault: "personal" | "company";
|
|
283
|
+
company?: string;
|
|
284
|
+
action: "point-at-vault" | "upload";
|
|
285
|
+
}
|
|
229
286
|
export interface OffloadOptions {
|
|
230
287
|
minBytes: number;
|
|
231
288
|
idleDays: number;
|
|
@@ -233,12 +290,16 @@ export interface OffloadOptions {
|
|
|
233
290
|
current: boolean;
|
|
234
291
|
dryRun: boolean;
|
|
235
292
|
yes: boolean;
|
|
293
|
+
/** With `current`: offload files at or under these HQ paths instead of the large-file list. */
|
|
294
|
+
paths?: string[];
|
|
236
295
|
}
|
|
237
296
|
export interface OffloadResult {
|
|
238
297
|
history: {
|
|
239
298
|
uploaded: number;
|
|
240
299
|
bytes: number;
|
|
241
300
|
freed_bytes: number;
|
|
301
|
+
/** True when this run did not ask for history. */
|
|
302
|
+
skipped?: boolean;
|
|
242
303
|
available?: boolean;
|
|
243
304
|
reason?: string;
|
|
244
305
|
candidates?: number;
|
|
@@ -273,13 +334,17 @@ export interface OffloadResult {
|
|
|
273
334
|
} | null;
|
|
274
335
|
};
|
|
275
336
|
current: {
|
|
276
|
-
offloaded:
|
|
277
|
-
path: string;
|
|
278
|
-
bytes: number;
|
|
279
|
-
}[];
|
|
337
|
+
offloaded: CurrentOffloaded[];
|
|
280
338
|
freed_bytes: number;
|
|
281
339
|
available?: boolean;
|
|
282
340
|
reason?: string;
|
|
341
|
+
/** Every candidate with its target vault, sync state, and action. */
|
|
342
|
+
files?: CurrentFilePlan[];
|
|
343
|
+
/** True when this run did not ask for current files. */
|
|
344
|
+
skipped?: boolean;
|
|
345
|
+
/** Company files skipped because HQ sync has not stored this version yet. */
|
|
346
|
+
unsynced?: string[];
|
|
347
|
+
message?: string;
|
|
283
348
|
};
|
|
284
349
|
errors: string[];
|
|
285
350
|
dry_run: boolean;
|