@indigoai-us/hq-cli 5.228.4 → 5.229.1
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 +19 -0
- package/dist/commands/lanes.js +151 -9
- package/dist/lib/checkpoint-ontology/hints.d.ts +41 -0
- package/dist/lib/checkpoint-ontology/hints.js +146 -0
- package/dist/lib/checkpoint-ontology/plan.d.ts +11 -0
- package/dist/lib/checkpoint-ontology/plan.js +34 -3
- package/dist/lib/lanes/index.d.ts +1 -1
- package/dist/lib/lanes/outpost-dispatch.d.ts +1 -0
- package/dist/lib/lanes/outpost-dispatch.js +1 -0
- package/dist/lib/lanes/outpost-profile-repin.d.ts +17 -0
- package/dist/lib/lanes/outpost-profile-repin.js +84 -0
- package/dist/lib/lanes/store.js +28 -0
- package/dist/lib/lanes/types.d.ts +10 -0
- package/dist/lib/lanes/types.js +1 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,25 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [5.229.1] — 2026-09-27
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- An authorized `hq lanes resume --worker` on an Outpost re-pins a changed
|
|
10
|
+
worker profile before spawn, preserves the provider session, records the
|
|
11
|
+
hash change in lane history, and syncs the resulting lane record to primary.
|
|
12
|
+
|
|
13
|
+
## [5.229.0] — 2026-09-27
|
|
14
|
+
|
|
15
|
+
### Added
|
|
16
|
+
|
|
17
|
+
- Behind the off-by-default `checkpoint.ontology.entity-hints` flag, each
|
|
18
|
+
knowledge input in the checkpoint ontology plan lists up to 25 existing
|
|
19
|
+
entities whose name appears in the text the model reads
|
|
20
|
+
(`known_entities`). The model is told to use those names instead of naming a
|
|
21
|
+
new entity, and to skip glossary or translation table rows, paths and names
|
|
22
|
+
built from tags or slugs.
|
|
23
|
+
|
|
5
24
|
## [5.228.4] — 2026-09-27
|
|
6
25
|
|
|
7
26
|
### Fixed
|
package/dist/commands/lanes.js
CHANGED
|
@@ -25,6 +25,7 @@ import { readLanesRegistryFlag } from "../lib/lanes/registry-flag.js";
|
|
|
25
25
|
import { resolveProcessFlagGate } from "../lib/flag-registry.js";
|
|
26
26
|
import { defaultTrackerActor, LANES_TRACKER_FLAG, LANES_TRACKER_MESH_FLAG, registerLaneTrackerCommands, } from "./lanes-tracker.js";
|
|
27
27
|
import { createOutpostHireGrantContext, parseOutpostHireGrantContext, sameHireGrantSenior, } from "../lib/lanes/hire-grant-dispatch.js";
|
|
28
|
+
import { createOutpostProfileRepinContext, parseOutpostProfileRepinContext, } from "../lib/lanes/outpost-profile-repin.js";
|
|
28
29
|
import { OUTPOST_DIR, OUTPOST_ID_PATH, readOutpostIdentity, resolveAdmissionHost, } from "../lib/lanes/admission-host.js";
|
|
29
30
|
export { OUTPOST_DIR, OUTPOST_ID_PATH, readOutpostIdentity } from "../lib/lanes/admission-host.js";
|
|
30
31
|
import { buildOutpostCapacityCommand, buildOutpostBalanceMoveCommand, buildOutpostIdentityBackfillCommand, buildOutpostLaneCommand, buildOutpostMoveTargetCommand, buildOutpostQuestionAnswerCommand, buildOutpostQuestionListCommand, buildOutpostQuestionShowCommand, buildOutpostPickupCommand, createOutpostsClient, dispatchErrorMessage, dispatchOutpostPickup, buildOutpostLaneListCommand, outpostRejectedEffortOption, parseOutpostPickupPayload, stageOutpostPayload, } from "../lib/lanes/outpost-dispatch.js";
|
|
@@ -3564,6 +3565,10 @@ function parseRemoteJson(stdout, surface) {
|
|
|
3564
3565
|
}
|
|
3565
3566
|
return null;
|
|
3566
3567
|
}
|
|
3568
|
+
function outpostRejectedProfileRepinOption(result) {
|
|
3569
|
+
return result.exitCode !== 0 &&
|
|
3570
|
+
/unknown option\s+(?:['"])?--profile-repin-context(?:['"])?(?:\s|$)/i.test(`${result.stderr}\n${result.stdout}`);
|
|
3571
|
+
}
|
|
3567
3572
|
function verifyOutpostProfileContent(hqRoot, lane) {
|
|
3568
3573
|
if (lane.host.kind !== "outpost" || lane.worker_profile === undefined)
|
|
3569
3574
|
return;
|
|
@@ -3579,6 +3584,89 @@ function verifyOutpostProfileContent(hqRoot, lane) {
|
|
|
3579
3584
|
});
|
|
3580
3585
|
}
|
|
3581
3586
|
}
|
|
3587
|
+
function resumeProfileRepinActor(ctx) {
|
|
3588
|
+
const laneId = ctx.env.HQ_LANE_ID?.trim();
|
|
3589
|
+
if (laneId)
|
|
3590
|
+
return `lane:${laneId}`;
|
|
3591
|
+
const sessionId = sessionIdFromEnv(ctx.env);
|
|
3592
|
+
return sessionId ? `session:${sessionId}` : "ssm-owner-dispatch";
|
|
3593
|
+
}
|
|
3594
|
+
function workerProfileHashMismatch(lane, profile) {
|
|
3595
|
+
return new LanesError("worker_profile_hash_mismatch", `Worker profile ${JSON.stringify(profile.id)} on Outpost ${lane.host.id} does not match the hash recorded by the lane`, {
|
|
3596
|
+
lane_id: lane.lane_id,
|
|
3597
|
+
worker: profile.id,
|
|
3598
|
+
outpost: lane.host.kind === "outpost" ? lane.host.id : null,
|
|
3599
|
+
expected_hash: lane.worker_profile_hash ?? null,
|
|
3600
|
+
actual_hash: profile.profile.contentHash,
|
|
3601
|
+
});
|
|
3602
|
+
}
|
|
3603
|
+
function authorizeResumeProfileRepin(ctx, lane, profile, encodedContext) {
|
|
3604
|
+
const ssmOwnerDispatch = ctx.isSsmOwnerDispatch();
|
|
3605
|
+
if (encodedContext !== undefined && !ssmOwnerDispatch) {
|
|
3606
|
+
throw profile === undefined
|
|
3607
|
+
? new LanesError("worker_profile_hash_mismatch", "Profile re-pin context is only valid on an Outpost owner dispatch", { lane_id: lane.lane_id })
|
|
3608
|
+
: workerProfileHashMismatch(lane, profile);
|
|
3609
|
+
}
|
|
3610
|
+
let context;
|
|
3611
|
+
if (encodedContext !== undefined) {
|
|
3612
|
+
try {
|
|
3613
|
+
context = parseOutpostProfileRepinContext(encodedContext, lane.lane_id);
|
|
3614
|
+
}
|
|
3615
|
+
catch {
|
|
3616
|
+
throw profile === undefined
|
|
3617
|
+
? new LanesError("worker_profile_hash_mismatch", "Profile re-pin context is invalid", { lane_id: lane.lane_id })
|
|
3618
|
+
: workerProfileHashMismatch(lane, profile);
|
|
3619
|
+
}
|
|
3620
|
+
if (lane.host.kind !== "outpost" ||
|
|
3621
|
+
!sameHireGrantSenior(lane.senior, context.expected_senior)) {
|
|
3622
|
+
throw profile === undefined
|
|
3623
|
+
? new LanesError("worker_profile_hash_mismatch", "Lane senior changed after profile re-pin authorization", { lane_id: lane.lane_id })
|
|
3624
|
+
: workerProfileHashMismatch(lane, profile);
|
|
3625
|
+
}
|
|
3626
|
+
}
|
|
3627
|
+
if (profile === undefined ||
|
|
3628
|
+
lane.host.kind !== "outpost" ||
|
|
3629
|
+
lane.worker_profile_hash === profile.profile.contentHash) {
|
|
3630
|
+
return undefined;
|
|
3631
|
+
}
|
|
3632
|
+
if (ssmOwnerDispatch) {
|
|
3633
|
+
if (context === undefined)
|
|
3634
|
+
throw workerProfileHashMismatch(lane, profile);
|
|
3635
|
+
return {
|
|
3636
|
+
kind: "ssm-owner-dispatch",
|
|
3637
|
+
expectedSenior: context.expected_senior,
|
|
3638
|
+
actor: context.actor,
|
|
3639
|
+
reason: context.reason,
|
|
3640
|
+
newHash: profile.profile.contentHash,
|
|
3641
|
+
};
|
|
3642
|
+
}
|
|
3643
|
+
if (context !== undefined || !callerMatchesSenior(ctx, lane.senior)) {
|
|
3644
|
+
throw workerProfileHashMismatch(lane, profile);
|
|
3645
|
+
}
|
|
3646
|
+
return {
|
|
3647
|
+
kind: "senior",
|
|
3648
|
+
expectedSenior: lane.senior,
|
|
3649
|
+
actor: resumeProfileRepinActor(ctx),
|
|
3650
|
+
reason: "resume --worker",
|
|
3651
|
+
newHash: profile.profile.contentHash,
|
|
3652
|
+
};
|
|
3653
|
+
}
|
|
3654
|
+
function assertResumeProfileRepinStillAuthorized(ctx, current, authorization) {
|
|
3655
|
+
const authorized = current.host.kind === "outpost" &&
|
|
3656
|
+
sameHireGrantSenior(current.senior, authorization.expectedSenior) &&
|
|
3657
|
+
(authorization.kind === "ssm-owner-dispatch"
|
|
3658
|
+
? ctx.isSsmOwnerDispatch()
|
|
3659
|
+
: callerMatchesSenior(ctx, current.senior));
|
|
3660
|
+
if (!authorized) {
|
|
3661
|
+
throw new LanesError("worker_profile_hash_mismatch", `Lane ${current.lane_id}'s senior changed before its Outpost profile could be re-pinned`, {
|
|
3662
|
+
lane_id: current.lane_id,
|
|
3663
|
+
expected_senior: authorization.expectedSenior,
|
|
3664
|
+
current_senior: current.senior,
|
|
3665
|
+
expected_hash: current.worker_profile_hash ?? null,
|
|
3666
|
+
actual_hash: authorization.newHash,
|
|
3667
|
+
});
|
|
3668
|
+
}
|
|
3669
|
+
}
|
|
3582
3670
|
function laneIsHostedHere(ctx, lane) {
|
|
3583
3671
|
if (lane.host.kind !== "outpost")
|
|
3584
3672
|
return true;
|
|
@@ -3603,6 +3691,15 @@ async function dispatchOutpostLaneCommand(ctx, lane, action, options) {
|
|
|
3603
3691
|
throw new LanesError("outpost_dispatch_failed", `Lane ${lane.lane_id} has no Outpost host to dispatch to`, { lane_id: lane.lane_id });
|
|
3604
3692
|
}
|
|
3605
3693
|
const outpostId = lane.host.id;
|
|
3694
|
+
const profileRepinContext = action === "resume" &&
|
|
3695
|
+
options.worker !== undefined &&
|
|
3696
|
+
callerMatchesSenior(ctx, lane.senior)
|
|
3697
|
+
? createOutpostProfileRepinContext({
|
|
3698
|
+
laneId: lane.lane_id,
|
|
3699
|
+
expectedSenior: lane.senior,
|
|
3700
|
+
actor: resumeProfileRepinActor(ctx),
|
|
3701
|
+
})
|
|
3702
|
+
: undefined;
|
|
3606
3703
|
let grantContext;
|
|
3607
3704
|
let grantHierarchy;
|
|
3608
3705
|
if (action === "grant") {
|
|
@@ -3618,7 +3715,7 @@ async function dispatchOutpostLaneCommand(ctx, lane, action, options) {
|
|
|
3618
3715
|
}
|
|
3619
3716
|
let dispatched;
|
|
3620
3717
|
try {
|
|
3621
|
-
|
|
3718
|
+
const dispatchCommand = (repinContext) => ctx.dispatchOutpostExec({
|
|
3622
3719
|
outpostId,
|
|
3623
3720
|
command: buildOutpostLaneCommand({
|
|
3624
3721
|
action: action === "grant"
|
|
@@ -3642,9 +3739,15 @@ async function dispatchOutpostLaneCommand(ctx, lane, action, options) {
|
|
|
3642
3739
|
senior: options.senior,
|
|
3643
3740
|
reason: options.reason,
|
|
3644
3741
|
grantContext,
|
|
3742
|
+
...(repinContext !== undefined ? { profileRepinContext: repinContext } : {}),
|
|
3645
3743
|
compact: options.compact,
|
|
3646
3744
|
}),
|
|
3647
3745
|
});
|
|
3746
|
+
dispatched = await dispatchCommand(profileRepinContext);
|
|
3747
|
+
if (profileRepinContext !== undefined &&
|
|
3748
|
+
outpostRejectedProfileRepinOption(dispatched)) {
|
|
3749
|
+
dispatched = await dispatchCommand();
|
|
3750
|
+
}
|
|
3648
3751
|
}
|
|
3649
3752
|
catch (err) {
|
|
3650
3753
|
throw new LanesError("outpost_dispatch_failed", `Could not dispatch ${action} for lane ${lane.lane_id} to Outpost ${outpostId}: ${dispatchErrorMessage(err)}`, { lane_id: lane.lane_id, outpost_id: outpostId });
|
|
@@ -3663,6 +3766,14 @@ async function dispatchOutpostLaneCommand(ctx, lane, action, options) {
|
|
|
3663
3766
|
});
|
|
3664
3767
|
}
|
|
3665
3768
|
if (ownerError) {
|
|
3769
|
+
if (action === "resume" && ownerError.error === "worker_profile_hash_mismatch") {
|
|
3770
|
+
throw new LanesError(ownerError.error, ownerError.message, {
|
|
3771
|
+
...ownerError.details,
|
|
3772
|
+
lane_id: lane.lane_id,
|
|
3773
|
+
outpost_id: outpostId,
|
|
3774
|
+
command_id: dispatched.commandId,
|
|
3775
|
+
});
|
|
3776
|
+
}
|
|
3666
3777
|
throw new LanesError("outpost_dispatch_failed", `Outpost ${outpostId} rejected the ${action} command: ${ownerError.message}`, {
|
|
3667
3778
|
lane_id: lane.lane_id,
|
|
3668
3779
|
outpost_id: outpostId,
|
|
@@ -3708,8 +3819,9 @@ async function dispatchOutpostLaneCommand(ctx, lane, action, options) {
|
|
|
3708
3819
|
: action === "reparent"
|
|
3709
3820
|
? remote.lane !== null && typeof remote.lane === "object" &&
|
|
3710
3821
|
Array.isArray(remote.changed_descendant_ids)
|
|
3711
|
-
: remote.delivery_status === "worker_spawned" ||
|
|
3712
|
-
remote.delivery_status === "queued_on_target_account")
|
|
3822
|
+
: (remote.delivery_status === "worker_spawned" ||
|
|
3823
|
+
remote.delivery_status === "queued_on_target_account") &&
|
|
3824
|
+
(options.worker === undefined || remote.lane !== undefined));
|
|
3713
3825
|
if (!resultConfirmed) {
|
|
3714
3826
|
throw new LanesError("outpost_delivery_unconfirmed", `Outpost ${outpostId} completed exec without a confirmed ${action} result for lane ${lane.lane_id}`, {
|
|
3715
3827
|
lane_id: lane.lane_id,
|
|
@@ -3730,19 +3842,22 @@ async function dispatchOutpostLaneCommand(ctx, lane, action, options) {
|
|
|
3730
3842
|
});
|
|
3731
3843
|
return;
|
|
3732
3844
|
}
|
|
3733
|
-
if (action === "reparent") {
|
|
3845
|
+
if (action === "reparent" || (action === "resume" && options.worker !== undefined)) {
|
|
3734
3846
|
const ownerLane = syncLaneRecordFromOwner(ctx.hqRoot(), lane.lane_id, outpostId, remote.lane, undefined, { allowNonTerminal: true });
|
|
3735
3847
|
const descendants = [];
|
|
3736
|
-
|
|
3737
|
-
const
|
|
3738
|
-
|
|
3848
|
+
if (action === "reparent") {
|
|
3849
|
+
for (const descendantId of changedDescendantIds) {
|
|
3850
|
+
const ownerDescendant = await readOutpostLaneSnapshot(ctx, outpostId, descendantId);
|
|
3851
|
+
descendants.push(syncLaneRecordFromOwner(ctx.hqRoot(), descendantId, outpostId, ownerDescendant, undefined, { allowNonTerminal: true }));
|
|
3852
|
+
}
|
|
3739
3853
|
}
|
|
3740
3854
|
printJson({
|
|
3741
3855
|
...remote,
|
|
3742
3856
|
lane_id: ownerLane.lane_id,
|
|
3743
3857
|
lane: ownerLane,
|
|
3744
|
-
|
|
3745
|
-
|
|
3858
|
+
...(action === "reparent"
|
|
3859
|
+
? { changed_descendant_ids: changedDescendantIds, descendants }
|
|
3860
|
+
: {}),
|
|
3746
3861
|
outpost_id: outpostId,
|
|
3747
3862
|
outpost_command_id: dispatched.commandId,
|
|
3748
3863
|
outpost_transport: "outposts_exec",
|
|
@@ -5248,6 +5363,18 @@ function applyResumeProfileAndEffort(current, next, input) {
|
|
|
5248
5363
|
next.worker_pack = input.profile.pack;
|
|
5249
5364
|
next.worker_profile = profile.id;
|
|
5250
5365
|
next.worker_profile_hash = profile.contentHash;
|
|
5366
|
+
if (input.profileRepin !== undefined &&
|
|
5367
|
+
current.host.kind === "outpost" &&
|
|
5368
|
+
current.worker_profile_hash !== profile.contentHash) {
|
|
5369
|
+
const entry = {
|
|
5370
|
+
old_hash: current.worker_profile_hash ?? null,
|
|
5371
|
+
new_hash: profile.contentHash,
|
|
5372
|
+
actor: input.profileRepin.actor,
|
|
5373
|
+
at: input.at ?? new Date().toISOString(),
|
|
5374
|
+
reason: input.profileRepin.reason,
|
|
5375
|
+
};
|
|
5376
|
+
next.worker_profile_repin_history = appendCappedHistory(current.worker_profile_repin_history ?? [], entry);
|
|
5377
|
+
}
|
|
5251
5378
|
next.worker_profile_ignored = ignoredWorkerProfileFields(current.provider);
|
|
5252
5379
|
next.worker_policy_delivery = undefined;
|
|
5253
5380
|
if (profile.meta.keep_alive !== undefined) {
|
|
@@ -5308,6 +5435,9 @@ function recordResumeSetup(ctx, lane, input) {
|
|
|
5308
5435
|
if (current.version !== lane.version) {
|
|
5309
5436
|
throw new LanesError("lane_resume_setup_conflict", `Lane ${lane.lane_id} changed while resume settings were prepared; run the resume again`, { lane_id: lane.lane_id, prepared_version: lane.version, version: current.version });
|
|
5310
5437
|
}
|
|
5438
|
+
if (input.profileRepin !== undefined) {
|
|
5439
|
+
assertResumeProfileRepinStillAuthorized(ctx, current, input.profileRepin);
|
|
5440
|
+
}
|
|
5311
5441
|
const next = { ...current };
|
|
5312
5442
|
applyResumeProfileAndEffort(current, next, { ...input, at: ctx.now().toISOString() });
|
|
5313
5443
|
if (input.cliModel !== undefined) {
|
|
@@ -5525,6 +5655,9 @@ async function prepareAccountMove(ctx, lane, input) {
|
|
|
5525
5655
|
if (current.version !== lane.version) {
|
|
5526
5656
|
throw new LanesError("account_move_conflict", `Lane ${laneId} changed while the move was prepared (version ${lane.version} → ${current.version}, state ${current.state}, account ${current.account ?? "(none)"}); run the move again`, { lane_id: laneId, prepared_version: lane.version, version: current.version, state: current.state, account: current.account ?? null });
|
|
5527
5657
|
}
|
|
5658
|
+
if (input.profileRepin !== undefined) {
|
|
5659
|
+
assertResumeProfileRepinStillAuthorized(ctx, current, input.profileRepin);
|
|
5660
|
+
}
|
|
5528
5661
|
const next = {
|
|
5529
5662
|
...current,
|
|
5530
5663
|
account: target,
|
|
@@ -5549,6 +5682,7 @@ async function prepareAccountMove(ctx, lane, input) {
|
|
|
5549
5682
|
};
|
|
5550
5683
|
applyResumeProfileAndEffort(current, next, {
|
|
5551
5684
|
...(input.workerProfile !== undefined ? { profile: input.workerProfile } : {}),
|
|
5685
|
+
...(input.profileRepin !== undefined ? { profileRepin: input.profileRepin } : {}),
|
|
5552
5686
|
...(input.effort !== undefined ? { effort: input.effort } : {}),
|
|
5553
5687
|
at,
|
|
5554
5688
|
});
|
|
@@ -6410,6 +6544,9 @@ function laneWithRecoveredProviderSession(hqRoot, lane) {
|
|
|
6410
6544
|
async function runResume(ctx, laneId, opts) {
|
|
6411
6545
|
const hqRoot = ctx.hqRoot();
|
|
6412
6546
|
let lane = readLane(hqRoot, laneId);
|
|
6547
|
+
if (opts.profileRepinContext !== undefined && !ctx.isSsmOwnerDispatch()) {
|
|
6548
|
+
throw new LanesError("worker_profile_hash_mismatch", "Profile re-pin context is only valid on an Outpost owner dispatch", { lane_id: laneId });
|
|
6549
|
+
}
|
|
6413
6550
|
if (lane.host.kind === "outpost" && !laneIsHostedHere(ctx, lane)) {
|
|
6414
6551
|
await dispatchOutpostLaneCommand(ctx, lane, "resume", opts);
|
|
6415
6552
|
return;
|
|
@@ -6478,6 +6615,7 @@ async function runResume(ctx, laneId, opts) {
|
|
|
6478
6615
|
const resumeProfile = opts.worker !== undefined
|
|
6479
6616
|
? resolveResumeProfile(ctx, lane, opts.worker)
|
|
6480
6617
|
: undefined;
|
|
6618
|
+
const profileRepin = authorizeResumeProfileRepin(ctx, lane, resumeProfile, opts.profileRepinContext);
|
|
6481
6619
|
const keepAlive = opts.keepAlive === true
|
|
6482
6620
|
? true
|
|
6483
6621
|
: resumeProfile?.profile.meta.keep_alive;
|
|
@@ -6502,6 +6640,7 @@ async function runResume(ctx, laneId, opts) {
|
|
|
6502
6640
|
account: opts.account,
|
|
6503
6641
|
cliModel: opts.model?.trim() || undefined,
|
|
6504
6642
|
...(resumeProfile !== undefined ? { workerProfile: resumeProfile } : {}),
|
|
6643
|
+
...(profileRepin !== undefined ? { profileRepin } : {}),
|
|
6505
6644
|
...(resumeEffort !== undefined ? { effort: resumeEffort } : {}),
|
|
6506
6645
|
pending: {
|
|
6507
6646
|
trigger: "resume",
|
|
@@ -6526,6 +6665,7 @@ async function runResume(ctx, laneId, opts) {
|
|
|
6526
6665
|
? { preservedModel: automaticAccountSelection.preservedModel }
|
|
6527
6666
|
: {}),
|
|
6528
6667
|
...(resumeProfile !== undefined ? { workerProfile: resumeProfile } : {}),
|
|
6668
|
+
...(profileRepin !== undefined ? { profileRepin } : {}),
|
|
6529
6669
|
...(resumeEffort !== undefined ? { effort: resumeEffort } : {}),
|
|
6530
6670
|
pending: {
|
|
6531
6671
|
trigger: "resume",
|
|
@@ -6544,6 +6684,7 @@ async function runResume(ctx, laneId, opts) {
|
|
|
6544
6684
|
}
|
|
6545
6685
|
lane = recordResumeSetup(ctx, lane, {
|
|
6546
6686
|
...(move === null && resumeProfile !== undefined ? { profile: resumeProfile } : {}),
|
|
6687
|
+
...(move === null && profileRepin !== undefined ? { profileRepin } : {}),
|
|
6547
6688
|
...(move === null && resumeEffort !== undefined ? { effort: resumeEffort } : {}),
|
|
6548
6689
|
...(keepAlive !== undefined ? { keepAlive } : {}),
|
|
6549
6690
|
...(move === null && keepCurrentAccount && opts.model !== undefined ? { cliModel: opts.model } : {}),
|
|
@@ -9419,6 +9560,7 @@ export function registerLanesCommand(parent, deps = {}) {
|
|
|
9419
9560
|
.option("--model <id>", "Model for the moved lane (with --account only)")
|
|
9420
9561
|
.option("--effort <level>", "Reasoning effort for the resumed provider turn")
|
|
9421
9562
|
.option("--worker <profile>", "Attach a personal worker profile for the resumed turn")
|
|
9563
|
+
.addOption(new Option("--profile-repin-context <base64url>").hideHelp())
|
|
9422
9564
|
.option("--keep-alive", "Automatically resume done lead lanes while their story remains open")
|
|
9423
9565
|
.option("--mesh-company <slug>", "Attach Work Mesh attribution after verifying company membership")
|
|
9424
9566
|
.option("--no-compact", "Skip the resume-time compaction check for this resume")
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Existing entities an input names, for `checkpoint.ontology.entity-hints`.
|
|
3
|
+
*
|
|
4
|
+
* The model never sees the company's entities, so it can only link one when it
|
|
5
|
+
* happens to pick a name or alias the index holds. On the CO-39 fixtures it
|
|
6
|
+
* created `SpaceXAI` beside `xAI`, `Indigo desktop app` beside `indigo-nx`
|
|
7
|
+
* (alias `Indigo desktop repo`) and `hq-work-site` beside `Indigo Workspace`
|
|
8
|
+
* (workspace/checkpoint-ontology/analysis/co41-wrong-creates.json). Each
|
|
9
|
+
* input's plan entry lists the existing entities whose name the input text
|
|
10
|
+
* contains, so the model can use that name.
|
|
11
|
+
*/
|
|
12
|
+
import type { EntityIndex } from "./tree.js";
|
|
13
|
+
export declare const ENTITY_HINTS: {
|
|
14
|
+
/** Entities listed per input. */
|
|
15
|
+
readonly perInput: 25;
|
|
16
|
+
/** An alias match counts this fraction of a canonical-name match with the same words. */
|
|
17
|
+
readonly aliasWeight: 0.5;
|
|
18
|
+
/** Aliases shown per entity. */
|
|
19
|
+
readonly aliases: 3;
|
|
20
|
+
/** Longest name, in words, matched through the n-gram set. Longer names use a phrase search. */
|
|
21
|
+
readonly maxGram: 4;
|
|
22
|
+
};
|
|
23
|
+
export interface EntityHint {
|
|
24
|
+
type: string;
|
|
25
|
+
name: string;
|
|
26
|
+
aliases: string[];
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Inverse document frequency of each word over the entities' match names, so
|
|
30
|
+
* `Sentry` outranks `HQ Build` in a document that names both.
|
|
31
|
+
*/
|
|
32
|
+
export declare function wordWeights(index: EntityIndex): Map<string, number>;
|
|
33
|
+
/**
|
|
34
|
+
* Up to `ENTITY_HINTS.perInput` writable entities whose canonical name or
|
|
35
|
+
* qualifying alias appears in `text` as whole words within one sentence, table
|
|
36
|
+
* cell or markdown block, highest weight first (ties by path). A match scores the summed word weights, halved for an alias.
|
|
37
|
+
* Entities with the same type and canonical name are listed once. Only
|
|
38
|
+
* entities from `index`, which is the plan's own company.
|
|
39
|
+
*/
|
|
40
|
+
export declare function entityHints(index: EntityIndex, weights: Map<string, number>, text: string): EntityHint[];
|
|
41
|
+
//# sourceMappingURL=hints.d.ts.map
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Existing entities an input names, for `checkpoint.ontology.entity-hints`.
|
|
3
|
+
*
|
|
4
|
+
* The model never sees the company's entities, so it can only link one when it
|
|
5
|
+
* happens to pick a name or alias the index holds. On the CO-39 fixtures it
|
|
6
|
+
* created `SpaceXAI` beside `xAI`, `Indigo desktop app` beside `indigo-nx`
|
|
7
|
+
* (alias `Indigo desktop repo`) and `hq-work-site` beside `Indigo Workspace`
|
|
8
|
+
* (workspace/checkpoint-ontology/analysis/co41-wrong-creates.json). Each
|
|
9
|
+
* input's plan entry lists the existing entities whose name the input text
|
|
10
|
+
* contains, so the model can use that name.
|
|
11
|
+
*/
|
|
12
|
+
export const ENTITY_HINTS = {
|
|
13
|
+
/** Entities listed per input. */
|
|
14
|
+
perInput: 25,
|
|
15
|
+
/** An alias match counts this fraction of a canonical-name match with the same words. */
|
|
16
|
+
aliasWeight: 0.5,
|
|
17
|
+
/** Aliases shown per entity. */
|
|
18
|
+
aliases: 3,
|
|
19
|
+
/** Longest name, in words, matched through the n-gram set. Longer names use a phrase search. */
|
|
20
|
+
maxGram: 4,
|
|
21
|
+
};
|
|
22
|
+
const HINT_TYPES = new Set(["person", "project", "company", "concept"]);
|
|
23
|
+
/** Token that stands for a sentence, cell or block boundary. A name never matches across one it does not contain. */
|
|
24
|
+
const BOUNDARY = "\n";
|
|
25
|
+
const TOKEN = /[\p{L}\p{N}]+|[.!?;:](?=\s|$)|\|/gu;
|
|
26
|
+
/**
|
|
27
|
+
* A line that continues the previous one when the previous one is prose or a
|
|
28
|
+
* list item: hard-wrapped markdown keeps a name that wraps across the line
|
|
29
|
+
* break. Blank lines, headings, list items, table rows, quotes and fences start
|
|
30
|
+
* a new block.
|
|
31
|
+
*/
|
|
32
|
+
function continuesProse(line) {
|
|
33
|
+
return line.trim() !== "" && !/^\s*(#|[-*+]\s|\d+[.)]\s|\||>|```|~~~)/.test(line);
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Lowercased words, with `BOUNDARY` between sentences (`.`, `!`, `?`, `;` or
|
|
37
|
+
* `:` before whitespace), table cells and markdown blocks. Names and text are
|
|
38
|
+
* split the same way, so a name that holds a boundary, such as `Tip 001:
|
|
39
|
+
* checkpoint`, still matches its own text.
|
|
40
|
+
*/
|
|
41
|
+
function tokens(text) {
|
|
42
|
+
const out = [];
|
|
43
|
+
const push = (token) => {
|
|
44
|
+
if (token !== BOUNDARY || (out.length > 0 && out[out.length - 1] !== BOUNDARY))
|
|
45
|
+
out.push(token);
|
|
46
|
+
};
|
|
47
|
+
let prevProse = null;
|
|
48
|
+
for (const line of text.toLowerCase().split("\n")) {
|
|
49
|
+
const prose = continuesProse(line);
|
|
50
|
+
if (prevProse !== null && !(prose && prevProse))
|
|
51
|
+
push(BOUNDARY);
|
|
52
|
+
for (const match of line.matchAll(TOKEN))
|
|
53
|
+
push(/^[\p{L}\p{N}]/u.test(match[0]) ? match[0] : BOUNDARY);
|
|
54
|
+
prevProse = prose || /^\s*([-*+]|\d+[.)])\s/.test(line);
|
|
55
|
+
}
|
|
56
|
+
while (out[0] === BOUNDARY)
|
|
57
|
+
out.shift();
|
|
58
|
+
while (out[out.length - 1] === BOUNDARY)
|
|
59
|
+
out.pop();
|
|
60
|
+
return out;
|
|
61
|
+
}
|
|
62
|
+
function words(name) {
|
|
63
|
+
return name.filter((token) => token !== BOUNDARY);
|
|
64
|
+
}
|
|
65
|
+
function matchNames(entity) {
|
|
66
|
+
const out = [];
|
|
67
|
+
const canonical = tokens(entity.canonical_name);
|
|
68
|
+
if (words(canonical).length > 0)
|
|
69
|
+
out.push({ tokens: canonical, weight: 1 });
|
|
70
|
+
for (const alias of entity.aliases) {
|
|
71
|
+
const aliasTokens = tokens(alias);
|
|
72
|
+
const count = words(aliasTokens).length;
|
|
73
|
+
if (count >= 2 || (count === 1 && /[-_./0-9]/.test(alias))) {
|
|
74
|
+
out.push({ tokens: aliasTokens, weight: ENTITY_HINTS.aliasWeight, alias });
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return out;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Inverse document frequency of each word over the entities' match names, so
|
|
81
|
+
* `Sentry` outranks `HQ Build` in a document that names both.
|
|
82
|
+
*/
|
|
83
|
+
export function wordWeights(index) {
|
|
84
|
+
const counts = new Map();
|
|
85
|
+
let total = 0;
|
|
86
|
+
for (const entity of index.byRel.values()) {
|
|
87
|
+
if (!HINT_TYPES.has(entity.type))
|
|
88
|
+
continue;
|
|
89
|
+
total += 1;
|
|
90
|
+
for (const word of new Set(matchNames(entity).flatMap((name) => words(name.tokens))))
|
|
91
|
+
counts.set(word, (counts.get(word) ?? 0) + 1);
|
|
92
|
+
}
|
|
93
|
+
const weights = new Map();
|
|
94
|
+
for (const [word, count] of counts)
|
|
95
|
+
weights.set(word, Math.log((total + 1) / count));
|
|
96
|
+
return weights;
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Up to `ENTITY_HINTS.perInput` writable entities whose canonical name or
|
|
100
|
+
* qualifying alias appears in `text` as whole words within one sentence, table
|
|
101
|
+
* cell or markdown block, highest weight first (ties by path). A match scores the summed word weights, halved for an alias.
|
|
102
|
+
* Entities with the same type and canonical name are listed once. Only
|
|
103
|
+
* entities from `index`, which is the plan's own company.
|
|
104
|
+
*/
|
|
105
|
+
export function entityHints(index, weights, text) {
|
|
106
|
+
const textTokens = tokens(text);
|
|
107
|
+
const grams = new Set();
|
|
108
|
+
for (let i = 0; i < textTokens.length; i += 1) {
|
|
109
|
+
for (let n = 1; n <= ENTITY_HINTS.maxGram && i + n <= textTokens.length; n += 1) {
|
|
110
|
+
grams.add(textTokens.slice(i, i + n).join(" "));
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
const padded = ` ${textTokens.join(" ")} `;
|
|
114
|
+
const best = new Map();
|
|
115
|
+
for (const entity of index.byRel.values()) {
|
|
116
|
+
if (!HINT_TYPES.has(entity.type))
|
|
117
|
+
continue;
|
|
118
|
+
let score = 0;
|
|
119
|
+
const matched = [];
|
|
120
|
+
for (const name of matchNames(entity)) {
|
|
121
|
+
const phrase = name.tokens.join(" ");
|
|
122
|
+
const found = name.tokens.length <= ENTITY_HINTS.maxGram ? grams.has(phrase) : padded.includes(` ${phrase} `);
|
|
123
|
+
if (!found)
|
|
124
|
+
continue;
|
|
125
|
+
if (name.alias !== undefined)
|
|
126
|
+
matched.push(name.alias);
|
|
127
|
+
score = Math.max(score, name.weight * words(name.tokens).reduce((sum, word) => sum + (weights.get(word) ?? 0), 0));
|
|
128
|
+
}
|
|
129
|
+
if (score <= 0)
|
|
130
|
+
continue;
|
|
131
|
+
const key = `${entity.type}\0${entity.canonical_name.toLowerCase()}`;
|
|
132
|
+
const held = best.get(key);
|
|
133
|
+
if (!held || score > held.score || (score === held.score && entity.rel < held.entity.rel))
|
|
134
|
+
best.set(key, { entity, score, matched });
|
|
135
|
+
}
|
|
136
|
+
return [...best.values()]
|
|
137
|
+
.sort((a, b) => b.score - a.score || (a.entity.rel < b.entity.rel ? -1 : a.entity.rel > b.entity.rel ? 1 : 0))
|
|
138
|
+
.slice(0, ENTITY_HINTS.perInput)
|
|
139
|
+
.map(({ entity, matched }) => ({
|
|
140
|
+
type: entity.type,
|
|
141
|
+
name: entity.canonical_name,
|
|
142
|
+
// Aliases the text used come first, so the model sees the name it read.
|
|
143
|
+
aliases: [...matched, ...entity.aliases.filter((alias) => !matched.includes(alias))].slice(0, ENTITY_HINTS.aliases),
|
|
144
|
+
}));
|
|
145
|
+
}
|
|
146
|
+
//# sourceMappingURL=hints.js.map
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
import { type CompanyBriefResult } from "./company-brief.js";
|
|
11
11
|
import { type OntologyCompany } from "./company.js";
|
|
12
12
|
import { type MultiWriterStart } from "./multi-writer.js";
|
|
13
|
+
import { type EntityHint } from "./hints.js";
|
|
13
14
|
export declare const ONTOLOGY_FLAG = "checkpoint.ontology";
|
|
14
15
|
export declare const COMPANY_BRIEF_FLAG = "checkpoint.ontology.company-brief";
|
|
15
16
|
/**
|
|
@@ -31,6 +32,13 @@ export declare const GROK_BACKEND_FLAG = "checkpoint.ontology.grok-backend";
|
|
|
31
32
|
* duplicate entities.
|
|
32
33
|
*/
|
|
33
34
|
export declare const MULTI_WRITER_FLAG = "checkpoint.ontology.multi-writer";
|
|
35
|
+
/**
|
|
36
|
+
* Each knowledge input lists the existing entities its text names
|
|
37
|
+
* (`known_entities`), and the model is told to use those names and to skip
|
|
38
|
+
* table rows, paths and slugs. CO-41: 11 of 93 creates in the CO-39 grok-4.6
|
|
39
|
+
* runs were model errors, 5 of them duplicates of an existing entity.
|
|
40
|
+
*/
|
|
41
|
+
export declare const ENTITY_HINTS_FLAG = "checkpoint.ontology.entity-hints";
|
|
34
42
|
export declare const PLAN_FILE = "ontology-plan.json";
|
|
35
43
|
export declare const CANDIDATES_FILE = "ontology-candidates.json";
|
|
36
44
|
export declare const REPORT_FILE = "ontology-report.json";
|
|
@@ -80,6 +88,8 @@ export interface PlanInput {
|
|
|
80
88
|
missing_refs?: string[];
|
|
81
89
|
/** Run-directory-relative copy of `path` with annotator entity lists removed; same line numbers. */
|
|
82
90
|
read_path?: string;
|
|
91
|
+
/** Existing entities this input's text names; only when `checkpoint.ontology.entity-hints` is on. */
|
|
92
|
+
known_entities?: EntityHint[];
|
|
83
93
|
}
|
|
84
94
|
export interface OntologyPlan {
|
|
85
95
|
schema: typeof PLAN_SCHEMA;
|
|
@@ -138,6 +148,7 @@ export type PlanOutcome = {
|
|
|
138
148
|
companyBrief?: CompanyBriefResult;
|
|
139
149
|
};
|
|
140
150
|
export declare const READ_PATH_INSTRUCTION = "When an input has read_path, read that file (relative to the run directory) instead of path, with the same line range. It is the same text with an older annotator's entity lists and entity links removed.";
|
|
151
|
+
export declare const ENTITY_HINTS_INSTRUCTIONS: string[];
|
|
141
152
|
export declare function ledgerPath(hqRoot: string, company: OntologyCompany): string;
|
|
142
153
|
export declare function readLedger(hqRoot: string, company: OntologyCompany): Ledger | null;
|
|
143
154
|
export declare function writeLedger(hqRoot: string, company: OntologyCompany, ledger: Ledger): void;
|
|
@@ -13,6 +13,7 @@ import * as path from "node:path";
|
|
|
13
13
|
import { writeCompanyBrief } from "./company-brief.js";
|
|
14
14
|
import { resolveOntologyCompany, safeWriteFile, stateRoot } from "./company.js";
|
|
15
15
|
import { startMultiWriterRun } from "./multi-writer.js";
|
|
16
|
+
import { entityHints, wordWeights } from "./hints.js";
|
|
16
17
|
import { buildEntityIndex, listKnowledgeFiles, listSignalFiles, readSignal } from "./tree.js";
|
|
17
18
|
export const ONTOLOGY_FLAG = "checkpoint.ontology";
|
|
18
19
|
export const COMPANY_BRIEF_FLAG = "checkpoint.ontology.company-brief";
|
|
@@ -35,6 +36,13 @@ export const GROK_BACKEND_FLAG = "checkpoint.ontology.grok-backend";
|
|
|
35
36
|
* duplicate entities.
|
|
36
37
|
*/
|
|
37
38
|
export const MULTI_WRITER_FLAG = "checkpoint.ontology.multi-writer";
|
|
39
|
+
/**
|
|
40
|
+
* Each knowledge input lists the existing entities its text names
|
|
41
|
+
* (`known_entities`), and the model is told to use those names and to skip
|
|
42
|
+
* table rows, paths and slugs. CO-41: 11 of 93 creates in the CO-39 grok-4.6
|
|
43
|
+
* runs were model errors, 5 of them duplicates of an existing entity.
|
|
44
|
+
*/
|
|
45
|
+
export const ENTITY_HINTS_FLAG = "checkpoint.ontology.entity-hints";
|
|
38
46
|
export const PLAN_FILE = "ontology-plan.json";
|
|
39
47
|
export const CANDIDATES_FILE = "ontology-candidates.json";
|
|
40
48
|
export const REPORT_FILE = "ontology-report.json";
|
|
@@ -47,6 +55,10 @@ export const BUDGET = {
|
|
|
47
55
|
chunkTokens: 12_000,
|
|
48
56
|
};
|
|
49
57
|
export const READ_PATH_INSTRUCTION = "When an input has read_path, read that file (relative to the run directory) instead of path, with the same line range. It is the same text with an older annotator's entity lists and entity links removed.";
|
|
58
|
+
export const ENTITY_HINTS_INSTRUCTIONS = [
|
|
59
|
+
"A knowledge input's known_entities lists entities that already exist in the company and whose names appear in its text. When the input refers to one of them, under that name or another, use that entity's name and type exactly and do not name a new entity for it.",
|
|
60
|
+
"Skip rows of a glossary or translation table, file and directory paths, and names built from tags or slugs.",
|
|
61
|
+
];
|
|
50
62
|
const CANDIDATE_INSTRUCTIONS = [
|
|
51
63
|
`Write ${CANDIDATES_FILE} in the run directory as {"schema":"${CANDIDATES_SCHEMA}","candidates":[...]} and nothing else.`,
|
|
52
64
|
"Each candidate: {input, type, name, aliases, confidence, evidence}. input is one of this plan's input ids; type is person, project, company or concept; name is the entity's full proper name; aliases are other names the input uses for it (at most 5); confidence is 0 to 1; evidence is a short quote from the input.",
|
|
@@ -206,6 +218,7 @@ export async function planOntologyRun(options) {
|
|
|
206
218
|
const ignoreAnnotations = (await options.readFlag(IGNORE_ANNOTATIONS_FLAG, company)) && payload.backend === "grok";
|
|
207
219
|
recordBackendPreference(hqRoot, company, await options.readFlag(GROK_BACKEND_FLAG, company));
|
|
208
220
|
const multiWriterOn = await options.readFlag(MULTI_WRITER_FLAG, company);
|
|
221
|
+
const hintsOn = await options.readFlag(ENTITY_HINTS_FLAG, company);
|
|
209
222
|
const touched = sessionTouched(hqRoot, company, payload.files);
|
|
210
223
|
// A first run takes its boundary from the entity tree, so the ledger is
|
|
211
224
|
// refreshed before merge-back moves any `last_updated` forward.
|
|
@@ -218,6 +231,15 @@ export async function planOntologyRun(options) {
|
|
|
218
231
|
const pending = Object.entries(ledger.entries)
|
|
219
232
|
.filter(([, entry]) => entry.state === "pending" && entry.reason === "session-touched")
|
|
220
233
|
.sort(([aRel, a], [bRel, b]) => a.mtime_ms - b.mtime_ms || aRel.localeCompare(bRel));
|
|
234
|
+
// Built after merge-back, so merged entities are hinted too.
|
|
235
|
+
let hintIndex = null;
|
|
236
|
+
const hintsFor = (text) => {
|
|
237
|
+
if (!hintIndex) {
|
|
238
|
+
const index = buildEntityIndex(company.dir);
|
|
239
|
+
hintIndex = { index, weights: wordWeights(index) };
|
|
240
|
+
}
|
|
241
|
+
return entityHints(hintIndex.index, hintIndex.weights, text);
|
|
242
|
+
};
|
|
221
243
|
const inputs = [];
|
|
222
244
|
let tokens = 0;
|
|
223
245
|
let remaining = 0;
|
|
@@ -242,6 +264,10 @@ export async function planOntologyRun(options) {
|
|
|
242
264
|
const cost = estTokens(Buffer.byteLength(lines.slice(start - 1, end).join("\n"), "utf8"));
|
|
243
265
|
if (inputs.length >= BUDGET.maxInputs || tokens + cost > BUDGET.maxTokens)
|
|
244
266
|
break;
|
|
267
|
+
// The model reads the stripped copy when there is one; its line numbers match.
|
|
268
|
+
const knownEntities = hintsOn && kind === "knowledge"
|
|
269
|
+
? hintsFor((cleaned ?? text).split("\n").slice(start - 1, end).join("\n"))
|
|
270
|
+
: null;
|
|
245
271
|
inputs.push({
|
|
246
272
|
id: chunks.length > 1 ? `${rel}#${index}` : rel,
|
|
247
273
|
kind,
|
|
@@ -254,6 +280,7 @@ export async function planOntologyRun(options) {
|
|
|
254
280
|
chunk: chunks.length > 1 ? { index, count: chunks.length } : null,
|
|
255
281
|
...(kind === "signal" ? { missing_refs: missingRefs(company.dir, rel) } : {}),
|
|
256
282
|
...(readPath !== null ? { read_path: readPath } : {}),
|
|
283
|
+
...(knownEntities !== null ? { known_entities: knownEntities } : {}),
|
|
257
284
|
});
|
|
258
285
|
tokens += cost;
|
|
259
286
|
}
|
|
@@ -314,9 +341,13 @@ export async function planOntologyRun(options) {
|
|
|
314
341
|
planned_tokens: tokens,
|
|
315
342
|
},
|
|
316
343
|
candidates_file: CANDIDATES_FILE,
|
|
317
|
-
instructions:
|
|
318
|
-
|
|
319
|
-
:
|
|
344
|
+
instructions: [
|
|
345
|
+
...CANDIDATE_INSTRUCTIONS.slice(0, 3),
|
|
346
|
+
...(inputs.some((input) => input.read_path) ? [READ_PATH_INSTRUCTION] : []),
|
|
347
|
+
CANDIDATE_INSTRUCTIONS[3],
|
|
348
|
+
...(inputs.some((input) => input.known_entities) ? ENTITY_HINTS_INSTRUCTIONS : []),
|
|
349
|
+
...CANDIDATE_INSTRUCTIONS.slice(4),
|
|
350
|
+
],
|
|
320
351
|
};
|
|
321
352
|
fs.mkdirSync(runDir, { recursive: true });
|
|
322
353
|
const planPath = path.join(runDir, PLAN_FILE);
|
|
@@ -10,7 +10,7 @@ export type { AdmissionCapReason, AdmissionHost } from "./admission.js";
|
|
|
10
10
|
export { buildHostCapacitySnapshot, chooseHostForLane, parseHostCapacitySnapshot, } from "./host-capacity.js";
|
|
11
11
|
export type { HostCapacityAccount, HostCapacityProvider, HostCapacitySnapshot, HostPlacementChoice, } from "./host-capacity.js";
|
|
12
12
|
export { CONFIG_SCHEMA_VERSION, ENVELOPE_DECISIONS, ENVELOPE_SCHEMA_VERSION, HISTORY_CAP, HOST_KINDS, LANE_PROVIDERS, LANE_SCHEMA_VERSION, LANE_STORY_STATUSES, LANE_STATES, LIVENESS_STATES, QUESTION_BODY_STORES, QUESTION_SCHEMA_VERSION, QUESTION_STATUSES, QUEUED_REASONS, RAISED_VIA, ROUTING_REASONS, SENIOR_KINDS, STORY_STATUSES, TERMINAL_LANE_STATES, isEnvelopeDecision, isHostKind, isLaneProvider, isLaneState, isLivenessState, isQuestionStatus, isSeniorKind, isTerminalLaneState, parseLaneEnvelope, parseLaneProvider, parseLaneState, parseLivenessState, parseMajorSchema, parseQuestionStatus, parseSeniorKind, } from "./types.js";
|
|
13
|
-
export type { AccountConfig, AttributionHistoryEntry, EnvelopeDecision, EnvelopeQuestion, HostKind, LaneCompanyRef, LaneAccountSelection, LaneAccountSelectionConsidered, LaneAccountSelectionHostCount, LaneEnvelope, LaneHost, LaneProvider, LanePlacementPolicy, LaneRecord, LaneStory, LaneStoryStatus, PendingResume, AccountHistoryEntry, LaneState, LanesConfig, LastEnvelopeRef, LivenessState, LivenessWindows, ProviderConfig, QuestionAnswer, QuestionBodyRef, QuestionRecord, QuestionStatus, QueuedReason, RaisedVia, RoutingHistoryEntry, RoutingReason, SeniorHistoryEntry, SeniorKind, SeniorRef, StoryStatus, TerminalLaneState, ThrottleConfig, } from "./types.js";
|
|
13
|
+
export type { AccountConfig, AttributionHistoryEntry, EnvelopeDecision, EnvelopeQuestion, HostKind, LaneCompanyRef, LaneAccountSelection, LaneAccountSelectionConsidered, LaneAccountSelectionHostCount, LaneEnvelope, LaneHost, LaneProvider, LanePlacementPolicy, LaneRecord, LaneStory, LaneStoryStatus, PendingResume, AccountHistoryEntry, LaneState, LanesConfig, LastEnvelopeRef, LivenessState, LivenessWindows, ProviderConfig, QuestionAnswer, QuestionBodyRef, QuestionRecord, QuestionStatus, QueuedReason, RaisedVia, RoutingHistoryEntry, RoutingReason, SeniorHistoryEntry, SeniorKind, SeniorRef, WorkerProfileRepinHistoryEntry, StoryStatus, TerminalLaneState, ThrottleConfig, } from "./types.js";
|
|
14
14
|
export { ACTIVE_LANE_STORY_STATUSES, activeLaneStories, laneStories, laneStory, storyKey, storyStatusForEnvelope, storyWarning, withCurrentStory, withLaneStories, } from "./stories.js";
|
|
15
15
|
export { DEFAULT_ACCOUNT_HOMES, DEFAULT_AUTO_COMPACT_TOKENS, DEFAULT_COMPACT_AFTER_MS, DEFAULT_CPU_BUSY_PAUSE, DEFAULT_CPU_BUSY_RESUME, DEFAULT_LIVE_WITHIN_MS, DEFAULT_LIVENESS_WINDOWS, DEFAULT_MAX_QUEUED, DEFAULT_MIN_AVAILABLE_MEM_MB, DEFAULT_PROVIDER_MAX_RUNNING, DEFAULT_SILENT_LOST_MS, DEFAULT_TURN_START_LOST_MS, LANES_CONFIG_REL, accountMaxRunning, canonicalizeHome, documentedDefaultConfig, expandHome, lanesConfigPath, loadLanesConfig, parseLanesConfig, providerCompactAfterMs, providerAutoCompactTokens, providerQuotaDefaultHoldMs, DEFAULT_QUOTA_HOLD_MS, writeLanesConfig, } from "./config.js";
|
|
16
16
|
export type { ConfigLoadDeps, ConfigWriteDeps } from "./config.js";
|
|
@@ -150,6 +150,7 @@ export function buildOutpostLaneCommand(input) {
|
|
|
150
150
|
["--worker", input.worker],
|
|
151
151
|
["--hire", input.hire],
|
|
152
152
|
["--grant-context", input.grantContext],
|
|
153
|
+
["--profile-repin-context", input.profileRepinContext],
|
|
153
154
|
["--senior", input.senior],
|
|
154
155
|
["--reason", input.reason],
|
|
155
156
|
]) {
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { type SeniorRef } from "./types.js";
|
|
2
|
+
declare const PROFILE_REPIN_CONTEXT_SCHEMA: "hq-lane-profile-repin-context.v1";
|
|
3
|
+
export interface OutpostProfileRepinContext {
|
|
4
|
+
schema_version: typeof PROFILE_REPIN_CONTEXT_SCHEMA;
|
|
5
|
+
lane_id: string;
|
|
6
|
+
expected_senior: SeniorRef;
|
|
7
|
+
actor: string;
|
|
8
|
+
reason: "resume --worker";
|
|
9
|
+
}
|
|
10
|
+
export declare function createOutpostProfileRepinContext(input: {
|
|
11
|
+
laneId: string;
|
|
12
|
+
expectedSenior: SeniorRef;
|
|
13
|
+
actor: string;
|
|
14
|
+
}): string;
|
|
15
|
+
export declare function parseOutpostProfileRepinContext(encoded: string, expectedLaneId: string): OutpostProfileRepinContext;
|
|
16
|
+
export {};
|
|
17
|
+
//# sourceMappingURL=outpost-profile-repin.d.ts.map
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { LanesError } from "./errors.js";
|
|
2
|
+
import { isSeniorKind } from "./types.js";
|
|
3
|
+
const PROFILE_REPIN_CONTEXT_SCHEMA = "hq-lane-profile-repin-context.v1";
|
|
4
|
+
const MAX_CONTEXT_BYTES = 4 * 1024;
|
|
5
|
+
function contextError(message) {
|
|
6
|
+
return new LanesError("worker_profile_repin_context_invalid", message, {});
|
|
7
|
+
}
|
|
8
|
+
function parseSenior(value) {
|
|
9
|
+
if (value === null || typeof value !== "object" || Array.isArray(value))
|
|
10
|
+
return null;
|
|
11
|
+
const record = value;
|
|
12
|
+
if (Object.keys(record).some((key) => key !== "kind" && key !== "id"))
|
|
13
|
+
return null;
|
|
14
|
+
if (!isSeniorKind(record.kind))
|
|
15
|
+
return null;
|
|
16
|
+
if (record.id !== undefined &&
|
|
17
|
+
(typeof record.id !== "string" || !record.id.trim() || record.id.trim() !== record.id))
|
|
18
|
+
return null;
|
|
19
|
+
if ((record.kind === "session" || record.kind === "lane") && record.id === undefined)
|
|
20
|
+
return null;
|
|
21
|
+
if (record.kind === "user" && record.id !== undefined)
|
|
22
|
+
return null;
|
|
23
|
+
return record.id === undefined
|
|
24
|
+
? { kind: record.kind }
|
|
25
|
+
: { kind: record.kind, id: record.id };
|
|
26
|
+
}
|
|
27
|
+
export function createOutpostProfileRepinContext(input) {
|
|
28
|
+
const context = {
|
|
29
|
+
schema_version: PROFILE_REPIN_CONTEXT_SCHEMA,
|
|
30
|
+
lane_id: input.laneId,
|
|
31
|
+
expected_senior: input.expectedSenior.id === undefined
|
|
32
|
+
? { kind: input.expectedSenior.kind }
|
|
33
|
+
: { kind: input.expectedSenior.kind, id: input.expectedSenior.id },
|
|
34
|
+
actor: input.actor,
|
|
35
|
+
reason: "resume --worker",
|
|
36
|
+
};
|
|
37
|
+
const encoded = Buffer.from(JSON.stringify(context), "utf8").toString("base64url");
|
|
38
|
+
if (Buffer.byteLength(encoded, "utf8") > MAX_CONTEXT_BYTES) {
|
|
39
|
+
throw contextError(`Profile re-pin context for lane ${input.laneId} is too large`);
|
|
40
|
+
}
|
|
41
|
+
return encoded;
|
|
42
|
+
}
|
|
43
|
+
export function parseOutpostProfileRepinContext(encoded, expectedLaneId) {
|
|
44
|
+
if (encoded.length === 0 ||
|
|
45
|
+
encoded.length > MAX_CONTEXT_BYTES ||
|
|
46
|
+
!/^[A-Za-z0-9_-]+$/.test(encoded)) {
|
|
47
|
+
throw contextError("Profile re-pin context must be bounded base64url text");
|
|
48
|
+
}
|
|
49
|
+
let raw;
|
|
50
|
+
try {
|
|
51
|
+
const decoded = Buffer.from(encoded, "base64url");
|
|
52
|
+
if (decoded.toString("base64url") !== encoded) {
|
|
53
|
+
throw new Error("non-canonical base64url");
|
|
54
|
+
}
|
|
55
|
+
raw = JSON.parse(decoded.toString("utf8"));
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
throw contextError("Profile re-pin context is not valid base64url JSON");
|
|
59
|
+
}
|
|
60
|
+
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
|
|
61
|
+
throw contextError("Profile re-pin context must be a JSON object");
|
|
62
|
+
}
|
|
63
|
+
const record = raw;
|
|
64
|
+
if (Object.keys(record).some((key) => !["schema_version", "lane_id", "expected_senior", "actor", "reason"].includes(key))) {
|
|
65
|
+
throw contextError("Profile re-pin context contains unknown fields");
|
|
66
|
+
}
|
|
67
|
+
const expectedSenior = parseSenior(record.expected_senior);
|
|
68
|
+
if (record.schema_version !== PROFILE_REPIN_CONTEXT_SCHEMA ||
|
|
69
|
+
record.lane_id !== expectedLaneId ||
|
|
70
|
+
expectedSenior === null ||
|
|
71
|
+
typeof record.actor !== "string" ||
|
|
72
|
+
!record.actor.trim() ||
|
|
73
|
+
record.reason !== "resume --worker") {
|
|
74
|
+
throw contextError(`Profile re-pin context is invalid for lane ${expectedLaneId}`);
|
|
75
|
+
}
|
|
76
|
+
return {
|
|
77
|
+
schema_version: PROFILE_REPIN_CONTEXT_SCHEMA,
|
|
78
|
+
lane_id: expectedLaneId,
|
|
79
|
+
expected_senior: expectedSenior,
|
|
80
|
+
actor: record.actor,
|
|
81
|
+
reason: "resume --worker",
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
//# sourceMappingURL=outpost-profile-repin.js.map
|
package/dist/lib/lanes/store.js
CHANGED
|
@@ -562,6 +562,29 @@ export function parseLaneRecord(raw, filePath) {
|
|
|
562
562
|
path: filePath,
|
|
563
563
|
});
|
|
564
564
|
}
|
|
565
|
+
if (raw.worker_profile_repin_history !== undefined) {
|
|
566
|
+
if (!Array.isArray(raw.worker_profile_repin_history)) {
|
|
567
|
+
throw new LaneSchemaError("worker_profile_repin_history must be an array when present", {
|
|
568
|
+
path: filePath,
|
|
569
|
+
field: "worker_profile_repin_history",
|
|
570
|
+
});
|
|
571
|
+
}
|
|
572
|
+
for (const [index, entry] of raw.worker_profile_repin_history.entries()) {
|
|
573
|
+
const field = `worker_profile_repin_history[${index}]`;
|
|
574
|
+
if (!isPlainObject(entry) || Object.keys(entry).some((key) => !["old_hash", "new_hash", "actor", "at", "reason"].includes(key))) {
|
|
575
|
+
throw new LaneSchemaError(`${field} must be an audit entry`, { path: filePath, field });
|
|
576
|
+
}
|
|
577
|
+
if (entry.old_hash !== null && (typeof entry.old_hash !== "string" || !entry.old_hash.trim())) {
|
|
578
|
+
throw new LaneSchemaError(`${field}.old_hash must be a non-empty string or null`, { path: filePath, field });
|
|
579
|
+
}
|
|
580
|
+
for (const key of ["new_hash", "actor", "reason"]) {
|
|
581
|
+
if (typeof entry[key] !== "string" || !entry[key].trim()) {
|
|
582
|
+
throw new LaneSchemaError(`${field}.${key} must be a non-empty string`, { path: filePath, field });
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
requireTimestamp(entry.at, `${field}.at`, filePath);
|
|
586
|
+
}
|
|
587
|
+
}
|
|
565
588
|
if (raw.account_selection !== undefined) {
|
|
566
589
|
parseLaneAccountSelection(raw.account_selection, filePath);
|
|
567
590
|
}
|
|
@@ -853,6 +876,11 @@ export function mergeLaneRecords(live, sidecars) {
|
|
|
853
876
|
account_history: unionHistory(all.map((record) => record.account_history ?? []), entryIdentity, base.account_history ?? []),
|
|
854
877
|
}
|
|
855
878
|
: {}),
|
|
879
|
+
...(all.some((record) => record.worker_profile_repin_history !== undefined)
|
|
880
|
+
? {
|
|
881
|
+
worker_profile_repin_history: unionHistory(all.map((record) => record.worker_profile_repin_history ?? []), entryIdentity, base.worker_profile_repin_history ?? []),
|
|
882
|
+
}
|
|
883
|
+
: {}),
|
|
856
884
|
stories: mergeLaneStories(all, base),
|
|
857
885
|
};
|
|
858
886
|
}
|
|
@@ -283,6 +283,14 @@ export interface SeniorHistoryEntry {
|
|
|
283
283
|
by: string;
|
|
284
284
|
reason: string;
|
|
285
285
|
}
|
|
286
|
+
/** Evidence for a senior-authorized Outpost worker-profile content re-pin. */
|
|
287
|
+
export interface WorkerProfileRepinHistoryEntry {
|
|
288
|
+
old_hash: string | null;
|
|
289
|
+
new_hash: string;
|
|
290
|
+
actor: string;
|
|
291
|
+
at: string;
|
|
292
|
+
reason: string;
|
|
293
|
+
}
|
|
286
294
|
export interface AttributionHistoryEntry {
|
|
287
295
|
project_id: string;
|
|
288
296
|
story_id: string;
|
|
@@ -372,6 +380,8 @@ export interface LaneRecord {
|
|
|
372
380
|
hire_grant?: number;
|
|
373
381
|
/** Content hash of the profile last applied to this lane. */
|
|
374
382
|
worker_profile_hash?: string;
|
|
383
|
+
/** Bounded audit history of authorized Outpost profile hash re-pins. */
|
|
384
|
+
worker_profile_repin_history?: WorkerProfileRepinHistoryEntry[];
|
|
375
385
|
/** Profile fields intentionally unsupported by the selected provider. */
|
|
376
386
|
worker_profile_ignored?: string[];
|
|
377
387
|
/** How policy files were delivered on the most recent profile spawn. */
|
package/dist/lib/lanes/types.js
CHANGED
|
@@ -223,7 +223,7 @@ export const LANE_FIELD_WRITERS = {
|
|
|
223
223
|
state: ["creator", "command", "watcher", "reconcile", "spawn"], queued_reason: ["creator", "command", "spawn", "watcher", "reconcile"], queued_at: ["creator", "command", "spawn", "watcher", "reconcile"], admission_reserved_at: ["creator", "command", "reconcile", "spawn", "watcher"],
|
|
224
224
|
pickup_validation_pending: ["creator", "command", "spawn", "reconcile"],
|
|
225
225
|
pickup_validation_pending_since: ["creator", "command", "spawn", "reconcile"],
|
|
226
|
-
priority: ["command"], worker: ["creator", "command"], worker_path: ["creator", "command"], worker_pack: ["creator", "command"], worker_profile: ["creator", "command"], worker_profile_hash: ["creator", "command", "spawn"], worker_profile_ignored: ["creator", "command", "spawn"], worker_policy_delivery: ["command", "spawn"],
|
|
226
|
+
priority: ["command"], worker: ["creator", "command"], worker_path: ["creator", "command"], worker_pack: ["creator", "command"], worker_profile: ["creator", "command"], worker_profile_hash: ["creator", "command", "spawn"], worker_profile_repin_history: ["command"], worker_profile_ignored: ["creator", "command", "spawn"], worker_policy_delivery: ["command", "spawn"],
|
|
227
227
|
keep_alive: ["creator", "command"], keep_alive_opted_in_at: ["creator", "command"], keep_alive_blocked_reason: ["command", "reconcile"], keep_alive_resume_count: ["command", "reconcile"], keep_alive_last_resume_at: ["command", "reconcile"], keep_alive_next_eligible_at: ["command", "reconcile"], keep_alive_exhausted_at: ["command", "reconcile"],
|
|
228
228
|
provider: ["creator"], account: ["creator", "spawn", "command", "watcher", "reconcile"], account_selection: ["creator", "command"], admission_accounts: ["creator", "command"], account_history: ["command", "spawn", "watcher", "reconcile"], pending_resume: ["command", "spawn", "watcher", "reconcile"], placement_default: ["creator"], host: ["creator", "command"], origin_host: ["creator"], origin_host_source: ["creator"],
|
|
229
229
|
version: ["creator"], created_at: ["creator"], updated_at: ["creator"], attribution_history: ["command"],
|