agent-relay-server 0.37.0 → 0.38.0
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/docs/openapi.json +1 -1
- package/package.json +2 -2
- package/public/assets/{activity-BgkmA1lh.js → activity-ClpDglG8.js} +2 -2
- package/public/assets/{activity-BgkmA1lh.js.map → activity-ClpDglG8.js.map} +1 -1
- package/public/assets/{agent-profiles-DS4_jLPT.js → agent-profiles-kb5H23CF.js} +2 -2
- package/public/assets/{agent-profiles-DS4_jLPT.js.map → agent-profiles-kb5H23CF.js.map} +1 -1
- package/public/assets/{agents-B7HnuAXx.js → agents-CHmEJvqV.js} +2 -2
- package/public/assets/{agents-B7HnuAXx.js.map → agents-CHmEJvqV.js.map} +1 -1
- package/public/assets/{analytics-0-akxJCJ.js → analytics-2kTjXIj1.js} +3 -3
- package/public/assets/{analytics-0-akxJCJ.js.map → analytics-2kTjXIj1.js.map} +1 -1
- package/public/assets/{automation-CaE1z_-M.js → automation-B5U_g-1P.js} +2 -2
- package/public/assets/{automation-CaE1z_-M.js.map → automation-B5U_g-1P.js.map} +1 -1
- package/public/assets/{branch-state-badge-D8-T2c1K.js → branch-state-badge-B1K7aIzF.js} +2 -2
- package/public/assets/{branch-state-badge-D8-T2c1K.js.map → branch-state-badge-B1K7aIzF.js.map} +1 -1
- package/public/assets/{channels-ppN8k4hu.js → channels-DyPw9JsY.js} +2 -2
- package/public/assets/{channels-ppN8k4hu.js.map → channels-DyPw9JsY.js.map} +1 -1
- package/public/assets/chat-zPXWB-03.js +2 -0
- package/public/assets/chat-zPXWB-03.js.map +1 -0
- package/public/assets/{connectors-CL9BALhF.js → connectors-k7JYCrrl.js} +2 -2
- package/public/assets/{connectors-CL9BALhF.js.map → connectors-k7JYCrrl.js.map} +1 -1
- package/public/assets/display-ConJ9cJB.js.map +1 -1
- package/public/assets/{formatted-body-impl-DExNPNsL.js → formatted-body-impl-tmf8IBfr.js} +2 -2
- package/public/assets/{formatted-body-impl-DExNPNsL.js.map → formatted-body-impl-tmf8IBfr.js.map} +1 -1
- package/public/assets/index-B1QUkb_O.js +21 -0
- package/public/assets/index-B1QUkb_O.js.map +1 -0
- package/public/assets/index-Bins8N_5.css +2 -0
- package/public/assets/{integrations-DX55ARy0.js → integrations-BEkyjBAs.js} +2 -2
- package/public/assets/{integrations-DX55ARy0.js.map → integrations-BEkyjBAs.js.map} +1 -1
- package/public/assets/{maintenance-DpTdJxQp.js → maintenance-Tn23oWBF.js} +2 -2
- package/public/assets/{maintenance-DpTdJxQp.js.map → maintenance-Tn23oWBF.js.map} +1 -1
- package/public/assets/{managed-agents-B3df2xfk.js → managed-agents-CasacvJX.js} +2 -2
- package/public/assets/{managed-agents-B3df2xfk.js.map → managed-agents-CasacvJX.js.map} +1 -1
- package/public/assets/{markdown-preview-impl-D0Zj7c3T.js → markdown-preview-impl-D4UIjB3I.js} +2 -2
- package/public/assets/{markdown-preview-impl-D0Zj7c3T.js.map → markdown-preview-impl-D4UIjB3I.js.map} +1 -1
- package/public/assets/{memory-TATN2vZf.js → memory-SVCob0fo.js} +2 -2
- package/public/assets/{memory-TATN2vZf.js.map → memory-SVCob0fo.js.map} +1 -1
- package/public/assets/{messages-3rS1lxIf.js → messages-CHK24Uxx.js} +2 -2
- package/public/assets/{messages-3rS1lxIf.js.map → messages-CHK24Uxx.js.map} +1 -1
- package/public/assets/{orchestrators-CRIV0g5y.js → orchestrators-CQcJb6VE.js} +2 -2
- package/public/assets/{orchestrators-CRIV0g5y.js.map → orchestrators-CQcJb6VE.js.map} +1 -1
- package/public/assets/{overview-CmHC_5oM.js → overview-DbyX7k-7.js} +2 -2
- package/public/assets/{overview-CmHC_5oM.js.map → overview-DbyX7k-7.js.map} +1 -1
- package/public/assets/{pairs-DBPAhXTI.js → pairs-CaL0_ZfW.js} +2 -2
- package/public/assets/{pairs-DBPAhXTI.js.map → pairs-CaL0_ZfW.js.map} +1 -1
- package/public/assets/{security-572X5MNX.js → security-BogsfkbT.js} +2 -2
- package/public/assets/{security-572X5MNX.js.map → security-BogsfkbT.js.map} +1 -1
- package/public/assets/{settings-vTBu8w3O.js → settings-BOsnUh5f.js} +2 -2
- package/public/assets/{settings-vTBu8w3O.js.map → settings-BOsnUh5f.js.map} +1 -1
- package/public/assets/{store-DKVWC6Uh.js → store-Bo72e9My.js} +2 -2
- package/public/assets/{store-DKVWC6Uh.js.map → store-Bo72e9My.js.map} +1 -1
- package/public/assets/{tasks-C0bPrDgN.js → tasks-CCxQovOv.js} +2 -2
- package/public/assets/{tasks-C0bPrDgN.js.map → tasks-CCxQovOv.js.map} +1 -1
- package/public/assets/{terminal-viewer-impl-rVPA6Fsx.js → terminal-viewer-impl-BDikdsxs.js} +2 -2
- package/public/assets/{terminal-viewer-impl-rVPA6Fsx.js.map → terminal-viewer-impl-BDikdsxs.js.map} +1 -1
- package/public/assets/{work-queue-BxkpTt_A.js → work-queue-fM-tu0iP.js} +2 -2
- package/public/assets/{work-queue-BxkpTt_A.js.map → work-queue-fM-tu0iP.js.map} +1 -1
- package/public/assets/{workspaces-CoC2nflZ.js → workspaces-Df0xJuIo.js} +2 -2
- package/public/assets/{workspaces-CoC2nflZ.js.map → workspaces-Df0xJuIo.js.map} +1 -1
- package/public/index.html +3 -3
- package/src/automations.ts +17 -2
- package/src/cli/index.ts +1 -1
- package/src/cli/workspace.ts +36 -3
- package/src/config-store.ts +46 -0
- package/src/maintenance.ts +6 -1
- package/src/mcp.ts +7 -2
- package/src/routes/workspaces.ts +4 -1
- package/src/services/send-message.ts +19 -1
- package/src/services/spawn-agent.ts +6 -0
- package/src/workspace-actions.ts +5 -1
- package/src/workspace-merge.ts +102 -3
- package/src/workspace-phase.ts +15 -1
- package/public/assets/chat-BANKUW05.js +0 -2
- package/public/assets/chat-BANKUW05.js.map +0 -1
- package/public/assets/index-3pO43nJo.css +0 -2
- package/public/assets/index-DEZdON6c.js +0 -21
- package/public/assets/index-DEZdON6c.js.map +0 -1
package/src/config-store.ts
CHANGED
|
@@ -8,6 +8,7 @@ import type {
|
|
|
8
8
|
ConfigEntry,
|
|
9
9
|
ConfigHistoryEntry,
|
|
10
10
|
InsightsConfig,
|
|
11
|
+
LandingConfig,
|
|
11
12
|
ManagedAgentState,
|
|
12
13
|
ManagedAgentStatus,
|
|
13
14
|
NotificationsConfig,
|
|
@@ -16,6 +17,7 @@ import type {
|
|
|
16
17
|
SpawnProvider,
|
|
17
18
|
StewardConfig,
|
|
18
19
|
WorkspaceConfig,
|
|
20
|
+
WorkspaceLandingPolicy,
|
|
19
21
|
} from "./types";
|
|
20
22
|
|
|
21
23
|
const CONFIG_HISTORY_LIMIT = 50;
|
|
@@ -29,6 +31,9 @@ const NOTIFICATIONS_NAMESPACE = "notifications";
|
|
|
29
31
|
const NOTIFICATIONS_KEY = "default";
|
|
30
32
|
const WORKSPACE_NAMESPACE = "workspace";
|
|
31
33
|
const WORKSPACE_KEY = "default";
|
|
34
|
+
const LANDING_NAMESPACE = "landing";
|
|
35
|
+
const LANDING_KEY = "default";
|
|
36
|
+
const VALID_LANDING_STRATEGIES = ["direct", "pr"] as const;
|
|
32
37
|
const VALID_PROFILE_PROVIDERS = ["any", "claude", "codex"] as const;
|
|
33
38
|
const VALID_PROFILE_BASES = ["host", "minimal", "isolated"] as const;
|
|
34
39
|
const VALID_PROFILE_INSTRUCTION_POLICIES = ["allow", "ignore"] as const;
|
|
@@ -239,6 +244,7 @@ function agentProfileDefaults(input: Pick<AgentProfile, "name" | "base"> & Parti
|
|
|
239
244
|
env: input.env ?? {},
|
|
240
245
|
providerOptions: input.providerOptions ?? {},
|
|
241
246
|
...(input.maxSpawnedAgents === undefined ? {} : { maxSpawnedAgents: input.maxSpawnedAgents }),
|
|
247
|
+
...(input.landingStrategy === undefined ? {} : { landingStrategy: input.landingStrategy }),
|
|
242
248
|
};
|
|
243
249
|
}
|
|
244
250
|
|
|
@@ -317,6 +323,9 @@ function validateAgentProfile(key: string, value: unknown): AgentProfile {
|
|
|
317
323
|
maxSpawnedAgents: value.maxSpawnedAgents === undefined || value.maxSpawnedAgents === null
|
|
318
324
|
? undefined
|
|
319
325
|
: cleanNumber(value.maxSpawnedAgents, "maxSpawnedAgents", { min: 0, max: 100 }),
|
|
326
|
+
landingStrategy: value.landingStrategy === undefined || value.landingStrategy === null
|
|
327
|
+
? undefined
|
|
328
|
+
: cleanEnum(value.landingStrategy, "landingStrategy", VALID_LANDING_STRATEGIES) as WorkspaceLandingPolicy,
|
|
320
329
|
});
|
|
321
330
|
}
|
|
322
331
|
|
|
@@ -508,6 +517,19 @@ function validateWorkspaceConfig(value: unknown): WorkspaceConfig {
|
|
|
508
517
|
return { symlinkPaths };
|
|
509
518
|
}
|
|
510
519
|
|
|
520
|
+
const LANDING_CONFIG_DEFAULTS: LandingConfig = {
|
|
521
|
+
strategy: "direct",
|
|
522
|
+
};
|
|
523
|
+
|
|
524
|
+
function validateLandingConfig(value: unknown): LandingConfig {
|
|
525
|
+
if (!isRecord(value)) throw new ValidationError("landing config value must be an object");
|
|
526
|
+
return {
|
|
527
|
+
strategy: value.strategy === undefined || value.strategy === null
|
|
528
|
+
? LANDING_CONFIG_DEFAULTS.strategy
|
|
529
|
+
: cleanEnum(value.strategy, "strategy", VALID_LANDING_STRATEGIES) as WorkspaceLandingPolicy,
|
|
530
|
+
};
|
|
531
|
+
}
|
|
532
|
+
|
|
511
533
|
function normalizeValue(namespace: string, key: string, value: unknown): unknown {
|
|
512
534
|
if (value === undefined) throw new ValidationError("value required");
|
|
513
535
|
if (namespace === SPAWN_POLICY_NAMESPACE) return validateSpawnPolicy(key, value);
|
|
@@ -516,6 +538,7 @@ function normalizeValue(namespace: string, key: string, value: unknown): unknown
|
|
|
516
538
|
if (namespace === INSIGHTS_NAMESPACE) return validateInsightsConfig(value);
|
|
517
539
|
if (namespace === NOTIFICATIONS_NAMESPACE) return validateNotificationsConfig(value);
|
|
518
540
|
if (namespace === WORKSPACE_NAMESPACE) return validateWorkspaceConfig(value);
|
|
541
|
+
if (namespace === LANDING_NAMESPACE) return validateLandingConfig(value);
|
|
519
542
|
if (JSON.stringify(value) === undefined) throw new ValidationError("value must be valid JSON");
|
|
520
543
|
return value;
|
|
521
544
|
}
|
|
@@ -682,6 +705,29 @@ export function setWorkspaceConfig(value: unknown, updatedBy?: string): ConfigEn
|
|
|
682
705
|
return setConfig(WORKSPACE_NAMESPACE, WORKSPACE_KEY, value as WorkspaceConfig, updatedBy);
|
|
683
706
|
}
|
|
684
707
|
|
|
708
|
+
/** Instance-wide landing config, merged over defaults (always returns a usable value). */
|
|
709
|
+
export function getLandingConfig(): LandingConfig {
|
|
710
|
+
const entry = getConfig<Partial<LandingConfig>>(LANDING_NAMESPACE, LANDING_KEY);
|
|
711
|
+
if (!entry) return { ...LANDING_CONFIG_DEFAULTS };
|
|
712
|
+
return validateLandingConfig({ ...LANDING_CONFIG_DEFAULTS, ...entry.value });
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
export function getLandingConfigEntry(): ConfigEntry<LandingConfig> {
|
|
716
|
+
const entry = getConfig<LandingConfig>(LANDING_NAMESPACE, LANDING_KEY);
|
|
717
|
+
return entry ?? {
|
|
718
|
+
namespace: LANDING_NAMESPACE,
|
|
719
|
+
key: LANDING_KEY,
|
|
720
|
+
value: { ...LANDING_CONFIG_DEFAULTS },
|
|
721
|
+
version: 0,
|
|
722
|
+
updatedAt: "default",
|
|
723
|
+
updatedBy: "system",
|
|
724
|
+
};
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
export function setLandingConfig(value: unknown, updatedBy?: string): ConfigEntry<LandingConfig> {
|
|
728
|
+
return setConfig(LANDING_NAMESPACE, LANDING_KEY, value as LandingConfig, updatedBy);
|
|
729
|
+
}
|
|
730
|
+
|
|
685
731
|
/**
|
|
686
732
|
* Spawn-param fragment carrying the global workspace symlink list to the orchestrator.
|
|
687
733
|
* Spread into every spawn command's params next to `agentProfile` so any isolated
|
package/src/maintenance.ts
CHANGED
|
@@ -37,7 +37,7 @@ import { reconcileLandedWorkspace } from "./branch-landed";
|
|
|
37
37
|
import { notifyAgentOffline } from "./agent-lifecycle-events";
|
|
38
38
|
import { workspaceActiveClaim } from "./workspace-claim";
|
|
39
39
|
import { reapOrphanedWorktrees } from "./workspace-orphans";
|
|
40
|
-
import { deriveBranchState, READY_TO_LAND_STATUSES, TERMINAL_WORKSPACE_STATUSES } from "./workspace-phase";
|
|
40
|
+
import { deriveBranchState, DIRTY_WORKTREE_LAND_SKIP_REASON, READY_TO_LAND_STATUSES, TERMINAL_WORKSPACE_STATUSES } from "./workspace-phase";
|
|
41
41
|
import { errMessage, RELAY_TOKEN_HEADER } from "agent-relay-sdk";
|
|
42
42
|
import { getStewardConfig } from "./config-store";
|
|
43
43
|
import { ensureRepoSteward } from "./steward";
|
|
@@ -716,6 +716,11 @@ async function autoMergeCleanFastForwards(): Promise<Record<string, unknown>> {
|
|
|
716
716
|
if (!preview || (preview as { available?: false }).available === false) continue;
|
|
717
717
|
const p = preview as WorkspaceMergePreview;
|
|
718
718
|
if (p.error || p.missing) continue;
|
|
719
|
+
if (p.reason === DIRTY_WORKTREE_LAND_SKIP_REASON) {
|
|
720
|
+
patchWorkspaceMetadata(ws.id, { lastLandSkipReason: p.reason, lastLandSkipAt: Date.now() });
|
|
721
|
+
} else if (ws.metadata.lastLandSkipReason === DIRTY_WORKTREE_LAND_SKIP_REASON) {
|
|
722
|
+
patchWorkspaceMetadata(ws.id, { lastLandSkipReason: undefined, lastLandSkipAt: undefined });
|
|
723
|
+
}
|
|
719
724
|
|
|
720
725
|
const ahead = p.unmergedAhead ?? p.ahead ?? 0;
|
|
721
726
|
const behind = p.behind ?? 0;
|
package/src/mcp.ts
CHANGED
|
@@ -44,8 +44,9 @@ import {
|
|
|
44
44
|
} from "./security";
|
|
45
45
|
import { sendMessageService } from "./services/send-message";
|
|
46
46
|
import { ServiceAuthError } from "./services/errors";
|
|
47
|
-
import type { ActivityKind, ArtifactKind, ArtifactSensitivity, AttachmentRef, Command, SendMessageInput, Message, SpawnApprovalMode, SpawnProvider, WorkspaceMergeStrategy, WorkspaceMode, WorkspaceRecord } from "./types";
|
|
47
|
+
import type { ActivityKind, ArtifactKind, ArtifactSensitivity, AttachmentRef, Command, SendMessageInput, Message, SpawnApprovalMode, SpawnProvider, WorkspaceAutoMergePolicy, WorkspaceMergeStrategy, WorkspaceMode, WorkspaceRecord } from "./types";
|
|
48
48
|
import { LAND_STRATEGIES, applyWorkspaceAction, waitForWorkspaceStatus, type WorkspaceAction } from "./workspace-actions";
|
|
49
|
+
import { AUTO_MERGE_POLICIES } from "./workspace-merge";
|
|
49
50
|
import { describeWorkspacePhase, landReceipt, readyContract, worktreeMcpInstructions } from "./workspace-phase";
|
|
50
51
|
import { type ProviderEffort } from "agent-relay-sdk/provider-catalog";
|
|
51
52
|
import { errMessage, isRecord, stringValue, SPAWN_PROVIDERS, APPROVAL_MODES, VALID_EFFORTS, VALID_WORKSPACE_MODES } from "agent-relay-sdk";
|
|
@@ -395,10 +396,12 @@ const TOOLS: ToolDefinition[] = [
|
|
|
395
396
|
type: "object",
|
|
396
397
|
properties: {
|
|
397
398
|
workspaceId: { type: "string", description: "Defaults to your own isolated workspace." },
|
|
398
|
-
strategy: { type: "string", enum: ["pr", "rebase-ff", "auto"], description: "Merge strategy (default auto)." },
|
|
399
|
+
strategy: { type: "string", enum: ["pr", "rebase-ff", "auto"], description: "Merge strategy (default auto). Falls back to the instance landing.strategy config when unset." },
|
|
399
400
|
deleteBranch: { type: "boolean", description: "Delete the branch after a successful merge (default true)." },
|
|
400
401
|
prTitle: { type: "string" },
|
|
401
402
|
prBody: { type: "string" },
|
|
403
|
+
autoMerge: { type: "string", enum: AUTO_MERGE_POLICIES, description: "Auto-merge policy for PR-strategy lands. Defaults to 'on-green' when strategy resolves to 'pr'. Ignored for rebase-ff/auto strategies." },
|
|
404
|
+
reviewer: { type: "string", description: "GitHub login or team slug to request as a reviewer on the opened PR (pr strategy only)." },
|
|
402
405
|
detail: { type: "string" },
|
|
403
406
|
},
|
|
404
407
|
additionalProperties: false,
|
|
@@ -919,6 +922,8 @@ function relayWorkspaceMutation(auth: McpAuthContext, action: WorkspaceAction, a
|
|
|
919
922
|
deleteBranch: action === "merge" ? optionalBoolean(args.deleteBranch, "deleteBranch") : undefined,
|
|
920
923
|
prTitle: optionalString(args.prTitle, "prTitle", 240),
|
|
921
924
|
prBody: optionalString(args.prBody, "prBody", 8000),
|
|
925
|
+
autoMerge: action === "merge" ? (optionalEnum(args.autoMerge, "autoMerge", AUTO_MERGE_POLICIES) as WorkspaceAutoMergePolicy | undefined) : undefined,
|
|
926
|
+
reviewer: action === "merge" ? optionalString(args.reviewer, "reviewer", 240) : undefined,
|
|
922
927
|
purpose: optionalString(args.purpose, "purpose", 120),
|
|
923
928
|
checkOnly: action === "deps-refresh" ? optionalBoolean(args.checkOnly, "checkOnly") === true : undefined,
|
|
924
929
|
auditMetadata: { via: "mcp", actor: auth.actor },
|
package/src/routes/workspaces.ts
CHANGED
|
@@ -10,7 +10,8 @@ import { createCommand } from "../commands-db";
|
|
|
10
10
|
import { LAND_STRATEGIES, isOwnerAlive, withOwnerOnline } from "../workspace-merge";
|
|
11
11
|
import { isPathWithinBase } from "../utils";
|
|
12
12
|
import { resolve } from "node:path";
|
|
13
|
-
import { type WorkspaceDiagnostics, type WorkspaceGitState, type WorkspaceMergeStrategy, type WorkspaceRecord, type WorkspaceStatus } from "../types";
|
|
13
|
+
import { type WorkspaceAutoMergePolicy, type WorkspaceDiagnostics, type WorkspaceGitState, type WorkspaceMergeStrategy, type WorkspaceRecord, type WorkspaceStatus } from "../types";
|
|
14
|
+
import { AUTO_MERGE_POLICIES } from "../workspace-merge";
|
|
14
15
|
import { workspaceActiveClaim } from "../workspace-claim";
|
|
15
16
|
|
|
16
17
|
export const getWorkspaces: Handler = (req) => {
|
|
@@ -327,6 +328,8 @@ export const postWorkspaceAction: Handler = async (req, params) => {
|
|
|
327
328
|
force: parsed.body.force === true,
|
|
328
329
|
prTitle: cleanString(parsed.body.prTitle, "prTitle", { max: 240 }),
|
|
329
330
|
prBody: cleanString(parsed.body.prBody, "prBody", { max: 8000 }),
|
|
331
|
+
autoMerge: optionalEnum(parsed.body.autoMerge, "autoMerge", AUTO_MERGE_POLICIES) as WorkspaceAutoMergePolicy | undefined,
|
|
332
|
+
reviewer: cleanString(parsed.body.reviewer, "reviewer", { max: 240 }),
|
|
330
333
|
purpose: cleanString(parsed.body.purpose, "purpose", { max: 120 }),
|
|
331
334
|
checkOnly: parsed.body.checkOnly === true,
|
|
332
335
|
auditMetadata: authAuditMetadata(req),
|
|
@@ -53,10 +53,28 @@ export interface SendMessageOptions {
|
|
|
53
53
|
* the runner reporting an agent's turn ON ITS BEHALF, so the wire `from` (the reported agent) is
|
|
54
54
|
* authoritative there and the token's own identity must NOT override it. This is the relay's own
|
|
55
55
|
* lane (not agent-directed), the agentId authz predicate is dropped for it too, and it preserves
|
|
56
|
-
* the pre-convergence behavior — a steward/runner token managing other agents can still mirror.
|
|
56
|
+
* the pre-convergence behavior — a steward/runner token managing other agents can still mirror.
|
|
57
|
+
*
|
|
58
|
+
* LINEAGE GATE (#362): the reserved-sink exception is NOT blanket lane access — the wire `from`
|
|
59
|
+
* must be an agent the caller is authorised to speak as. The caller qualifies when the wire
|
|
60
|
+
* `from` matches its own resolved identity (`callerAgentId`), OR when `from` is explicitly
|
|
61
|
+
* listed in the token's managed set (`constraints.agents`), OR when the caller carries no
|
|
62
|
+
* agent-level constraints at all (admin/server/integration). Any other combination is an
|
|
63
|
+
* impersonation attempt and is rejected. */
|
|
57
64
|
function resolveFrom(input: SendMessageInput, ctx: AuthContext, reservedSink: boolean): string {
|
|
58
65
|
const from = reservedSink ? (input.from || ctx.callerAgentId) : (ctx.callerAgentId ?? input.from);
|
|
59
66
|
if (!from) throw new ValidationError("from is required");
|
|
67
|
+
if (reservedSink && from !== ctx.callerAgentId) {
|
|
68
|
+
// Constrained component tokens: the wire `from` must be in the token's managed agents list.
|
|
69
|
+
// Unconstrained callers (admin, server, integration — ctx.constraints is undefined) are
|
|
70
|
+
// allowed through unchanged; they hold full reach by design.
|
|
71
|
+
const managed = ctx.constraints?.agents;
|
|
72
|
+
if (managed !== undefined && !managed.includes(from)) {
|
|
73
|
+
throw new ServiceAuthError(
|
|
74
|
+
`reserved-sink wire from "${from}" is not in the caller's managed agents — impersonation rejected (#362)`,
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
60
78
|
return from;
|
|
61
79
|
}
|
|
62
80
|
|
|
@@ -190,6 +190,12 @@ export async function spawnAgent(input: SpawnAgentInput, ctx: AuthContext): Prom
|
|
|
190
190
|
policyName: input.policyName,
|
|
191
191
|
spawnRequestId,
|
|
192
192
|
createdBy: requestedBy,
|
|
193
|
+
// Thread the resolved profile into the mint so the spawn grant (command:spawn/shutdown +
|
|
194
|
+
// maxSpawnedAgents quota) is honored — parity with the HTTP path (agent-sessions.ts). #349
|
|
195
|
+
// dropped this when it unified the spawn service, silently un-spawning every default-spawner
|
|
196
|
+
// (P0 regression #364). For an agent caller, agentInitiated still forces a non-spawn-capable
|
|
197
|
+
// child regardless of profile (no grandchildren).
|
|
198
|
+
profile: input.profile || undefined,
|
|
193
199
|
...(callerId ? { agentInitiated: true, spawnedBy: callerId } : {}),
|
|
194
200
|
});
|
|
195
201
|
|
package/src/workspace-actions.ts
CHANGED
|
@@ -25,7 +25,7 @@ import { isOwnerAlive, requestWorkspaceMerge } from "./workspace-merge";
|
|
|
25
25
|
export { LAND_STRATEGIES, DEFAULT_MERGE_STRATEGY } from "./workspace-merge";
|
|
26
26
|
import { claimMetadataPatch, workspaceActiveClaim } from "./workspace-claim";
|
|
27
27
|
import { TERMINAL_WORKSPACE_STATUSES } from "./workspace-phase";
|
|
28
|
-
import type { Command, WorkspaceMergeStrategy, WorkspaceRecord, WorkspaceStatus } from "./types";
|
|
28
|
+
import type { Command, WorkspaceAutoMergePolicy, WorkspaceMergeStrategy, WorkspaceRecord, WorkspaceStatus } from "./types";
|
|
29
29
|
|
|
30
30
|
// Single source of truth for the action verb set. The route's `optionalEnum` and
|
|
31
31
|
// the MCP tool surface both import this so they can never drift out of sync.
|
|
@@ -54,6 +54,8 @@ interface ApplyWorkspaceActionInput {
|
|
|
54
54
|
deleteBranch?: boolean;
|
|
55
55
|
prTitle?: string;
|
|
56
56
|
prBody?: string;
|
|
57
|
+
autoMerge?: WorkspaceAutoMergePolicy;
|
|
58
|
+
reviewer?: string;
|
|
57
59
|
// cleanup
|
|
58
60
|
force?: boolean;
|
|
59
61
|
// claim / release-claim
|
|
@@ -171,6 +173,8 @@ export function applyWorkspaceAction(workspace: WorkspaceRecord, input: ApplyWor
|
|
|
171
173
|
deleteBranch: input.deleteBranch !== false,
|
|
172
174
|
prTitle: input.prTitle,
|
|
173
175
|
prBody: input.prBody,
|
|
176
|
+
autoMerge: input.autoMerge,
|
|
177
|
+
reviewer: input.reviewer,
|
|
174
178
|
metadata: { ...metadata, ...(detail ? { detail } : {}), ...(agentId ? { updatedByAgentId: agentId } : {}) },
|
|
175
179
|
});
|
|
176
180
|
if (!result.ok) return { ok: false, httpStatus: result.status, error: result.error };
|
package/src/workspace-merge.ts
CHANGED
|
@@ -7,9 +7,21 @@ import {
|
|
|
7
7
|
setMergeLeaseCommand,
|
|
8
8
|
updateWorkspaceStatus,
|
|
9
9
|
} from "./db";
|
|
10
|
-
import
|
|
10
|
+
import { getLandingConfig } from "./config-store";
|
|
11
|
+
import type {
|
|
12
|
+
Command,
|
|
13
|
+
WorkspaceAutoMergePolicy,
|
|
14
|
+
WorkspaceLandingPolicy,
|
|
15
|
+
WorkspaceMergeStrategy,
|
|
16
|
+
WorkspaceRecord,
|
|
17
|
+
} from "./types";
|
|
18
|
+
import { AUTO_MERGE_POLICIES } from "./types";
|
|
11
19
|
import { isPathWithinBase } from "./utils";
|
|
12
20
|
|
|
21
|
+
// Re-export so consumers can import the tuple from the strategy home without a
|
|
22
|
+
// second import hop.
|
|
23
|
+
export { AUTO_MERGE_POLICIES } from "./types";
|
|
24
|
+
|
|
13
25
|
// One home for the land-strategy contract, shared by BOTH land surfaces — the HTTP
|
|
14
26
|
// route (`POST /api/workspaces/:id/actions`, driven by `agent-relay workspace land`)
|
|
15
27
|
// and the `relay_workspace_land` MCP tool. They validate against the same tuple and
|
|
@@ -21,6 +33,60 @@ import { isPathWithinBase } from "./utils";
|
|
|
21
33
|
export const LAND_STRATEGIES = ["pr", "rebase-ff", "auto"] as const;
|
|
22
34
|
export const DEFAULT_MERGE_STRATEGY: WorkspaceMergeStrategy = "auto";
|
|
23
35
|
|
|
36
|
+
/** Normalize a user-facing policy word or a raw WorkspaceMergeStrategy to a WorkspaceMergeStrategy. */
|
|
37
|
+
function normalizeToMergeStrategy(value: string): WorkspaceMergeStrategy {
|
|
38
|
+
if (value === "direct") return "auto";
|
|
39
|
+
if (value === "pr" || value === "rebase-ff" || value === "auto") return value;
|
|
40
|
+
return "auto";
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Resolve the effective merge strategy from the three-level precedence chain.
|
|
45
|
+
*
|
|
46
|
+
* Precedence (highest → lowest):
|
|
47
|
+
* explicit (per-invocation --strategy or relay_workspace_land.strategy)
|
|
48
|
+
* > profileStrategy (per-profile landingStrategy)
|
|
49
|
+
* > instanceStrategy (instance-wide landing.strategy from config-store)
|
|
50
|
+
* > "direct" (hard-coded default, which maps to "auto")
|
|
51
|
+
*
|
|
52
|
+
* `explicit` accepts BOTH raw WorkspaceMergeStrategy literals ("pr", "rebase-ff",
|
|
53
|
+
* "auto") and policy words ("direct", "pr") so callers don't need to pre-normalise.
|
|
54
|
+
*/
|
|
55
|
+
export function resolveLandStrategy(opts: {
|
|
56
|
+
explicit?: string;
|
|
57
|
+
profileStrategy?: WorkspaceLandingPolicy;
|
|
58
|
+
instanceStrategy?: WorkspaceLandingPolicy;
|
|
59
|
+
}): WorkspaceMergeStrategy {
|
|
60
|
+
if (opts.explicit !== undefined && opts.explicit !== null) {
|
|
61
|
+
return normalizeToMergeStrategy(opts.explicit);
|
|
62
|
+
}
|
|
63
|
+
if (opts.profileStrategy !== undefined) {
|
|
64
|
+
return normalizeToMergeStrategy(opts.profileStrategy);
|
|
65
|
+
}
|
|
66
|
+
if (opts.instanceStrategy !== undefined) {
|
|
67
|
+
return normalizeToMergeStrategy(opts.instanceStrategy);
|
|
68
|
+
}
|
|
69
|
+
return DEFAULT_MERGE_STRATEGY;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Derive the issue number from a workspace metadata bag.
|
|
74
|
+
* Checks `metadata.issueNumber` (number or numeric string) and
|
|
75
|
+
* `metadata.taskId` (number or numeric string) — both common shapes.
|
|
76
|
+
* Returns the integer if found, otherwise undefined.
|
|
77
|
+
*/
|
|
78
|
+
function resolveIssueNumber(metadata: Record<string, unknown>): number | undefined {
|
|
79
|
+
for (const key of ["issueNumber", "taskId"]) {
|
|
80
|
+
const raw = metadata[key];
|
|
81
|
+
if (typeof raw === "number" && Number.isSafeInteger(raw) && raw > 0) return raw;
|
|
82
|
+
if (typeof raw === "string") {
|
|
83
|
+
const n = Number.parseInt(raw, 10);
|
|
84
|
+
if (Number.isSafeInteger(n) && n > 0) return n;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return undefined;
|
|
88
|
+
}
|
|
89
|
+
|
|
24
90
|
interface RequestWorkspaceMergeOptions {
|
|
25
91
|
/** Who asked for the merge (lease holder + audit). e.g. an agent id, "dashboard", "auto-merge". */
|
|
26
92
|
requestedBy: string;
|
|
@@ -33,6 +99,15 @@ interface RequestWorkspaceMergeOptions {
|
|
|
33
99
|
push?: boolean;
|
|
34
100
|
prTitle?: string;
|
|
35
101
|
prBody?: string;
|
|
102
|
+
/**
|
|
103
|
+
* Auto-merge policy for a PR-strategy land. When the effective strategy is "pr" and
|
|
104
|
+
* this is unset, defaults to "on-green" — a pr land MUST always reach a terminal
|
|
105
|
+
* state and should not block on a human hitting the merge button. Ignored for non-pr
|
|
106
|
+
* strategies (omitted from the command params entirely so the host stays clean).
|
|
107
|
+
*/
|
|
108
|
+
autoMerge?: WorkspaceAutoMergePolicy;
|
|
109
|
+
/** Optional reviewer handle (GitHub login or team slug) to request on the opened PR. */
|
|
110
|
+
reviewer?: string;
|
|
36
111
|
/** Extra metadata merged onto the workspace row when moving to merge_planned. */
|
|
37
112
|
metadata?: Record<string, unknown>;
|
|
38
113
|
}
|
|
@@ -101,6 +176,28 @@ export function requestWorkspaceMerge(workspace: WorkspaceRecord, opts: RequestW
|
|
|
101
176
|
releaseMergeLease({ repoRoot: workspace.repoRoot, workspaceId: workspace.id });
|
|
102
177
|
return { ok: false, status: 404, error: "workspace not found" };
|
|
103
178
|
}
|
|
179
|
+
const effectiveStrategy = opts.strategy ?? DEFAULT_MERGE_STRATEGY;
|
|
180
|
+
|
|
181
|
+
// Slice 6 — issue auto-linkage: append "Closes #N" to the PR body when the
|
|
182
|
+
// workspace metadata carries an issue number, WITHOUT clobbering an explicit body.
|
|
183
|
+
const issueNumber = resolveIssueNumber(workspace.metadata);
|
|
184
|
+
let prBody = opts.prBody;
|
|
185
|
+
if (issueNumber !== undefined) {
|
|
186
|
+
const closesLine = `Closes #${issueNumber}`;
|
|
187
|
+
if (!prBody) {
|
|
188
|
+
prBody = closesLine;
|
|
189
|
+
} else if (!prBody.includes(closesLine)) {
|
|
190
|
+
prBody = `${prBody}\n\n${closesLine}`;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Slice 3 — auto-merge default for PR lands: a pr-strategy land MUST reach a
|
|
195
|
+
// terminal state; default to "on-green" so CI can close the loop automatically.
|
|
196
|
+
const isPrStrategy = effectiveStrategy === "pr";
|
|
197
|
+
const autoMerge: WorkspaceAutoMergePolicy | undefined = isPrStrategy
|
|
198
|
+
? (opts.autoMerge ?? "on-green")
|
|
199
|
+
: undefined;
|
|
200
|
+
|
|
104
201
|
const command = createCommand({
|
|
105
202
|
type: "workspace.merge",
|
|
106
203
|
source: "system",
|
|
@@ -114,11 +211,13 @@ export function requestWorkspaceMerge(workspace: WorkspaceRecord, opts: RequestW
|
|
|
114
211
|
branch: workspace.branch,
|
|
115
212
|
baseRef: workspace.baseRef,
|
|
116
213
|
baseSha: workspace.baseSha,
|
|
117
|
-
strategy:
|
|
214
|
+
strategy: effectiveStrategy,
|
|
118
215
|
deleteBranch,
|
|
119
216
|
push: opts.push !== false,
|
|
120
217
|
prTitle: opts.prTitle,
|
|
121
|
-
prBody
|
|
218
|
+
prBody,
|
|
219
|
+
...(autoMerge !== undefined ? { autoMerge } : {}),
|
|
220
|
+
...(opts.reviewer !== undefined ? { reviewer: opts.reviewer } : {}),
|
|
122
221
|
requestedBy: opts.requestedBy,
|
|
123
222
|
requestedAt: Date.now(),
|
|
124
223
|
},
|
package/src/workspace-phase.ts
CHANGED
|
@@ -80,6 +80,7 @@ export function worktreeReapable(state: WorktreeReapState | null | undefined): b
|
|
|
80
80
|
// an unpushed branch, a wedged steward) and the agent/human should be told —
|
|
81
81
|
// instead of the old behavior where it looked healthy for 90 minutes.
|
|
82
82
|
export const LAND_PENDING_STALL_MS = 15 * 60 * 1000;
|
|
83
|
+
export const DIRTY_WORKTREE_LAND_SKIP_REASON = "worktree has uncommitted changes";
|
|
83
84
|
|
|
84
85
|
type WorkspacePhase =
|
|
85
86
|
| "working" // active — your turn: commit, then mark ready
|
|
@@ -134,7 +135,7 @@ const READY_ACTION: WorkspaceNextAction = {
|
|
|
134
135
|
// marks ready, immune to the heartbeat `updated_at` bump) — not `updatedAt`, which
|
|
135
136
|
// keeps ticking on every heartbeat and made the stall look fresh forever.
|
|
136
137
|
export function describeWorkspacePhase(
|
|
137
|
-
workspace: Pick<WorkspaceRecord, "status" | "branch" | "stewardAgentId" | "readyAt"
|
|
138
|
+
workspace: Pick<WorkspaceRecord, "status" | "branch" | "stewardAgentId" | "readyAt"> & { metadata?: Record<string, unknown> },
|
|
138
139
|
opts: { now?: number; stallMs?: number } = {},
|
|
139
140
|
): WorkspacePhaseView {
|
|
140
141
|
switch (workspace.status) {
|
|
@@ -160,6 +161,19 @@ export function describeWorkspacePhase(
|
|
|
160
161
|
// the agent (and the dashboard) stop reporting a wedged land as healthy.
|
|
161
162
|
if (pendingMs !== undefined && pendingMs > stallMs) {
|
|
162
163
|
const mins = Math.round(pendingMs / 60_000);
|
|
164
|
+
const lastLandSkipReason = typeof workspace.metadata?.lastLandSkipReason === "string"
|
|
165
|
+
? workspace.metadata.lastLandSkipReason
|
|
166
|
+
: undefined;
|
|
167
|
+
if (lastLandSkipReason === DIRTY_WORKTREE_LAND_SKIP_REASON) {
|
|
168
|
+
return {
|
|
169
|
+
phase: "land-pending",
|
|
170
|
+
headline: `Stalled — handed off ${mins} min ago but auto-merge is blocked by uncommitted worktree changes.`,
|
|
171
|
+
hint: "Commit or stash your uncommitted changes in the worktree, then wait for Relay to retry the auto-merge. Do NOT merge, push, rebase, or touch the main checkout yourself.",
|
|
172
|
+
actionNeeded: true,
|
|
173
|
+
nextActions: [WAIT_ACTION],
|
|
174
|
+
blockers: ["owner worktree has uncommitted changes — commit or stash them in the worktree"],
|
|
175
|
+
};
|
|
176
|
+
}
|
|
163
177
|
return {
|
|
164
178
|
phase: "land-pending",
|
|
165
179
|
headline: `Stalled — handed off ${mins} min ago but still hasn't landed. A clean auto-merge runs every ~2 min, so this is past the healthy window and likely stuck (no online orchestrator, an unpushed branch, or a wedged merge/steward).`,
|