@moretea-labs/forge 1.5.0 → 1.6.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/CHANGELOG.md +28 -1
- package/DESIGN.md +7 -2
- package/README.md +7 -7
- package/README.zh-CN.md +5 -3
- package/THIRD_PARTY_NOTICES.md +1 -1
- package/assets/hooks/pre-edit-guard.sh +8 -39
- package/assets/hooks/prompt-guard.sh +2 -2
- package/assets/plugin-registry.v1.json +54 -3
- package/assets/reference-configs/hook-operations.md +1 -1
- package/assets/reference-configs/sprint-contracts.md +2 -2
- package/assets/skill-version.json +2 -2
- package/assets/templates/helpers/ensure-task-workflow.sh +2 -2
- package/assets/templates/helpers/sprint-backlog.sh +6 -6
- package/docs/architecture/current/automation-and-schedule-engine.md +9 -3
- package/docs/architecture/current/codegraph-context-provider.md +15 -3
- package/docs/architecture/current/dispatch-and-agent-strategy.md +32 -13
- package/docs/architecture/current/entity-model.md +20 -0
- package/docs/architecture/current/external-plugin-governance.md +3 -3
- package/docs/architecture/current/system-overview.md +26 -1
- package/docs/architecture/current/thin-harness-v1.md +10 -0
- package/docs/forge-chatgpt-mcp-setup.md +27 -3
- package/docs/operations/chatgpt-personal-assistant-runtime.md +12 -2
- package/docs/operations/controller-browser-plugin.md +12 -16
- package/docs/operations/features.md +1 -1
- package/docs/operations/homebrew.md +2 -2
- package/docs/public-usage-guide.md +1 -1
- package/docs/public-usage-guide.zh-CN.md +1 -1
- package/docs/tutorials/01-install-and-start.md +6 -4
- package/docs/tutorials/01-install-and-start.zh-CN.md +3 -1
- package/docs/tutorials/02-connect-chatgpt.md +8 -2
- package/docs/tutorials/02-connect-chatgpt.zh-CN.md +8 -2
- package/docs/wiki/Installation.md +1 -1
- package/docs/wiki/Quick-Start.md +1 -1
- package/package.json +1 -1
- package/scripts/check-public-docs.sh +14 -1
- package/scripts/migrate-chatgpt-automation-schedules.ts +64 -0
- package/scripts/sprint-backlog.sh +1 -1
- package/src/cli/commands/chatgpt.ts +31 -2
- package/src/cli/commands/init-hook.ts +3 -1
- package/src/cli/commands/mcp.ts +2 -0
- package/src/cli/commands/plugin.ts +36 -9
- package/src/cli/commands/runtime.ts +1 -0
- package/src/cli/commands/setup-profile.ts +49 -18
- package/src/cli/controller/context-pack.ts +158 -6
- package/src/cli/hook/prompt-guard-decision.ts +17 -18
- package/src/cli/hook/prompt-intents.ts +1 -1
- package/src/cli/local-bridge/console-assets.ts +17 -0
- package/src/cli/local-bridge/console-automations.ts +205 -23
- package/src/cli/local-bridge/console-view-models.ts +2 -0
- package/src/cli/local-bridge/facade-api.ts +2 -0
- package/src/cli/local-bridge/frontend/src/App.tsx +1 -1
- package/src/cli/local-bridge/frontend/src/features/automations/AutomationsView.tsx +143 -3
- package/src/cli/local-bridge/frontend/src/features/capabilities/CapabilitiesView.tsx +1 -1
- package/src/cli/local-bridge/frontend/src/features/overview/OverviewView.tsx +181 -3
- package/src/cli/local-bridge/frontend/src/features/repositories/RepositoriesView.tsx +1 -1
- package/src/cli/local-bridge/frontend/src/features/work/WorkView.tsx +4 -3
- package/src/cli/local-bridge/frontend/src/lib/api.ts +18 -6
- package/src/cli/local-bridge/frontend/src/shell/CommandBar.tsx +1 -1
- package/src/cli/local-bridge/frontend/src/shell/Sidebar.tsx +2 -1
- package/src/cli/local-bridge/frontend/src/styles.css +27 -11
- package/src/cli/local-bridge/frontend/src/types.ts +6 -2
- package/src/cli/local-bridge/server.ts +31 -20
- package/src/cli/local-bridge/ui-dist/app.css +1 -1
- package/src/cli/local-bridge/ui-dist/app.js +5003 -4815
- package/src/cli/mcp/auth.ts +2 -0
- package/src/cli/mcp/paths.ts +8 -2
- package/src/cli/mcp/repository-tools.ts +124 -9
- package/src/cli/mcp/server.ts +164 -19
- package/src/cli/mcp/setup.ts +9 -1
- package/src/cli/mcp/transports/http.ts +52 -2
- package/src/cli/mcp/transports/session-registry.ts +24 -0
- package/src/cli/repositories/command-classifier.ts +113 -0
- package/src/cli/repositories/safe-patch.ts +6 -4
- package/src/cli/repositories/structured-git.ts +49 -7
- package/src/cli/repository/inspector.ts +16 -3
- package/src/runtime/assistant/intent.ts +3 -3
- package/src/runtime/control-plane/execution/direct-edit-work-completion.ts +157 -2
- package/src/runtime/control-plane/execution/execution-identity.ts +65 -14
- package/src/runtime/control-plane/execution/validation.ts +15 -2
- package/src/runtime/control-plane/execution/work-terminal-cleanup.ts +32 -0
- package/src/runtime/control-plane/execution/work-validation-reconciler.ts +6 -7
- package/src/runtime/control-plane/execution/work-verification-snapshot.ts +2 -1
- package/src/runtime/control-plane/facade/capability-registry.ts +1 -1
- package/src/runtime/control-plane/facade/controller-session-store.ts +12 -13
- package/src/runtime/control-plane/facade/goal-workloop-access.ts +7 -2
- package/src/runtime/control-plane/facade/goal-workloop.ts +231 -8
- package/src/runtime/control-plane/facade/plan-contract-store.ts +32 -12
- package/src/runtime/control-plane/facade/requirement-board.ts +24 -8
- package/src/runtime/control-plane/facade/types.ts +33 -6
- package/src/runtime/control-plane/facade/work-contract-store.ts +31 -2
- package/src/runtime/control-plane/launcher/chatgpt-work-continuation.ts +375 -23
- package/src/runtime/control-plane/persistence/requirement-store.ts +15 -11
- package/src/runtime/diagnostics/mcp-timing.ts +6 -0
- package/src/runtime/execution/process-runtime/check-facade.ts +1 -10
- package/src/runtime/execution/process-runtime/check-result.ts +10 -2
- package/src/runtime/execution/process-runtime/command-facade.ts +1 -0
- package/src/runtime/execution/process-runtime/gc.ts +64 -10
- package/src/runtime/execution/process-runtime/persisted-check.ts +10 -2
- package/src/runtime/execution/process-runtime/resource-claims.ts +87 -2
- package/src/runtime/execution/process-runtime/runtime.ts +16 -1
- package/src/runtime/execution/process-runtime/store.ts +6 -0
- package/src/runtime/gateway/mcp/execution-tools.ts +27 -12
- package/src/runtime/gateway/mcp/router.ts +1 -1
- package/src/runtime/gateway/mcp/runtime-tools.ts +482 -178
- package/src/runtime/model-clients/deepseek-adapter.ts +0 -32
- package/src/runtime/plugins/browser-adapter.ts +19 -72
- package/src/runtime/plugins/browser-handoff-host.ts +6 -31
- package/src/runtime/plugins/browser-handoff.ts +0 -3
- package/src/runtime/plugins/browser-macos-bridge.ts +8 -0
- package/src/runtime/plugins/desktop-operator-registration.ts +8 -11
- package/src/runtime/plugins/external-registration.ts +84 -5
- package/src/runtime/plugins/first-party-registry.ts +2 -2
- package/src/runtime/plugins/gmail-adapter.ts +28 -2
- package/src/runtime/plugins/local-system-adapter.ts +2 -2
- package/src/runtime/plugins/plugin-management-adapter.ts +140 -0
- package/src/runtime/plugins/store.ts +107 -5
- package/src/runtime/plugins/types.ts +4 -0
- package/src/runtime/recovery/actions.ts +0 -10
- package/src/runtime/recovery/classifier.ts +0 -9
- package/src/runtime/recovery/maintenance-executor.ts +43 -11
- package/src/runtime/recovery/probe.ts +0 -1
- package/src/runtime/recovery/types.ts +0 -1
- package/src/runtime/root/package-connector-service.ts +136 -0
- package/src/runtime/root/package-runtime-service.ts +25 -12
- package/src/runtime/root/release-materialize.ts +161 -18
- package/src/runtime/root/service.ts +6 -4
- package/src/runtime/safe-tooling/index.ts +0 -1
- package/src/runtime/safe-tooling/types.ts +0 -30
- package/src/runtime/standalone-recovery/core.ts +2 -2
- package/src/runtime/workflow/schedules/browser-probe.ts +180 -0
- package/src/runtime/workflow/schedules/chatgpt-automation-migration.ts +40 -0
- package/src/runtime/workflow/schedules/engine.ts +357 -64
- package/src/runtime/workflow/schedules/types.ts +5 -0
- package/src/runtime/workflow/schedules/work-continuation.ts +100 -27
- package/bin/forge-desktop-helper.mjs +0 -204
- package/docs/operations/controller-desktop-plugin.md +0 -112
- package/src/runtime/plugins/desktop-adapter.ts +0 -374
- package/src/runtime/safe-tooling/browser-safe.ts +0 -133
|
@@ -364,7 +364,33 @@ function gmailActions(): AssistantPluginActionDescriptor[] {
|
|
|
364
364
|
];
|
|
365
365
|
}
|
|
366
366
|
|
|
367
|
-
function
|
|
367
|
+
export function encodeMimeHeaderValue(value: string): string {
|
|
368
|
+
if (/[\r\n]/.test(value)) {
|
|
369
|
+
throw new AssistantPluginError('PLUGIN_ACTION_ARGUMENT_INVALID', 'MIME header values must not contain CR or LF.');
|
|
370
|
+
}
|
|
371
|
+
if (/^[\x00-\x7F]*$/.test(value)) return value;
|
|
372
|
+
|
|
373
|
+
// RFC 2047 encoded-words are limited to 75 characters including wrappers.
|
|
374
|
+
// 45 UTF-8 bytes encode to at most 60 Base64 characters, leaving room for
|
|
375
|
+
// the =?UTF-8?B?...?= wrapper. Split only at Unicode code-point boundaries.
|
|
376
|
+
const chunks: string[] = [];
|
|
377
|
+
let current = '';
|
|
378
|
+
for (const character of value) {
|
|
379
|
+
const candidate = `${current}${character}`;
|
|
380
|
+
if (current && Buffer.byteLength(candidate, 'utf8') > 45) {
|
|
381
|
+
chunks.push(current);
|
|
382
|
+
current = character;
|
|
383
|
+
} else {
|
|
384
|
+
current = candidate;
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
if (current) chunks.push(current);
|
|
388
|
+
return chunks
|
|
389
|
+
.map((chunk) => `=?UTF-8?B?${Buffer.from(chunk, 'utf8').toString('base64')}?=`)
|
|
390
|
+
.join('\r\n ');
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
export function buildMimeMessage(args: Record<string, unknown>, config: GmailPluginConfig): string {
|
|
368
394
|
const to = stringArray(args.to);
|
|
369
395
|
const cc = stringArray(args.cc);
|
|
370
396
|
const bcc = stringArray(args.bcc);
|
|
@@ -378,7 +404,7 @@ function buildMimeMessage(args: Record<string, unknown>, config: GmailPluginConf
|
|
|
378
404
|
...(cc ? [`Cc: ${cc.join(', ')}`] : []),
|
|
379
405
|
...(bcc ? [`Bcc: ${bcc.join(', ')}`] : []),
|
|
380
406
|
...(config.accountEmail ? [`From: ${config.accountEmail}`] : []),
|
|
381
|
-
`Subject: ${subject}`,
|
|
407
|
+
`Subject: ${encodeMimeHeaderValue(subject)}`,
|
|
382
408
|
'Content-Type: text/plain; charset="UTF-8"',
|
|
383
409
|
'MIME-Version: 1.0',
|
|
384
410
|
'',
|
|
@@ -522,7 +522,7 @@ function actions(): AssistantPluginActionDescriptor[] {
|
|
|
522
522
|
{ actionId: 'restart_user_launch_agent', title: 'Restart verified user LaunchAgent', description: 'Restart one loaded user LaunchAgent by exact label only after launchctl evidence contains the expected program identity.', readOnly: false, risk: 'workspace_write', confirmation: 'authorization', defaultTimeoutMs: 30_000, cancellable: true, idempotent: false, scopes: ['local-system.process'], resourceClaims: controllerWrite, argumentsSchema: { type: 'object', properties: { label: { type: 'string' }, expected_program_contains: { type: 'string' } }, required: ['label', 'expected_program_contains'], additionalProperties: false } },
|
|
523
523
|
{ actionId: 'stop_user_launch_agent', title: 'Stop verified user LaunchAgent', description: 'Boot out one exact loaded user LaunchAgent only after its launchd identity matches the expected program.', readOnly: false, risk: 'workspace_write', confirmation: 'authorization', defaultTimeoutMs: 30_000, cancellable: true, idempotent: true, scopes: ['local-system.process'], resourceClaims: controllerWrite, argumentsSchema: { type: 'object', properties: { label: { type: 'string' }, expected_program_contains: { type: 'string' } }, required: ['label', 'expected_program_contains'], additionalProperties: false } },
|
|
524
524
|
{ actionId: 'start_user_launch_agent', title: 'Start verified user LaunchAgent', description: 'Bootstrap or start one exact current-user LaunchAgent from ~/Library/LaunchAgents only after the installed plist matches the expected program identity.', readOnly: false, risk: 'workspace_write', confirmation: 'authorization', defaultTimeoutMs: 30_000, cancellable: true, idempotent: true, scopes: ['local-system.process'], resourceClaims: controllerWrite, argumentsSchema: { type: 'object', properties: { label: { type: 'string' }, expected_program_contains: { type: 'string' } }, required: ['label', 'expected_program_contains'], additionalProperties: false } },
|
|
525
|
-
{ actionId: 'open_application', title: 'Open application', description: 'Open one macOS application by name or bundle id.', readOnly: false, risk: 'workspace_write', confirmation: 'authorization', defaultTimeoutMs: 30_000, cancellable: true, idempotent: false, scopes: ['local-system.open'], resourceClaims: controllerWrite, argumentsSchema: { type: 'object', properties: { app_name: { type: 'string' }, bundle_id: { type: 'string' } }, additionalProperties: false } },
|
|
525
|
+
{ actionId: 'open_application', title: 'Open application', description: 'Open one macOS application by name or bundle id in the background without stealing foreground focus.', readOnly: false, risk: 'workspace_write', confirmation: 'authorization', defaultTimeoutMs: 30_000, cancellable: true, idempotent: false, scopes: ['local-system.open'], resourceClaims: controllerWrite, argumentsSchema: { type: 'object', properties: { app_name: { type: 'string' }, bundle_id: { type: 'string' } }, additionalProperties: false } },
|
|
526
526
|
{ actionId: 'list_targets', title: 'List filesystem targets', description: 'List active expiring local filesystem grants.', readOnly: true, risk: 'readonly', confirmation: 'none', defaultTimeoutMs: 10_000, cancellable: true, idempotent: true, scopes: ['local-system.files.read'], resourceClaims: controllerRead, argumentsSchema: { type: 'object', properties: {}, additionalProperties: false } },
|
|
527
527
|
{ actionId: 'authorize_target', title: 'Authorize filesystem target', description: 'Authorize an expiring filesystem target. By default, a path inside a Git project authorizes the whole project/repository root; use scope=directory only when intentionally granting a narrower directory.', readOnly: false, risk: 'workspace_write', confirmation: 'authorization', defaultTimeoutMs: 15_000, cancellable: true, idempotent: true, scopes: ['local-system.files.write'], resourceClaims: controllerWrite, argumentsSchema: { type: 'object', properties: { target_key: { type: 'string' }, root_path: { type: 'string' }, scope: { type: 'string', enum: ['auto', 'project', 'directory'] }, expires_in_minutes: { type: 'number' }, reason: { type: 'string' }, access: { type: 'string', enum: ['read_only', 'read_write'] } }, required: ['target_key', 'root_path', 'reason'], additionalProperties: false } },
|
|
528
528
|
{ actionId: 'revoke_target', title: 'Revoke filesystem target', description: 'Immediately revoke one active filesystem target owned by the authenticated caller.', readOnly: false, risk: 'workspace_write', confirmation: 'authorization', defaultTimeoutMs: 15_000, cancellable: true, idempotent: false, scopes: ['local-system.files.write'], resourceClaims: controllerWrite, argumentsSchema: { type: 'object', properties: { target_key: { type: 'string' } }, required: ['target_key'], additionalProperties: false } },
|
|
@@ -636,7 +636,7 @@ export async function executeLocalSystemPluginAction(input: AssistantPluginActio
|
|
|
636
636
|
if ((!appName && !bundleId) || (appName && bundleId)) {
|
|
637
637
|
throw new AssistantPluginError('PLUGIN_ACTION_ARGUMENT_INVALID', 'Provide exactly one of app_name or bundle_id.', { retryable: false });
|
|
638
638
|
}
|
|
639
|
-
const command = bundleId ? ['open', '-b', bundleId] : ['open', '-a', appName as string];
|
|
639
|
+
const command = bundleId ? ['open', '-g', '-b', bundleId] : ['open', '-g', '-a', appName as string];
|
|
640
640
|
const opened = run(command[0], command.slice(1));
|
|
641
641
|
if (!opened.ok) throw new AssistantPluginError('LOCAL_SYSTEM_OPEN_FAILED', opened.stderr || opened.stdout, { retryable: true, details: { command } });
|
|
642
642
|
return { opened: true, command };
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
AssistantPluginActionDescriptor,
|
|
3
|
+
AssistantPluginActionExecutionInput,
|
|
4
|
+
AssistantPluginCapability,
|
|
5
|
+
AssistantPluginManifest,
|
|
6
|
+
AssistantPluginPermissionScope,
|
|
7
|
+
} from './types';
|
|
8
|
+
import { AssistantPluginError } from './errors';
|
|
9
|
+
import {
|
|
10
|
+
disableExternalPluginRegistration,
|
|
11
|
+
getExternalPluginRegistration,
|
|
12
|
+
installExternalPluginRegistration,
|
|
13
|
+
listExternalPluginRegistrations,
|
|
14
|
+
previewExternalPluginRegistration,
|
|
15
|
+
removeExternalPluginRegistration,
|
|
16
|
+
type ExternalPluginRegistrationInput,
|
|
17
|
+
} from './external-registration';
|
|
18
|
+
|
|
19
|
+
const PLUGIN_ID = 'plugin_management';
|
|
20
|
+
|
|
21
|
+
function now(): string { return new Date().toISOString(); }
|
|
22
|
+
function requiredString(value: unknown, field: string): string {
|
|
23
|
+
if (typeof value !== 'string' || !value.trim()) throw new AssistantPluginError('PLUGIN_ACTION_ARGUMENT_INVALID', `${field} is required.`);
|
|
24
|
+
return value.trim();
|
|
25
|
+
}
|
|
26
|
+
function expectedRevision(value: unknown): number | undefined {
|
|
27
|
+
if (value === undefined) return undefined;
|
|
28
|
+
if (typeof value !== 'number' || !Number.isInteger(value) || value < 0) {
|
|
29
|
+
throw new AssistantPluginError('PLUGIN_ACTION_ARGUMENT_INVALID', 'expected_revision must be a non-negative integer.');
|
|
30
|
+
}
|
|
31
|
+
return value;
|
|
32
|
+
}
|
|
33
|
+
function registrationInput(value: unknown): ExternalPluginRegistrationInput {
|
|
34
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
35
|
+
throw new AssistantPluginError('PLUGIN_ACTION_ARGUMENT_INVALID', 'registration must be an object.');
|
|
36
|
+
}
|
|
37
|
+
return value as ExternalPluginRegistrationInput;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function permissions(): AssistantPluginPermissionScope[] {
|
|
41
|
+
return [
|
|
42
|
+
{ scope: 'external-plugins.read', mode: 'read', description: 'Read and validate external plugin registrations.', granted: true, required: true },
|
|
43
|
+
{ scope: 'external-plugins.write', mode: 'write', description: 'Install, update, and disable validated external plugin registrations.', granted: true, required: true },
|
|
44
|
+
{ scope: 'external-plugins.remove', mode: 'write', description: 'Remove one external plugin registration after strong confirmation.', granted: true, required: true },
|
|
45
|
+
];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function capabilities(): AssistantPluginCapability[] {
|
|
49
|
+
return [{
|
|
50
|
+
capabilityId: 'external-plugin-registration-management',
|
|
51
|
+
title: 'External Plugin Registration Management',
|
|
52
|
+
description: 'Preview, install/update, inspect, disable, and remove external provider registrations through the canonical validation authority.',
|
|
53
|
+
scopes: ['external-plugins.read', 'external-plugins.write', 'external-plugins.remove'],
|
|
54
|
+
actions: ['list_registrations', 'get_registration', 'preview_registration', 'install_registration', 'disable_registration', 'remove_registration'],
|
|
55
|
+
}];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const registrationProperty = { type: 'object', description: 'ExternalPluginRegistrationInput. Canonical Forge validation rejects invalid identity, transport, lifecycle, permissions, capabilities, and actions.' };
|
|
59
|
+
function actions(): AssistantPluginActionDescriptor[] {
|
|
60
|
+
return [
|
|
61
|
+
{
|
|
62
|
+
actionId: 'list_registrations', title: 'List external plugin registrations', description: 'List canonical installed external plugin registrations.',
|
|
63
|
+
readOnly: true, risk: 'readonly', confirmation: 'none', defaultTimeoutMs: 10_000, cancellable: true, idempotent: true,
|
|
64
|
+
scopes: ['external-plugins.read'], resourceClaims: [{ resource: 'provider-state', mode: 'read' }],
|
|
65
|
+
argumentsSchema: { type: 'object', properties: {}, additionalProperties: false },
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
actionId: 'get_registration', title: 'Get external plugin registration', description: 'Read one canonical external plugin registration.',
|
|
69
|
+
readOnly: true, risk: 'readonly', confirmation: 'none', defaultTimeoutMs: 10_000, cancellable: true, idempotent: true,
|
|
70
|
+
scopes: ['external-plugins.read'], resourceClaims: [{ resource: 'provider-state', mode: 'read' }],
|
|
71
|
+
argumentsSchema: { type: 'object', properties: { plugin_id: { type: 'string' } }, required: ['plugin_id'], additionalProperties: false },
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
actionId: 'preview_registration', title: 'Preview external plugin registration', description: 'Validate registration input and return its canonical fingerprint/revision plan without mutation.',
|
|
75
|
+
readOnly: true, risk: 'readonly', confirmation: 'none', defaultTimeoutMs: 10_000, cancellable: true, idempotent: true,
|
|
76
|
+
scopes: ['external-plugins.read'], resourceClaims: [{ resource: 'provider-state', mode: 'read' }],
|
|
77
|
+
argumentsSchema: { type: 'object', properties: { registration: registrationProperty }, required: ['registration'], additionalProperties: false },
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
actionId: 'install_registration', title: 'Install or update external plugin registration', description: 'Install/update through installExternalPluginRegistration with revision guarding.',
|
|
81
|
+
readOnly: false, risk: 'workspace_write', confirmation: 'authorization', defaultTimeoutMs: 10_000, cancellable: true, idempotent: true,
|
|
82
|
+
scopes: ['external-plugins.write'], resourceClaims: [{ resource: 'provider-state', mode: 'write' }],
|
|
83
|
+
argumentsSchema: { type: 'object', properties: { registration: registrationProperty, expected_revision: { type: 'number' } }, required: ['registration'], additionalProperties: false },
|
|
84
|
+
},
|
|
85
|
+
{
|
|
86
|
+
actionId: 'disable_registration', title: 'Disable external plugin registration', description: 'Disable one installed registration while preserving its audited identity and revision history.',
|
|
87
|
+
readOnly: false, risk: 'workspace_write', confirmation: 'authorization', defaultTimeoutMs: 10_000, cancellable: true, idempotent: true,
|
|
88
|
+
scopes: ['external-plugins.write'], resourceClaims: [{ resource: 'provider-state', mode: 'write' }],
|
|
89
|
+
argumentsSchema: { type: 'object', properties: { plugin_id: { type: 'string' }, expected_revision: { type: 'number' } }, required: ['plugin_id'], additionalProperties: false },
|
|
90
|
+
},
|
|
91
|
+
{
|
|
92
|
+
actionId: 'remove_registration', title: 'Remove external plugin registration', description: 'Explicitly remove one canonical external provider registration.',
|
|
93
|
+
readOnly: false, risk: 'destructive', confirmation: 'strong_confirmation', requiredConfirmationText: 'remove-external-plugin-registration', defaultTimeoutMs: 10_000, cancellable: true, idempotent: true,
|
|
94
|
+
scopes: ['external-plugins.remove'], resourceClaims: [{ resource: 'provider-state', mode: 'write' }],
|
|
95
|
+
argumentsSchema: { type: 'object', properties: { plugin_id: { type: 'string' }, expected_revision: { type: 'number' } }, required: ['plugin_id'], additionalProperties: false },
|
|
96
|
+
},
|
|
97
|
+
];
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function buildPluginManagementManifest(previousRevision = 0, previousUpdatedAt?: string): AssistantPluginManifest {
|
|
101
|
+
return {
|
|
102
|
+
schemaVersion: 1, manifestVersion: 1, revision: Math.max(1, previousRevision || 1), pluginId: PLUGIN_ID,
|
|
103
|
+
provider: 'forge-controller', displayName: 'Forge Plugin Management', pluginVersion: '1.0.0',
|
|
104
|
+
authority: { strategy: 'derived', duplicateStateAllowed: false, sourceOfTruth: ['controller-home:plugins/external/registrations'] },
|
|
105
|
+
enabled: true, lifecycle: { state: 'enabled', reason: 'Canonical external plugin registration authority is available.' },
|
|
106
|
+
health: { state: 'ready', checkedAt: now(), ready: true, probed: true, errors: [], warnings: [] },
|
|
107
|
+
permissions: permissions(), capabilities: capabilities(), actions: actions(), updatedAt: previousUpdatedAt ?? now(),
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export async function executePluginManagementAction(input: AssistantPluginActionExecutionInput): Promise<Record<string, unknown>> {
|
|
112
|
+
switch (input.actionId) {
|
|
113
|
+
case 'list_registrations': return { registrations: listExternalPluginRegistrations(input.controllerHome) };
|
|
114
|
+
case 'get_registration': {
|
|
115
|
+
const pluginId = requiredString(input.args.plugin_id, 'plugin_id');
|
|
116
|
+
const registration = getExternalPluginRegistration(input.controllerHome, pluginId);
|
|
117
|
+
if (!registration) throw new AssistantPluginError('PLUGIN_EXTERNAL_REGISTRATION_NOT_FOUND', `External plugin registration ${pluginId} was not found.`, { retryable: false });
|
|
118
|
+
return { registration };
|
|
119
|
+
}
|
|
120
|
+
case 'preview_registration': return { preview: previewExternalPluginRegistration(input.controllerHome, registrationInput(input.args.registration)) };
|
|
121
|
+
case 'install_registration': return { registration: installExternalPluginRegistration(input.controllerHome, registrationInput(input.args.registration), { expectedRevision: expectedRevision(input.args.expected_revision) }) };
|
|
122
|
+
case 'disable_registration': return { registration: disableExternalPluginRegistration(input.controllerHome, requiredString(input.args.plugin_id, 'plugin_id'), { expectedRevision: expectedRevision(input.args.expected_revision) }) };
|
|
123
|
+
case 'remove_registration': return { removed: removeExternalPluginRegistration(input.controllerHome, requiredString(input.args.plugin_id, 'plugin_id'), { expectedRevision: expectedRevision(input.args.expected_revision) }) };
|
|
124
|
+
default: throw new AssistantPluginError('PLUGIN_ACTION_NOT_SUPPORTED', `${PLUGIN_ID}/${input.actionId} is not supported.`, { retryable: false });
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function affectedPluginIdsAfterAction(actionId: string, result: Record<string, unknown>): string[] {
|
|
129
|
+
if (!['install_registration', 'disable_registration', 'remove_registration'].includes(actionId)) return [];
|
|
130
|
+
const value = (result.registration ?? result.removed) as { pluginId?: unknown } | undefined;
|
|
131
|
+
return typeof value?.pluginId === 'string' && value.pluginId.trim() ? [value.pluginId.trim()] : [];
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export const pluginManagementAdapter = {
|
|
135
|
+
pluginId: PLUGIN_ID,
|
|
136
|
+
scope: 'controller' as const,
|
|
137
|
+
buildManifest: buildPluginManagementManifest,
|
|
138
|
+
executeAction: executePluginManagementAction,
|
|
139
|
+
affectedPluginIdsAfterAction,
|
|
140
|
+
};
|
|
@@ -2,7 +2,7 @@ import { createHash } from 'crypto';
|
|
|
2
2
|
import { join } from 'path';
|
|
3
3
|
import type { RepositoryRecord } from '../../cli/repositories/types';
|
|
4
4
|
import { CONTROLLER_SCOPE_REPO_ID, controllerSystemRoot, ensureControllerHome, repositoryControllerRoot } from '../../cli/repositories/controller-home';
|
|
5
|
-
import { existsSync, mkdirSync, readdirSync} from 'fs';
|
|
5
|
+
import { existsSync, mkdirSync, readdirSync, rmSync } from 'fs';
|
|
6
6
|
import type { ExecutionJob, ResourceClaimSpec } from '../execution/jobs/types';
|
|
7
7
|
import { appendRuntimeEvent } from '../evidence/event-ledger';
|
|
8
8
|
import { readJsonFile, sanitizeFileComponent, writeJsonAtomic } from '../shared/json-files';
|
|
@@ -18,7 +18,7 @@ import {
|
|
|
18
18
|
recordWorkCompletionReceipt,
|
|
19
19
|
updateWorkContract,
|
|
20
20
|
} from '../control-plane/facade/work-contract-store';
|
|
21
|
-
import type
|
|
21
|
+
import { isTerminalWorkContractStatus, type LocalEffectCompletionReceipt, type RemoteEffectCompletionReceipt, type WorkContract, type WorkRisk } from '../control-plane/facade/types';
|
|
22
22
|
import type {
|
|
23
23
|
AssistantPluginAdapter,
|
|
24
24
|
AssistantPluginActionDescriptor,
|
|
@@ -671,6 +671,74 @@ function localEffectTarget(
|
|
|
671
671
|
};
|
|
672
672
|
}
|
|
673
673
|
|
|
674
|
+
function remoteEffectWorkForPluginAction(
|
|
675
|
+
controllerHome: string,
|
|
676
|
+
repository: RepositoryRecord,
|
|
677
|
+
action: AssistantPluginActionDescriptor,
|
|
678
|
+
request: AssistantPluginActionRequest,
|
|
679
|
+
receiptId?: string,
|
|
680
|
+
): WorkContract | undefined {
|
|
681
|
+
const workId = request.workId?.trim();
|
|
682
|
+
if (!workId) return undefined;
|
|
683
|
+
if (action.risk !== 'remote_write') {
|
|
684
|
+
throw new Error(`WORK_PLUGIN_RECEIPT_BINDING_REQUIRES_REMOTE_WRITE: ${action.actionId}`);
|
|
685
|
+
}
|
|
686
|
+
const work = getWorkContract({ controllerHome, repoId: repository.repoId }, workId);
|
|
687
|
+
if (!work) throw new Error(`WORK_PLUGIN_RECEIPT_BINDING_NOT_FOUND: ${workId}`);
|
|
688
|
+
if (work.workKind !== 'remote_effect') {
|
|
689
|
+
throw new Error(`WORK_PLUGIN_RECEIPT_BINDING_KIND_MISMATCH: ${workId} is ${work.workKind}, expected remote_effect`);
|
|
690
|
+
}
|
|
691
|
+
if (work.checks.length > 0) {
|
|
692
|
+
throw new Error(`WORK_PLUGIN_RECEIPT_BINDING_CHECKS_PRESENT: ${workId} declares repository checks and cannot be completed by a plugin receipt alone`);
|
|
693
|
+
}
|
|
694
|
+
if (isTerminalWorkContractStatus(work.status)) {
|
|
695
|
+
if (work.status === 'completed' && receiptId && work.completionReceipt?.receiptId === receiptId) return work;
|
|
696
|
+
throw new Error(`WORK_PLUGIN_RECEIPT_BINDING_TERMINAL: ${workId} is ${work.status}`);
|
|
697
|
+
}
|
|
698
|
+
return work;
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
function bindRemoteEffectReceiptToWork(
|
|
702
|
+
controllerHome: string,
|
|
703
|
+
repository: RepositoryRecord,
|
|
704
|
+
action: AssistantPluginActionDescriptor,
|
|
705
|
+
request: AssistantPluginActionRequest,
|
|
706
|
+
receipt: PluginActionReceipt,
|
|
707
|
+
): WorkContract | undefined {
|
|
708
|
+
if (!request.workId) return undefined;
|
|
709
|
+
if (receipt.status !== 'succeeded') return remoteEffectWorkForPluginAction(controllerHome, repository, action, request);
|
|
710
|
+
const work = remoteEffectWorkForPluginAction(controllerHome, repository, action, request, receipt.receiptId);
|
|
711
|
+
if (!work) return undefined;
|
|
712
|
+
if (work.status === 'completed' && work.completionReceipt?.receiptId === receipt.receiptId) return work;
|
|
713
|
+
if (!work.evidenceRefs.some((evidence) => evidence.evidenceId === receipt.receiptId)) {
|
|
714
|
+
appendWorkEvidence({ controllerHome, repoId: repository.repoId }, work.workId, {
|
|
715
|
+
evidenceId: receipt.receiptId,
|
|
716
|
+
title: 'typed remote plugin effect completed',
|
|
717
|
+
summary: `${receipt.pluginId}/${receipt.actionId} completed with durable receipt ${receipt.receiptId}.`,
|
|
718
|
+
detailLevel: 'summary',
|
|
719
|
+
});
|
|
720
|
+
}
|
|
721
|
+
const remoteReceipt: RemoteEffectCompletionReceipt = {
|
|
722
|
+
schemaVersion: 1,
|
|
723
|
+
receiptId: receipt.receiptId,
|
|
724
|
+
source: 'remote_effect',
|
|
725
|
+
workId: work.workId,
|
|
726
|
+
pluginId: receipt.pluginId,
|
|
727
|
+
actionId: receipt.actionId,
|
|
728
|
+
requestId: receipt.requestId,
|
|
729
|
+
semanticKey: receipt.semanticKey,
|
|
730
|
+
resultDigest: createHash('sha256').update(JSON.stringify(receipt.result ?? null)).digest('hex'),
|
|
731
|
+
recordedAt: new Date().toISOString(),
|
|
732
|
+
};
|
|
733
|
+
return recordWorkCompletionReceipt(
|
|
734
|
+
{ controllerHome, repoId: repository.repoId },
|
|
735
|
+
work.workId,
|
|
736
|
+
remoteReceipt,
|
|
737
|
+
'completed_remote',
|
|
738
|
+
'remote_effect',
|
|
739
|
+
);
|
|
740
|
+
}
|
|
741
|
+
|
|
674
742
|
function compatibilityJobFromReceipt(receipt: PluginActionReceipt, checkoutId: string): ExecutionJob {
|
|
675
743
|
return {
|
|
676
744
|
schemaVersion: 1,
|
|
@@ -741,6 +809,10 @@ export async function submitAssistantPluginAction(
|
|
|
741
809
|
if (!existing) {
|
|
742
810
|
throw new Error(`PLUGIN_RECEIPT_LOST: ${request.requestId}`);
|
|
743
811
|
}
|
|
812
|
+
if (request.workId && existing.workId !== request.workId) {
|
|
813
|
+
throw new Error(`WORK_PLUGIN_RECEIPT_BINDING_CONFLICT: ${request.requestId} already belongs to Work ${existing.workId ?? 'none'}`);
|
|
814
|
+
}
|
|
815
|
+
if (request.workId) bindRemoteEffectReceiptToWork(controllerHome, repository, action, request, existing);
|
|
744
816
|
return {
|
|
745
817
|
manifest,
|
|
746
818
|
action,
|
|
@@ -754,6 +826,7 @@ export async function submitAssistantPluginAction(
|
|
|
754
826
|
|
|
755
827
|
const createdAt = new Date().toISOString();
|
|
756
828
|
const receiptId = `PLG-${Date.now()}-${createHash('sha256').update(request.requestId).digest('hex').slice(0, 8)}`;
|
|
829
|
+
const boundRemoteWork = remoteEffectWorkForPluginAction(controllerHome, repository, action, request);
|
|
757
830
|
const requiresLocalEffectWork = localSystemActionRequiresWork(
|
|
758
831
|
repository,
|
|
759
832
|
request.pluginId,
|
|
@@ -833,6 +906,17 @@ export async function submitAssistantPluginAction(
|
|
|
833
906
|
: undefined,
|
|
834
907
|
});
|
|
835
908
|
let resultWithLineage = result;
|
|
909
|
+
if (boundRemoteWork) {
|
|
910
|
+
resultWithLineage = {
|
|
911
|
+
...result,
|
|
912
|
+
work: {
|
|
913
|
+
workId: boundRemoteWork.workId,
|
|
914
|
+
workKind: 'remote_effect',
|
|
915
|
+
completionOutcome: 'completed_remote',
|
|
916
|
+
status: 'completed',
|
|
917
|
+
},
|
|
918
|
+
};
|
|
919
|
+
}
|
|
836
920
|
if (acceptedWork) {
|
|
837
921
|
const recordedAt = new Date().toISOString();
|
|
838
922
|
const target = localEffectTarget(normalizedArgs, result);
|
|
@@ -877,7 +961,7 @@ export async function submitAssistantPluginAction(
|
|
|
877
961
|
semanticKey: key,
|
|
878
962
|
status: 'succeeded',
|
|
879
963
|
createdAt,
|
|
880
|
-
...(acceptedWork ? { workId: acceptedWork.workId } : {}),
|
|
964
|
+
...(acceptedWork ? { workId: acceptedWork.workId } : boundRemoteWork ? { workId: boundRemoteWork.workId } : {}),
|
|
881
965
|
origin: request.origin,
|
|
882
966
|
result: resultWithLineage,
|
|
883
967
|
};
|
|
@@ -889,6 +973,7 @@ export async function submitAssistantPluginAction(
|
|
|
889
973
|
semanticKey: key,
|
|
890
974
|
createdAt,
|
|
891
975
|
} satisfies PluginActionRequestIndex);
|
|
976
|
+
if (boundRemoteWork) bindRemoteEffectReceiptToWork(controllerHome, repository, action, request, receipt);
|
|
892
977
|
const nextManifest = getAssistantPluginManifest(controllerHome, repository, request.pluginId);
|
|
893
978
|
return {
|
|
894
979
|
manifest: nextManifest,
|
|
@@ -897,11 +982,18 @@ export async function submitAssistantPluginAction(
|
|
|
897
982
|
deduplicated: false,
|
|
898
983
|
result: resultWithLineage,
|
|
899
984
|
receipt,
|
|
900
|
-
workId: acceptedWork?.workId,
|
|
985
|
+
workId: acceptedWork?.workId ?? boundRemoteWork?.workId,
|
|
901
986
|
};
|
|
902
987
|
} catch (error) {
|
|
903
988
|
const message = error instanceof Error ? error.message : String(error);
|
|
904
989
|
const code = /^([A-Z][A-Z0-9_]+)/.exec(message)?.[1] ?? 'PLUGIN_ACTION_FAILED';
|
|
990
|
+
if (boundRemoteWork) {
|
|
991
|
+
appendWorkEvidence({ controllerHome, repoId: repository.repoId }, boundRemoteWork.workId, {
|
|
992
|
+
title: 'typed remote plugin effect failed',
|
|
993
|
+
summary: `${request.pluginId}/${request.actionId}: ${message}`.slice(0, 1_000),
|
|
994
|
+
detailLevel: 'summary',
|
|
995
|
+
});
|
|
996
|
+
}
|
|
905
997
|
if (acceptedWork) {
|
|
906
998
|
const current = getWorkContract({ controllerHome, repoId: repository.repoId }, acceptedWork.workId);
|
|
907
999
|
updateWorkContract({ controllerHome, repoId: repository.repoId }, acceptedWork.workId, {
|
|
@@ -926,7 +1018,7 @@ export async function submitAssistantPluginAction(
|
|
|
926
1018
|
semanticKey: key,
|
|
927
1019
|
status: 'failed',
|
|
928
1020
|
createdAt,
|
|
929
|
-
...(acceptedWork ? { workId: acceptedWork.workId } : {}),
|
|
1021
|
+
...(acceptedWork ? { workId: acceptedWork.workId } : boundRemoteWork ? { workId: boundRemoteWork.workId } : {}),
|
|
930
1022
|
origin: request.origin,
|
|
931
1023
|
error: { code, message },
|
|
932
1024
|
};
|
|
@@ -963,6 +1055,16 @@ export async function executeAssistantPluginAction(
|
|
|
963
1055
|
args: normalizedArgs,
|
|
964
1056
|
providerIdentityPrevalidated: manifestLookup.providerIdentityPrevalidated,
|
|
965
1057
|
});
|
|
1058
|
+
for (const affectedPluginId of adapter.affectedPluginIdsAfterAction?.(input.actionId, result) ?? []) {
|
|
1059
|
+
if (!affectedPluginId || affectedPluginId === input.pluginId) continue;
|
|
1060
|
+
invalidateAssistantPluginManifestCache(input.controllerHome, repository.repoId, affectedPluginId);
|
|
1061
|
+
const affectedAdapter = resolvePluginAdapter(input.controllerHome, affectedPluginId);
|
|
1062
|
+
if (affectedAdapter && adapterMatchesRepository(affectedAdapter, repository)) {
|
|
1063
|
+
syncAssistantPluginManifest(input.controllerHome, repository, affectedPluginId);
|
|
1064
|
+
} else {
|
|
1065
|
+
rmSync(manifestPath(input.controllerHome, repository.repoId, affectedPluginId), { force: true });
|
|
1066
|
+
}
|
|
1067
|
+
}
|
|
966
1068
|
const refreshManifest = adapter.shouldRefreshManifestAfterAction?.(input.actionId) ?? true;
|
|
967
1069
|
const nextManifest = refreshManifest
|
|
968
1070
|
? syncAssistantPluginManifest(input.controllerHome, repository, input.pluginId).manifest
|
|
@@ -100,6 +100,8 @@ export interface AssistantPluginActionRequest {
|
|
|
100
100
|
pluginId: string;
|
|
101
101
|
actionId: string;
|
|
102
102
|
requestId: string;
|
|
103
|
+
/** Optional existing external-effect Work that owns this typed action receipt. */
|
|
104
|
+
workId?: string;
|
|
103
105
|
args: Record<string, unknown>;
|
|
104
106
|
timeoutMs?: number;
|
|
105
107
|
signal?: AbortSignal;
|
|
@@ -135,4 +137,6 @@ export interface AssistantPluginAdapter {
|
|
|
135
137
|
executeAction(input: AssistantPluginActionExecutionInput): Promise<Record<string, unknown>>;
|
|
136
138
|
/** Defaults to true. External providers can defer routine health refresh until the normal manifest cache expires. */
|
|
137
139
|
shouldRefreshManifestAfterAction?(actionId: string): boolean;
|
|
140
|
+
/** Other plugin manifests whose canonical authority changed as a side effect of this action. */
|
|
141
|
+
affectedPluginIdsAfterAction?(actionId: string, result: Record<string, unknown>): string[];
|
|
138
142
|
}
|
|
@@ -131,16 +131,6 @@ export const RECOVERY_ACTIONS = {
|
|
|
131
131
|
localOnly: true,
|
|
132
132
|
boundedTo: ['google_workspace_auth_handoff'],
|
|
133
133
|
},
|
|
134
|
-
browserDomainAccessPreview: {
|
|
135
|
-
id: 'recovery.browser_domain_access_preview',
|
|
136
|
-
title: 'Preview browser domain grant',
|
|
137
|
-
description: 'Preview a domain-key browser grant instead of retrying arbitrary URLs.',
|
|
138
|
-
class: 'browser_domain_grant_required',
|
|
139
|
-
risk: 'readonly',
|
|
140
|
-
confirmation: 'none',
|
|
141
|
-
localOnly: true,
|
|
142
|
-
boundedTo: ['browser_allowed_domains'],
|
|
143
|
-
},
|
|
144
134
|
externalFilesystemGrantPreview: {
|
|
145
135
|
id: 'recovery.external_filesystem_grant_preview',
|
|
146
136
|
title: 'Preview external filesystem grant',
|
|
@@ -52,13 +52,6 @@ const LOCAL_JOBS_LEGACY_ACTIVE_PATTERNS = [
|
|
|
52
52
|
/local jobs?.*finish before runtime storage/i,
|
|
53
53
|
];
|
|
54
54
|
|
|
55
|
-
const BROWSER_GRANT_PATTERNS = [
|
|
56
|
-
/web_target_not_allowed/i,
|
|
57
|
-
/browser.*domain.*not allowed/i,
|
|
58
|
-
/allowed_domains/i,
|
|
59
|
-
/domain access.*required/i,
|
|
60
|
-
/browser domain grant/i,
|
|
61
|
-
];
|
|
62
55
|
|
|
63
56
|
const EXTERNAL_FILESYSTEM_GRANT_PATTERNS = [
|
|
64
57
|
/external filesystem/i,
|
|
@@ -125,7 +118,6 @@ export function classifyFailure(message: string | undefined): RecoveryClass {
|
|
|
125
118
|
if (LOCAL_JOBS_UNREADABLE_PATTERNS.some((pattern) => pattern.test(text))) return 'local_jobs_unreadable';
|
|
126
119
|
if (RUNTIME_STORAGE_PATTERNS.some((pattern) => pattern.test(text))) return 'runtime_storage_not_ready';
|
|
127
120
|
if (AUTH_PATTERNS.some((pattern) => pattern.test(text))) return 'auth_required';
|
|
128
|
-
if (BROWSER_GRANT_PATTERNS.some((pattern) => pattern.test(text))) return 'browser_domain_grant_required';
|
|
129
121
|
if (EXTERNAL_FILESYSTEM_GRANT_PATTERNS.some((pattern) => pattern.test(text))) return 'external_filesystem_grant_required';
|
|
130
122
|
if (DIRTY_WORKTREE_CONFLICT_PATTERNS.some((pattern) => pattern.test(text))) return 'dirty_worktree_conflict';
|
|
131
123
|
if (USER_ACTION_PATTERNS.some((pattern) => pattern.test(text))) return 'user_action_required';
|
|
@@ -144,7 +136,6 @@ export function dominantRecoveryClass(classes: readonly RecoveryClass[]): Recove
|
|
|
144
136
|
'local_jobs_legacy_active',
|
|
145
137
|
'local_jobs_unreadable',
|
|
146
138
|
'runtime_storage_not_ready',
|
|
147
|
-
'browser_domain_grant_required',
|
|
148
139
|
'external_filesystem_grant_required',
|
|
149
140
|
'local_jobs_reconciliation_required',
|
|
150
141
|
'maintenance_executor_required',
|
|
@@ -6,7 +6,11 @@ import { ensureRepositoryRuntimeStorage, type RepositoryRuntimeStorageReport } f
|
|
|
6
6
|
import type { RepositoryRecord } from '../../cli/repositories/types';
|
|
7
7
|
import { rebuildRepositoryProjection } from '../projections/materialized-view';
|
|
8
8
|
import { getWorkContract, readWorkContractStore, transitionWorkContractPhase } from '../control-plane/facade/work-contract-store';
|
|
9
|
-
import {
|
|
9
|
+
import { getControllerSession } from '../control-plane/facade/controller-session-store';
|
|
10
|
+
import { listPlanContracts } from '../control-plane/facade/plan-contract-store';
|
|
11
|
+
import { readRequirement } from '../control-plane/persistence/requirement-store';
|
|
12
|
+
import { isTerminalWorkContractStatus, type WorkContract } from '../control-plane/facade/types';
|
|
13
|
+
import { listSchedules } from '../workflow/schedules/store';
|
|
10
14
|
import {
|
|
11
15
|
collectRuntimeProcesses,
|
|
12
16
|
removeRuntimeTempEntry,
|
|
@@ -397,6 +401,29 @@ function normalizedOptions(options: RuntimeMaintenanceOptions = {}): Required<Ru
|
|
|
397
401
|
};
|
|
398
402
|
}
|
|
399
403
|
|
|
404
|
+
function activeWorkAuthorityRefs(
|
|
405
|
+
repository: RuntimeMaintenanceRepository,
|
|
406
|
+
controllerHome: string,
|
|
407
|
+
contract: WorkContract,
|
|
408
|
+
): string[] {
|
|
409
|
+
const refs: string[] = [];
|
|
410
|
+
if (getControllerSession({ controllerHome, repoId: repository.repoId }, contract.workId)) refs.push('controller_session');
|
|
411
|
+
const activePlans = listPlanContracts({ controllerHome, repoId: repository.repoId, status: 'active', limit: 100 });
|
|
412
|
+
const plan = activePlans.find((candidate) => candidate.planId === contract.planId || candidate.steps.some((step) => step.workId === contract.workId));
|
|
413
|
+
if (plan) refs.push(`plan:${plan.planId}`);
|
|
414
|
+
if (contract.requirementId) {
|
|
415
|
+
const requirement = readRequirement({ controllerHome }, contract.requirementId)?.value;
|
|
416
|
+
if (requirement && !['done', 'cancelled'].includes(requirement.state)) refs.push(`requirement:${requirement.requirementId}:${requirement.state}`);
|
|
417
|
+
}
|
|
418
|
+
const boundSchedule = listSchedules(controllerHome, repository.repoId).find((schedule) => {
|
|
419
|
+
if (!schedule.enabled) return false;
|
|
420
|
+
const args = schedule.action.arguments as Record<string, unknown> | undefined;
|
|
421
|
+
return args?.work_id === contract.workId;
|
|
422
|
+
});
|
|
423
|
+
if (boundSchedule) refs.push(`schedule:${boundSchedule.scheduleId}`);
|
|
424
|
+
return refs;
|
|
425
|
+
}
|
|
426
|
+
|
|
400
427
|
function scanStaleWorkContractCandidates(
|
|
401
428
|
repository: RuntimeMaintenanceRepository,
|
|
402
429
|
controllerHome: string,
|
|
@@ -413,16 +440,21 @@ function scanStaleWorkContractCandidates(
|
|
|
413
440
|
.filter(({ ageMinutes }) => ageMinutes >= options.minAgeMinutes)
|
|
414
441
|
.sort((left, right) => right.ageMinutes - left.ageMinutes)
|
|
415
442
|
.slice(0, options.maxCandidates)
|
|
416
|
-
.map(({ contract, ageMinutes }) =>
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
443
|
+
.map(({ contract, ageMinutes }) => {
|
|
444
|
+
const authorityRefs = activeWorkAuthorityRefs(repository, controllerHome, contract);
|
|
445
|
+
return {
|
|
446
|
+
kind: 'stale_work_contract' as const,
|
|
447
|
+
id: contract.workId,
|
|
448
|
+
status: contract.status,
|
|
449
|
+
safe: authorityRefs.length === 0,
|
|
450
|
+
reason: authorityRefs.length > 0
|
|
451
|
+
? `Nonterminal WorkContract is old but still has active lifecycle authority (${authorityRefs.join(', ')}); maintenance must not cancel it from age alone.`
|
|
452
|
+
: 'Nonterminal WorkContract exceeded the explicit maintenance age threshold and has no active Plan, Requirement, Schedule, or Controller authority; explicit full maintenance may cancel it while retaining evidence.',
|
|
453
|
+
ageMinutes,
|
|
454
|
+
suggestedAction: 'full_maintenance_pass' as const,
|
|
455
|
+
ownershipStatus: 'explicit' as const,
|
|
456
|
+
};
|
|
457
|
+
});
|
|
426
458
|
}
|
|
427
459
|
|
|
428
460
|
function scanStaleEditSessionCandidates(
|
|
@@ -61,7 +61,6 @@ function failingJobClass(input: CapabilityRecoveryInput): RecoveryClass {
|
|
|
61
61
|
function suggestedActionsForClass(recoveryClass: RecoveryClass): RecoveryActionDescriptor[] {
|
|
62
62
|
if (recoveryClass === 'platform_blocked' || recoveryClass === 'dirty_worktree_conflict') return [RECOVERY_ACTIONS.createPatchHandoff];
|
|
63
63
|
if (recoveryClass === 'auth_required') return [RECOVERY_ACTIONS.workspaceAuthLoginPrepare];
|
|
64
|
-
if (recoveryClass === 'browser_domain_grant_required') return [RECOVERY_ACTIONS.browserDomainAccessPreview];
|
|
65
64
|
if (recoveryClass === 'external_filesystem_grant_required') return [RECOVERY_ACTIONS.externalFilesystemGrantPreview];
|
|
66
65
|
if (recoveryClass === 'agent_runtime_failure') return [RECOVERY_ACTIONS.reconcileJobs];
|
|
67
66
|
if (recoveryClass === 'source_defect_suspected' || recoveryClass === 'external_lifecycle_required') return [];
|