@moretea-labs/forge 1.5.0-rc.1 → 1.5.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/AGENTS.md +9 -11
- package/CHANGELOG.md +27 -1
- package/CLAUDE.md +8 -10
- package/DESIGN.md +278 -0
- package/README.md +70 -90
- package/README.zh-CN.md +52 -55
- package/SKILL.md +8 -11
- package/THIRD_PARTY_NOTICES.md +9 -3
- package/assets/branding/README.md +4 -2
- package/assets/hooks/lib/workflow-state.sh +19 -18
- package/assets/hooks/post-edit-guard.sh +6 -75
- package/assets/hooks/prompt-guard.sh +24 -12
- package/assets/hooks/session-start-context.sh +17 -17
- package/assets/hooks/stop-orchestrator.sh +2 -2
- package/assets/partials/04-project-structure.partial.md +6 -6
- package/assets/partials/05-workflow.partial.md +6 -6
- package/assets/partials/07-footer.partial.md +1 -1
- package/assets/partials/08-orchestration.partial.md +2 -2
- package/assets/partials-agents/02-operating-mode.partial.md +5 -5
- package/assets/partials-agents/03-orchestration.partial.md +3 -3
- package/assets/partials-agents/04-task-protocol.partial.md +3 -3
- package/assets/partials-agents/08-deep-docs.partial.md +1 -1
- package/assets/reference-configs/agentic-development-flow.md +49 -86
- package/assets/reference-configs/external-tooling.md +21 -61
- package/assets/reference-configs/handoff-protocol.md +25 -20
- package/assets/reference-configs/harness-overview.md +8 -8
- package/assets/skill-version.json +2 -2
- package/assets/templates/helpers/archive-workflow.sh +0 -1
- package/assets/templates/helpers/capture-plan.sh +3 -3
- package/assets/templates/helpers/check-task-workflow.sh +2 -2
- package/assets/templates/helpers/codex-handoff-resume.sh +5 -5
- package/assets/templates/helpers/ensure-task-workflow.sh +37 -35
- package/assets/templates/helpers/new-plan.sh +1 -1
- package/assets/templates/helpers/plan-to-todo.sh +2 -2
- package/assets/templates/helpers/prepare-codex-handoff.sh +2 -2
- package/assets/templates/helpers/prepare-handoff.sh +9 -9
- package/assets/templates/helpers/refresh-current-status.sh +2 -2
- package/assets/templates/helpers/ship-worktrees.sh +1 -1
- package/assets/templates/helpers/sprint-backlog.sh +1 -1
- package/assets/templates/helpers/verify-sprint.sh +7 -7
- package/assets/templates/helpers/workstream-sync.sh +1 -1
- package/assets/templates/plan.template.md +1 -1
- package/assets/workflow-contract.v1.json +6 -3
- package/docs/architecture/current/README.md +10 -9
- package/docs/architecture/current/approved-target-architecture.zh-CN.md +2 -2
- package/docs/architecture/current/architecture-invariants.md +2 -2
- package/docs/architecture/current/automation-and-schedule-engine.md +15 -15
- package/docs/architecture/current/codegraph-context-provider.md +2 -2
- package/docs/architecture/current/control-plane-authority-inventory.md +12 -0
- package/docs/architecture/current/dispatch-and-agent-strategy.md +11 -11
- package/docs/architecture/current/entity-model.md +0 -27
- package/docs/architecture/current/failure-recovery.md +0 -24
- package/docs/architecture/current/governance.md +1 -1
- package/docs/architecture/current/implementation-status.md +1 -1
- package/docs/architecture/current/legacy-governance.md +58 -0
- package/docs/architecture/current/multi-repository-execution.md +1 -1
- package/docs/architecture/current/peer-supercontroller-runtime.md +1 -1
- package/docs/architecture/current/personal-assistant-plugin-baseline.md +4 -5
- package/docs/architecture/current/scheduler-and-resource-claims.md +0 -10
- package/docs/architecture/current/target-requirements-traceability.md +2 -2
- package/docs/architecture/current/thin-harness-governance-policy.md +1 -1
- package/docs/architecture/current/thin-harness-v1.md +9 -11
- package/docs/architecture/domains/runtime-harness.md +1 -1
- package/docs/forge-autonomous-goal-loop.md +3 -1
- package/docs/forge-chatgpt-mcp-setup.md +67 -176
- package/docs/images/forge-banner-cn.svg +26 -1
- package/docs/images/forge-banner.svg +26 -1
- package/docs/operations/features.md +1 -1
- package/docs/operations/homebrew.md +3 -3
- package/docs/operations/mcp-tool-exposure.md +5 -5
- package/docs/operations/platform-support.md +45 -37
- package/docs/operations/platform-support.zh-CN.md +40 -38
- package/docs/operations/releasing.md +3 -3
- package/docs/operations/releasing.zh-CN.md +3 -3
- package/docs/operations/troubleshooting.md +11 -6
- package/docs/operations/troubleshooting.zh-CN.md +11 -6
- package/docs/public-usage-guide.md +28 -21
- package/docs/public-usage-guide.zh-CN.md +28 -21
- package/docs/tutorials/01-install-and-start.md +58 -35
- package/docs/tutorials/01-install-and-start.zh-CN.md +58 -35
- package/docs/tutorials/02-connect-chatgpt.md +118 -46
- package/docs/tutorials/02-connect-chatgpt.zh-CN.md +119 -45
- package/docs/wiki/Architecture.md +1 -1
- package/docs/wiki/Installation.md +2 -2
- package/docs/wiki/Quick-Start.md +1 -1
- package/docs/wiki/Work-Lifecycle.md +1 -1
- package/install.ps1 +4 -5
- package/install.sh +4 -6
- package/package.json +15 -5
- package/scripts/archive-workflow.sh +0 -1
- package/scripts/benchmark-thin-harness-mcp-e2e.ts +27 -14
- package/scripts/build-controller-ui.ts +28 -0
- package/scripts/capture-plan.sh +4 -4
- package/scripts/check-agent-tooling.sh +12 -1
- package/scripts/check-platform-support.mjs +2 -2
- package/scripts/check-public-docs.sh +16 -5
- package/scripts/check-release-readiness.sh +5 -11
- package/scripts/check-runtime-architecture.mjs +14 -22
- package/scripts/check-tarball-install-smoke.sh +3 -2
- package/scripts/check-task-workflow.sh +2 -2
- package/scripts/codex-handoff-resume.sh +5 -5
- package/scripts/create-project-dirs.sh +3 -3
- package/scripts/ensure-task-workflow.sh +45 -35
- package/scripts/init-project.sh +4 -4
- package/scripts/install-desktop-operator-registration.ts +2 -2
- package/scripts/lib/project-init-lib.sh +46 -44
- package/scripts/migrate-project-template.sh +7 -4
- package/scripts/new-plan.sh +1 -1
- package/scripts/plan-to-todo.sh +2 -2
- package/scripts/prepare-codex-handoff.sh +2 -2
- package/scripts/prepare-handoff.sh +9 -9
- package/scripts/refresh-current-status.sh +2 -2
- package/scripts/setup-plugins.sh +3 -3
- package/scripts/ship-worktrees.sh +1 -1
- package/scripts/sprint-backlog.sh +5 -5
- package/scripts/sync-codex-installed-copies.sh +2 -3
- package/scripts/verify-sprint.sh +7 -7
- package/scripts/workstream-sync.sh +1 -1
- package/src/cli/chatgpt-browser/bridge-extension.ts +1 -1
- package/src/cli/chatgpt-browser/bridge-provider.ts +29 -0
- package/src/cli/chatgpt-browser/file-policy.ts +3 -1
- package/src/cli/commands/chatgpt.ts +51 -0
- package/src/cli/commands/doctor.ts +1 -1
- package/src/cli/commands/init-hook.ts +213 -70
- package/src/cli/commands/init.ts +5 -6
- package/src/cli/commands/mcp.ts +31 -5
- package/src/cli/commands/runtime.ts +61 -8
- package/src/cli/commands/setup-profile.ts +472 -0
- package/src/cli/controller/check-runner.ts +20 -3
- package/src/cli/controller/codex-continuation.ts +4 -7
- package/src/cli/controller/context-pack.ts +118 -14
- package/src/cli/controller/operational-plan.ts +4 -4
- package/src/cli/controller/task-ledger.ts +10 -10
- package/src/cli/controller/work-mode.ts +6 -13
- package/src/cli/index.ts +14 -35
- package/src/cli/local-bridge/connector-freshness.ts +1 -1
- package/src/cli/local-bridge/console-assets.ts +17 -0
- package/src/cli/local-bridge/console-automations.ts +160 -0
- package/src/cli/local-bridge/dashboard.ts +8 -1303
- package/src/cli/local-bridge/facade-api.ts +16 -7
- package/src/cli/local-bridge/frontend/src/App.tsx +10 -0
- package/src/cli/local-bridge/frontend/src/components/Button.tsx +1 -0
- package/src/cli/local-bridge/frontend/src/components/DetailPane.tsx +3 -0
- package/src/cli/local-bridge/frontend/src/components/SearchField.tsx +1 -0
- package/src/cli/local-bridge/frontend/src/components/SectionHeader.tsx +1 -0
- package/src/cli/local-bridge/frontend/src/components/Segmented.tsx +1 -0
- package/src/cli/local-bridge/frontend/src/components/StatusText.tsx +3 -0
- package/src/cli/local-bridge/frontend/src/design/icons.tsx +11 -0
- package/src/cli/local-bridge/frontend/src/design/tokens.css +8 -0
- package/src/cli/local-bridge/frontend/src/features/automations/AutomationsView.tsx +3 -0
- package/src/cli/local-bridge/frontend/src/features/capabilities/CapabilitiesView.tsx +4 -0
- package/src/cli/local-bridge/frontend/src/features/overview/OverviewView.tsx +3 -0
- package/src/cli/local-bridge/frontend/src/features/repositories/RepositoriesView.tsx +2 -0
- package/src/cli/local-bridge/frontend/src/features/settings/SettingsView.tsx +2 -0
- package/src/cli/local-bridge/frontend/src/features/system/SystemView.tsx +2 -0
- package/src/cli/local-bridge/frontend/src/features/work/WorkView.tsx +3 -0
- package/src/cli/local-bridge/frontend/src/lib/api.ts +23 -0
- package/src/cli/local-bridge/frontend/src/lib/format.ts +4 -0
- package/src/cli/local-bridge/frontend/src/lib/routes.ts +7 -0
- package/src/cli/local-bridge/frontend/src/main.tsx +1 -0
- package/src/cli/local-bridge/frontend/src/shell/AppShell.tsx +1 -0
- package/src/cli/local-bridge/frontend/src/shell/CommandBar.tsx +2 -0
- package/src/cli/local-bridge/frontend/src/shell/Sidebar.tsx +3 -0
- package/src/cli/local-bridge/frontend/src/styles.css +12 -0
- package/src/cli/local-bridge/frontend/src/types.ts +14 -0
- package/src/cli/local-bridge/frontend/src/vite-env.d.ts +1 -0
- package/src/cli/local-bridge/frontend/vite.config.ts +27 -0
- package/src/cli/local-bridge/server.ts +45 -0
- package/src/cli/local-bridge/ui-dist/app.css +1 -0
- package/src/cli/local-bridge/ui-dist/app.js +11094 -0
- package/src/cli/mcp/auth.ts +2 -0
- package/src/cli/mcp/instructions.ts +3 -3
- package/src/cli/mcp/legacy-tool-service.ts +27 -23
- package/src/cli/mcp/multi-repository.ts +2 -0
- package/src/cli/mcp/policy.ts +7 -5
- package/src/cli/mcp/repository-tools.ts +53 -14
- package/src/cli/mcp/server.ts +5 -10
- package/src/cli/mcp/setup.ts +220 -276
- package/src/cli/mcp/tool-capability-cache.ts +1 -1
- package/src/cli/mcp/toolset-names.ts +1 -1
- package/src/cli/mcp/toolset.ts +0 -1
- package/src/cli/mcp/transports/http.ts +51 -4
- package/src/cli/repositories/branch-name-policy.ts +0 -7
- package/src/cli/repositories/controller-home.ts +110 -3
- package/src/cli/repositories/safe-patch.ts +1 -1
- package/src/cli/repositories/selected-path-actions.ts +26 -24
- package/src/core/adoption/gitignore-plan.ts +2 -3
- package/src/core/adoption/plan.ts +4 -1
- package/src/runtime/control-plane/execution/edit-validation-coordinator.ts +556 -0
- package/src/runtime/control-plane/execution/work-handle-store.ts +128 -1
- package/src/runtime/control-plane/execution/work-validation-reconciler.ts +277 -0
- package/src/runtime/control-plane/execution/work-verification-snapshot.ts +169 -0
- package/src/runtime/control-plane/facade/capability-registry.ts +0 -10
- package/src/runtime/control-plane/facade/handoff-inbox-store.ts +44 -25
- package/src/runtime/control-plane/facade/suggested-actions.ts +7 -1
- package/src/runtime/control-plane/facade/types.ts +0 -1
- package/src/runtime/control-plane/global-scheduler/scheduler.ts +30 -8
- package/src/runtime/control-plane/launcher/chatgpt-work-binding-store.ts +114 -0
- package/src/runtime/control-plane/launcher/chatgpt-work-continuation.ts +175 -0
- package/src/runtime/control-plane/launcher/launch-reservation-store.ts +161 -0
- package/src/runtime/control-plane/launcher/provider-mcp-bootstrap.ts +64 -0
- package/src/runtime/control-plane/launcher/thin-launcher.ts +117 -24
- package/src/runtime/control-plane/routing/route-policy.ts +14 -14
- package/src/runtime/control-plane/runtime-cleanup.ts +0 -1
- package/src/runtime/evidence/event-ledger.ts +1 -1
- package/src/runtime/{workflow/campaigns/workspace.ts → execution/managed-workspace.ts} +41 -43
- package/src/runtime/execution/process-runtime/check-facade.ts +6 -0
- package/src/runtime/execution/process-runtime/check-result.ts +45 -0
- package/src/runtime/execution/process-runtime/check-runner-sidecar.ts +42 -16
- package/src/runtime/execution/process-runtime/command-facade.ts +4 -2
- package/src/runtime/execution/process-runtime/gc.ts +1 -1
- package/src/runtime/execution/process-runtime/index.ts +3 -0
- package/src/runtime/execution/process-runtime/interactive-admission.ts +14 -5
- package/src/runtime/execution/process-runtime/persisted-check.ts +285 -0
- package/src/runtime/execution/process-runtime/resource-claims.ts +30 -0
- package/src/runtime/execution/process-runtime/runtime.ts +18 -1
- package/src/runtime/execution/process-runtime/store.ts +1 -19
- package/src/runtime/execution/process-runtime/types.ts +4 -0
- package/src/runtime/execution/thin-harness/execution-router.ts +0 -1
- package/src/runtime/execution/thin-harness/fast-executor.ts +1 -1
- package/src/runtime/execution/thin-harness/lightweight-lanes.ts +1 -2
- package/src/runtime/execution/thin-harness/types.ts +0 -1
- package/src/runtime/execution/workers/executor.ts +1 -1
- package/src/runtime/gateway/mcp/execution-tools.ts +1 -1
- package/src/runtime/gateway/mcp/persisted-check-process.ts +3 -255
- package/src/runtime/gateway/mcp/process-tools.ts +16 -3
- package/src/runtime/gateway/mcp/resource-policy.ts +0 -9
- package/src/runtime/gateway/mcp/router.ts +117 -182
- package/src/runtime/gateway/mcp/runtime-tools.ts +331 -409
- package/src/runtime/gateway/mcp/work-validation-reconciler.ts +1 -219
- package/src/runtime/plugins/browser-adapter.ts +5 -0
- package/src/runtime/plugins/desktop-operator-registration.ts +1 -1
- package/src/runtime/plugins/store.ts +14 -6
- package/src/runtime/plugins/types.ts +1 -1
- package/src/runtime/projections/materialized-view.ts +1 -23
- package/src/runtime/recovery/maintenance-executor.ts +9 -0
- package/src/runtime/resources/ownership.ts +0 -1
- package/src/runtime/root/gateway-adapter.ts +2 -0
- package/src/runtime/root/mcp-transport.ts +10 -2
- package/src/runtime/root/package-connector-service.ts +136 -0
- package/src/runtime/root/package-runtime-release.ts +161 -0
- package/src/runtime/root/package-runtime-service.ts +185 -0
- package/src/runtime/root/release-materialize.ts +19 -0
- package/src/runtime/root/runtime.ts +2 -1
- package/src/runtime/root/scheduler.ts +5 -0
- package/src/runtime/root/service.ts +18 -5
- package/src/runtime/standalone-recovery/core.ts +65 -16
- package/src/runtime/workflow/findings/store.ts +145 -106
- package/src/runtime/workflow/findings/types.ts +22 -0
- package/src/runtime/workflow/schedules/engine.ts +132 -2
- package/src/runtime/workflow/schedules/pending-activation.ts +139 -0
- package/src/runtime/workflow/schedules/work-continuation.ts +218 -0
- package/docs/CHANGELOG.md +0 -978
- package/docs/README.zh-CN.md +0 -58
- package/scripts/smoke-chatgpt-supervised-automation.ts +0 -208
- package/src/cli/commands/controller.ts +0 -680
- package/src/runtime/workflow/campaigns/cleanup.ts +0 -263
- package/src/runtime/workflow/campaigns/engine.ts +0 -112
- package/src/runtime/workflow/campaigns/normalize.ts +0 -100
- package/src/runtime/workflow/campaigns/review.ts +0 -315
- package/src/runtime/workflow/campaigns/store.ts +0 -526
- package/src/runtime/workflow/campaigns/supervisor.ts +0 -112
- package/src/runtime/workflow/campaigns/types.ts +0 -290
- /package/src/runtime/{workflow/campaigns → execution}/workspace-agent.ts +0 -0
|
@@ -28,7 +28,6 @@ import { RepoActorRegistry } from '../repo-actor/registry';
|
|
|
28
28
|
import { reconcileExecutionJobsAsync } from './reconciliation';
|
|
29
29
|
import { tickSchedules } from '../../workflow/schedules/engine';
|
|
30
30
|
import { tickPortfolioWorkflows } from '../../workflow/portfolio/engine';
|
|
31
|
-
import { tickCampaigns } from '../../workflow/campaigns/engine';
|
|
32
31
|
import { tickGoalLoopsForController } from '../goal-loop';
|
|
33
32
|
import { readJsonFile, writeJsonAtomic } from '../../shared/json-files';
|
|
34
33
|
import { resolveBunExecutable } from '../../shared/process-environment';
|
|
@@ -36,6 +35,9 @@ import { isProcessAlive, terminateProcessTree } from '../../shared/process-tree'
|
|
|
36
35
|
import { readSchedulerWakeSignal, waitForSchedulerWakeSignal } from './wake-signal';
|
|
37
36
|
import { cleanupControllerRuntimeState } from '../runtime-cleanup';
|
|
38
37
|
import { reconcileTerminalWorkCleanups } from '../execution/work-terminal-cleanup';
|
|
38
|
+
import { gcTerminalProcesses } from '../../execution/process-runtime/gc';
|
|
39
|
+
import { reconcilePendingWorkValidations } from '../execution/work-validation-reconciler';
|
|
40
|
+
import { reconcilePendingEditValidations } from '../execution/edit-validation-coordinator';
|
|
39
41
|
import { schedulerDispatchAllowed } from '../facade/work-admission-policy';
|
|
40
42
|
import { rebuildRepositoryProjection, refreshRepositoryProjectionForRepository } from '../../projections/materialized-view';
|
|
41
43
|
import { readRepositoryGitStatusSample, sampleRepositoryGitStatusForRepositories } from '../../projections/git-status-sampler';
|
|
@@ -215,7 +217,6 @@ export class GlobalScheduler {
|
|
|
215
217
|
private readonly fatalOnTickError: boolean;
|
|
216
218
|
private lastScheduleTick = 0;
|
|
217
219
|
private lastPortfolioTick = 0;
|
|
218
|
-
private lastCampaignTick = 0;
|
|
219
220
|
private lastGoalLoopTick = 0;
|
|
220
221
|
private lastReconcile = 0;
|
|
221
222
|
private lastPersistedAt = 0;
|
|
@@ -232,6 +233,10 @@ export class GlobalScheduler {
|
|
|
232
233
|
private sourceScansAvoided = 0;
|
|
233
234
|
private runtimeCleanup = cleanupControllerRuntimeState;
|
|
234
235
|
private terminalWorkCleanup = reconcileTerminalWorkCleanups;
|
|
236
|
+
private processGc = gcTerminalProcesses;
|
|
237
|
+
private workValidationReconcile = reconcilePendingWorkValidations;
|
|
238
|
+
private editValidationReconcile = reconcilePendingEditValidations;
|
|
239
|
+
private repositoryList = listRepositories;
|
|
235
240
|
private lastDarwinMemorySampleAt = 0;
|
|
236
241
|
private cachedDarwinAvailableMemoryMb: number | undefined;
|
|
237
242
|
private darwinMemorySampleInFlight: Promise<void> | undefined;
|
|
@@ -612,6 +617,7 @@ export class GlobalScheduler {
|
|
|
612
617
|
this.lastHeartbeatAt = new Date(now).toISOString();
|
|
613
618
|
this.lastTickAt = this.lastHeartbeatAt;
|
|
614
619
|
this.persistState();
|
|
620
|
+
const repositories = this.repositoryList(this.controllerHome).filter((repo) => repo.enabled && !repo.removedAt);
|
|
615
621
|
if (now - this.lastCleanupAt >= RUNTIME_CLEANUP_INTERVAL_MS) {
|
|
616
622
|
// Advance the interval before cleanup so a failing pass cannot create a
|
|
617
623
|
// tight retry loop on every scheduler tick.
|
|
@@ -630,13 +636,32 @@ export class GlobalScheduler {
|
|
|
630
636
|
} catch (error) {
|
|
631
637
|
console.error('[forge cleanup] terminal Work cleanup failed:', error);
|
|
632
638
|
}
|
|
639
|
+
if (repositories.length > 0) {
|
|
640
|
+
const slot = Math.floor(now / RUNTIME_CLEANUP_INTERVAL_MS) % repositories.length;
|
|
641
|
+
const repo = repositories[slot]!;
|
|
642
|
+
const result = this.processGc({ controllerHome: this.controllerHome, repoId: repo.repoId });
|
|
643
|
+
if (!result.ok) console.error('[forge cleanup] Process GC failed:', result.error ?? 'unknown error');
|
|
644
|
+
}
|
|
633
645
|
}
|
|
634
646
|
if (now - this.lastReconcile >= 5_000) {
|
|
635
647
|
await reconcileExecutionJobsAsync(this.controllerHome);
|
|
648
|
+
for (const repository of repositories) {
|
|
649
|
+
const validation = this.workValidationReconcile(this.controllerHome, repository.repoId, 500);
|
|
650
|
+
if (validation.errors.length > 0) {
|
|
651
|
+
console.error(
|
|
652
|
+
`[forge validation] background Work reconciliation reported ${validation.errors.length} error(s) for ${repository.repoId}`,
|
|
653
|
+
);
|
|
654
|
+
}
|
|
655
|
+
const editValidation = await this.editValidationReconcile(this.controllerHome, repository, 200);
|
|
656
|
+
if (editValidation.errors.length > 0) {
|
|
657
|
+
console.error(
|
|
658
|
+
`[forge validation] background EditSession reconciliation reported ${editValidation.errors.length} error(s) for ${repository.repoId}`,
|
|
659
|
+
);
|
|
660
|
+
}
|
|
661
|
+
}
|
|
636
662
|
this.lastReconcile = now;
|
|
637
663
|
this.lastReconcileAt = new Date(now).toISOString();
|
|
638
664
|
}
|
|
639
|
-
const repositories = listRepositories(this.controllerHome).filter((repo) => repo.enabled && !repo.removedAt);
|
|
640
665
|
const activeJobSnapshot = listActiveExecutionJobs(this.controllerHome);
|
|
641
666
|
const hasActiveWork = activeJobSnapshot.length > 0;
|
|
642
667
|
const sourceScanDue = hasActiveWork || now - this.lastSourceScanAt >= IDLE_REPOSITORY_SCAN_INTERVAL_MS;
|
|
@@ -843,12 +868,9 @@ export class GlobalScheduler {
|
|
|
843
868
|
this.spawnWorker(pending.repoId, pending.jobId);
|
|
844
869
|
}
|
|
845
870
|
// Dispatch is latency-sensitive. Run repository workflow maintenance only
|
|
846
|
-
// after queued Jobs have had a chance to claim capacity.
|
|
871
|
+
// after queued Jobs have had a chance to claim capacity. Goal
|
|
847
872
|
// state transitions still wake the scheduler immediately when they enqueue work.
|
|
848
|
-
|
|
849
|
-
tickCampaigns(this.controllerHome, repositories.map((repo) => repo.repoId));
|
|
850
|
-
this.lastCampaignTick = now;
|
|
851
|
-
}
|
|
873
|
+
|
|
852
874
|
if (now - this.lastGoalLoopTick >= 5_000) {
|
|
853
875
|
try {
|
|
854
876
|
tickGoalLoopsForController(
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { withControllerLock } from '../../../cli/repositories/locks';
|
|
2
|
+
import { readControlPlaneRecord, writeControlPlaneRecord } from '../persistence/sqlite-store';
|
|
3
|
+
|
|
4
|
+
const NAMESPACE = 'chatgpt_work_conversation_binding';
|
|
5
|
+
|
|
6
|
+
export interface ChatgptWorkConversationBinding {
|
|
7
|
+
schemaVersion: 1;
|
|
8
|
+
repoId: string;
|
|
9
|
+
workId: string;
|
|
10
|
+
conversationUrl: string;
|
|
11
|
+
conversationId: string;
|
|
12
|
+
localAlias: string;
|
|
13
|
+
latestBrowserSessionId?: string;
|
|
14
|
+
createdAt: string;
|
|
15
|
+
updatedAt: string;
|
|
16
|
+
lastContinuedAt?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface ChatgptWorkBindingStoreOptions {
|
|
20
|
+
controllerHome: string;
|
|
21
|
+
repoId: string;
|
|
22
|
+
now?: () => string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function nowIso(options: ChatgptWorkBindingStoreOptions): string {
|
|
26
|
+
return options.now?.() ?? new Date().toISOString();
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function parseChatgptConversationIdentity(value: string): { conversationUrl: string; conversationId: string } {
|
|
30
|
+
let url: URL;
|
|
31
|
+
try {
|
|
32
|
+
url = new URL(value);
|
|
33
|
+
} catch {
|
|
34
|
+
throw new Error('CHATGPT_WORK_CONVERSATION_URL_INVALID');
|
|
35
|
+
}
|
|
36
|
+
if (url.protocol !== 'https:' || !['chatgpt.com', 'www.chatgpt.com', 'chat.openai.com'].includes(url.hostname)) {
|
|
37
|
+
throw new Error('CHATGPT_WORK_CONVERSATION_URL_INVALID');
|
|
38
|
+
}
|
|
39
|
+
const parts = url.pathname.split('/').filter(Boolean);
|
|
40
|
+
const c = parts.lastIndexOf('c');
|
|
41
|
+
const conversationId = c >= 0 ? parts[c + 1]?.trim() : undefined;
|
|
42
|
+
if (!conversationId) throw new Error('CHATGPT_WORK_CONVERSATION_ID_MISSING');
|
|
43
|
+
url.protocol = 'https:';
|
|
44
|
+
url.hostname = 'chatgpt.com';
|
|
45
|
+
url.search = '';
|
|
46
|
+
url.hash = '';
|
|
47
|
+
return { conversationUrl: url.toString(), conversationId };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function record(options: ChatgptWorkBindingStoreOptions, workId: string) {
|
|
51
|
+
return readControlPlaneRecord<ChatgptWorkConversationBinding>(
|
|
52
|
+
options.controllerHome,
|
|
53
|
+
NAMESPACE,
|
|
54
|
+
options.repoId,
|
|
55
|
+
workId,
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function getChatgptWorkConversationBinding(
|
|
60
|
+
options: ChatgptWorkBindingStoreOptions,
|
|
61
|
+
workId: string,
|
|
62
|
+
): ChatgptWorkConversationBinding | undefined {
|
|
63
|
+
return record(options, workId)?.value;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function bindChatgptWorkConversation(
|
|
67
|
+
options: ChatgptWorkBindingStoreOptions,
|
|
68
|
+
input: {
|
|
69
|
+
workId: string;
|
|
70
|
+
conversationUrl: string;
|
|
71
|
+
latestBrowserSessionId?: string;
|
|
72
|
+
localAlias?: string;
|
|
73
|
+
},
|
|
74
|
+
): ChatgptWorkConversationBinding {
|
|
75
|
+
if (!input.workId.trim()) throw new Error('CHATGPT_WORK_BINDING_WORK_REQUIRED');
|
|
76
|
+
const identity = parseChatgptConversationIdentity(input.conversationUrl);
|
|
77
|
+
return withControllerLock(
|
|
78
|
+
options.controllerHome,
|
|
79
|
+
{ scope: 'task', repoId: options.repoId, taskId: `chatgpt-work-binding-${input.workId}` },
|
|
80
|
+
`chatgpt-work-binding:${input.workId}`,
|
|
81
|
+
() => {
|
|
82
|
+
const existing = record(options, input.workId);
|
|
83
|
+
if (existing && existing.value.conversationId !== identity.conversationId) {
|
|
84
|
+
throw new Error(`CHATGPT_WORK_CONVERSATION_REBIND_REQUIRED: ${input.workId}:${existing.value.conversationId}`);
|
|
85
|
+
}
|
|
86
|
+
const now = nowIso(options);
|
|
87
|
+
const localAlias = input.localAlias?.trim()
|
|
88
|
+
|| existing?.value.localAlias
|
|
89
|
+
|| `Forge · ${options.repoId} · ${input.workId} · ${identity.conversationId.slice(0, 8)}`;
|
|
90
|
+
const binding: ChatgptWorkConversationBinding = {
|
|
91
|
+
schemaVersion: 1,
|
|
92
|
+
repoId: options.repoId,
|
|
93
|
+
workId: input.workId,
|
|
94
|
+
conversationUrl: identity.conversationUrl,
|
|
95
|
+
conversationId: identity.conversationId,
|
|
96
|
+
localAlias: localAlias.slice(0, 180),
|
|
97
|
+
latestBrowserSessionId: input.latestBrowserSessionId ?? existing?.value.latestBrowserSessionId,
|
|
98
|
+
createdAt: existing?.value.createdAt ?? now,
|
|
99
|
+
updatedAt: now,
|
|
100
|
+
lastContinuedAt: now,
|
|
101
|
+
};
|
|
102
|
+
writeControlPlaneRecord(options.controllerHome, {
|
|
103
|
+
namespace: NAMESPACE,
|
|
104
|
+
scope: options.repoId,
|
|
105
|
+
key: input.workId,
|
|
106
|
+
schemaVersion: 1,
|
|
107
|
+
value: binding,
|
|
108
|
+
action: existing ? 'chatgpt_work_conversation_continue' : 'chatgpt_work_conversation_bind',
|
|
109
|
+
expectedRevision: existing?.revision ?? null,
|
|
110
|
+
});
|
|
111
|
+
return binding;
|
|
112
|
+
},
|
|
113
|
+
);
|
|
114
|
+
}
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import { randomUUID } from 'crypto';
|
|
2
|
+
import { executeBrowserPluginAction, readBrowserPluginConfiguration } from '../../plugins/browser-adapter';
|
|
3
|
+
import { controllerPluginRepository } from '../../plugins/store';
|
|
4
|
+
import { getWorkContract } from '../facade/work-contract-store';
|
|
5
|
+
import {
|
|
6
|
+
bindChatgptWorkConversation,
|
|
7
|
+
getChatgptWorkConversationBinding,
|
|
8
|
+
type ChatgptWorkConversationBinding,
|
|
9
|
+
} from './chatgpt-work-binding-store';
|
|
10
|
+
|
|
11
|
+
const CONTROLLER_CHATGPT_SESSION_ID = 'forge-chatgpt-supercontroller';
|
|
12
|
+
const CHATGPT_PROMPT_SELECTOR = '[name="prompt-textarea"]';
|
|
13
|
+
const CHATGPT_SEND_KEY = 'Enter';
|
|
14
|
+
const CHATGPT_ALLOWED_DOMAINS = ['chatgpt.com', 'www.chatgpt.com'];
|
|
15
|
+
|
|
16
|
+
export interface WorkChatgptContinuationInput {
|
|
17
|
+
controllerHome: string;
|
|
18
|
+
repoId: string;
|
|
19
|
+
repoRoot: string;
|
|
20
|
+
workId: string;
|
|
21
|
+
prompt: string;
|
|
22
|
+
title?: string;
|
|
23
|
+
browserSessionId?: string;
|
|
24
|
+
conversationUrl?: string;
|
|
25
|
+
timeoutMs?: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface WorkChatgptContinuationResult {
|
|
29
|
+
status: 'dispatched' | 'failed';
|
|
30
|
+
provider: 'controller-browser';
|
|
31
|
+
browserSessionId: string;
|
|
32
|
+
conversationUrl?: string;
|
|
33
|
+
conversationId?: string;
|
|
34
|
+
localAlias?: string;
|
|
35
|
+
resumedFromBinding: boolean;
|
|
36
|
+
error?: { code: string; message: string };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function requestId(workId: string, actionId: string): string {
|
|
40
|
+
return `chatgpt-work:${workId}:${actionId}:${randomUUID()}`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function controllerBrowserAction(
|
|
44
|
+
controllerHome: string,
|
|
45
|
+
workId: string,
|
|
46
|
+
actionId: string,
|
|
47
|
+
args: Record<string, unknown>,
|
|
48
|
+
timeoutMs?: number,
|
|
49
|
+
): Promise<Record<string, unknown>> {
|
|
50
|
+
const repository = controllerPluginRepository(controllerHome);
|
|
51
|
+
const repoRoot = repository.canonicalRoot ?? repository.localRoot;
|
|
52
|
+
if (!repoRoot) throw new Error('CHATGPT_CONTROLLER_BROWSER_ROOT_UNAVAILABLE');
|
|
53
|
+
return executeBrowserPluginAction({
|
|
54
|
+
controllerHome,
|
|
55
|
+
repoId: repository.repoId,
|
|
56
|
+
repoRoot,
|
|
57
|
+
pluginId: 'browser',
|
|
58
|
+
actionId,
|
|
59
|
+
requestId: requestId(workId, actionId),
|
|
60
|
+
args,
|
|
61
|
+
timeoutMs,
|
|
62
|
+
origin: { surface: 'schedule', actor: 'chatgpt-work-continuation' },
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function ensureControllerChatgptBrowser(controllerHome: string, workId: string): Promise<void> {
|
|
67
|
+
const repository = controllerPluginRepository(controllerHome);
|
|
68
|
+
const repoRoot = repository.canonicalRoot ?? repository.localRoot;
|
|
69
|
+
if (!repoRoot) throw new Error('CHATGPT_CONTROLLER_BROWSER_ROOT_UNAVAILABLE');
|
|
70
|
+
const existing = readBrowserPluginConfiguration(repoRoot);
|
|
71
|
+
const allowedDomains = [...new Set([...existing.allowedDomains, ...CHATGPT_ALLOWED_DOMAINS])];
|
|
72
|
+
await controllerBrowserAction(controllerHome, workId, 'configure', {
|
|
73
|
+
enabled: true,
|
|
74
|
+
...(!existing.enabled ? {
|
|
75
|
+
browser_mode: 'attach_preferred',
|
|
76
|
+
profile_mode: 'repo_local',
|
|
77
|
+
browser_channel: 'chromium',
|
|
78
|
+
cdp_attach_fallback: 'fail_closed',
|
|
79
|
+
native_attach_mode: 'auto',
|
|
80
|
+
native_browser_candidates: ['chrome'],
|
|
81
|
+
} : {}),
|
|
82
|
+
allowed_domains: allowedDomains,
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function stringField(value: unknown): string | undefined {
|
|
87
|
+
return typeof value === 'string' && value.trim() ? value.trim() : undefined;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function resultUrl(result: Record<string, unknown>): string | undefined {
|
|
91
|
+
return stringField(result.url)
|
|
92
|
+
?? stringField((result.session as Record<string, unknown> | undefined)?.url);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Dispatches a bounded resume prompt to one controller-owned ChatGPT Web tab.
|
|
97
|
+
* Chat history is transport context only. Forge Work/Plan/evidence remain authoritative.
|
|
98
|
+
*/
|
|
99
|
+
export async function runWorkChatgptContinuation(input: WorkChatgptContinuationInput): Promise<WorkChatgptContinuationResult> {
|
|
100
|
+
const store = { controllerHome: input.controllerHome, repoId: input.repoId };
|
|
101
|
+
const existing = getChatgptWorkConversationBinding(store, input.workId);
|
|
102
|
+
const seedUrl = input.conversationUrl?.trim() || existing?.conversationUrl;
|
|
103
|
+
const sessionId = input.browserSessionId?.trim() || CONTROLLER_CHATGPT_SESSION_ID;
|
|
104
|
+
let binding: ChatgptWorkConversationBinding | undefined = existing;
|
|
105
|
+
|
|
106
|
+
try {
|
|
107
|
+
const work = getWorkContract(store, input.workId);
|
|
108
|
+
if (!work || work.repoId !== input.repoId) {
|
|
109
|
+
throw new Error(`CHATGPT_WORK_CONTRACT_NOT_FOUND: ${input.repoId}:${input.workId}`);
|
|
110
|
+
}
|
|
111
|
+
await ensureControllerChatgptBrowser(input.controllerHome, input.workId);
|
|
112
|
+
if (seedUrl && !binding) {
|
|
113
|
+
binding = bindChatgptWorkConversation(store, {
|
|
114
|
+
workId: input.workId,
|
|
115
|
+
conversationUrl: seedUrl,
|
|
116
|
+
latestBrowserSessionId: sessionId,
|
|
117
|
+
localAlias: input.title,
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
const targetUrl = binding?.conversationUrl ?? seedUrl ?? 'https://chatgpt.com/';
|
|
121
|
+
await controllerBrowserAction(input.controllerHome, input.workId, 'navigate', {
|
|
122
|
+
session_id: sessionId,
|
|
123
|
+
url: targetUrl,
|
|
124
|
+
wait_until: 'domcontentloaded',
|
|
125
|
+
timeout_ms: input.timeoutMs ?? 60_000,
|
|
126
|
+
retries: 1,
|
|
127
|
+
}, input.timeoutMs);
|
|
128
|
+
await controllerBrowserAction(input.controllerHome, input.workId, 'fill', {
|
|
129
|
+
session_id: sessionId,
|
|
130
|
+
selector: CHATGPT_PROMPT_SELECTOR,
|
|
131
|
+
text: input.prompt,
|
|
132
|
+
timeout_ms: input.timeoutMs ?? 60_000,
|
|
133
|
+
post_action_wait_ms: 100,
|
|
134
|
+
}, input.timeoutMs);
|
|
135
|
+
const sent = await controllerBrowserAction(input.controllerHome, input.workId, 'press', {
|
|
136
|
+
session_id: sessionId,
|
|
137
|
+
selector: CHATGPT_PROMPT_SELECTOR,
|
|
138
|
+
key: CHATGPT_SEND_KEY,
|
|
139
|
+
timeout_ms: input.timeoutMs ?? 60_000,
|
|
140
|
+
post_action_wait_ms: 1_500,
|
|
141
|
+
}, input.timeoutMs);
|
|
142
|
+
const observedUrl = resultUrl(sent) ?? targetUrl;
|
|
143
|
+
if (/\/c\/[^/?#]+/.test(observedUrl)) {
|
|
144
|
+
binding = bindChatgptWorkConversation(store, {
|
|
145
|
+
workId: input.workId,
|
|
146
|
+
conversationUrl: observedUrl,
|
|
147
|
+
latestBrowserSessionId: sessionId,
|
|
148
|
+
localAlias: binding?.localAlias ?? input.title,
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
return {
|
|
152
|
+
status: 'dispatched',
|
|
153
|
+
provider: 'controller-browser',
|
|
154
|
+
browserSessionId: sessionId,
|
|
155
|
+
conversationUrl: binding?.conversationUrl ?? observedUrl,
|
|
156
|
+
conversationId: binding?.conversationId,
|
|
157
|
+
localAlias: binding?.localAlias,
|
|
158
|
+
resumedFromBinding: Boolean(existing),
|
|
159
|
+
};
|
|
160
|
+
} catch (error) {
|
|
161
|
+
return {
|
|
162
|
+
status: 'failed',
|
|
163
|
+
provider: 'controller-browser',
|
|
164
|
+
browserSessionId: sessionId,
|
|
165
|
+
conversationUrl: binding?.conversationUrl ?? seedUrl,
|
|
166
|
+
conversationId: binding?.conversationId,
|
|
167
|
+
localAlias: binding?.localAlias,
|
|
168
|
+
resumedFromBinding: Boolean(existing),
|
|
169
|
+
error: {
|
|
170
|
+
code: error instanceof Error && error.message.includes(':') ? error.message.split(':', 1)[0] : 'CHATGPT_CONTROLLER_BROWSER_FAILED',
|
|
171
|
+
message: error instanceof Error ? error.message : String(error),
|
|
172
|
+
},
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import { randomUUID } from 'crypto';
|
|
2
|
+
import { withControllerLock } from '../../../cli/repositories/locks';
|
|
3
|
+
import {
|
|
4
|
+
readControlPlaneRecord,
|
|
5
|
+
writeControlPlaneRecord,
|
|
6
|
+
} from '../persistence/sqlite-store';
|
|
7
|
+
import type { ControllerType } from '../facade/types';
|
|
8
|
+
|
|
9
|
+
const NAMESPACE = 'external_controller_launch_reservation';
|
|
10
|
+
const DEFAULT_TTL_MS = 2 * 60_000;
|
|
11
|
+
const MAX_TTL_MS = 10 * 60_000;
|
|
12
|
+
|
|
13
|
+
export interface ExternalControllerLaunchReservation {
|
|
14
|
+
schemaVersion: 1;
|
|
15
|
+
repoId: string;
|
|
16
|
+
workId: string;
|
|
17
|
+
reservationId: string;
|
|
18
|
+
controllerType: Exclude<ControllerType, 'human'>;
|
|
19
|
+
createdAt: string;
|
|
20
|
+
expiresAt: string;
|
|
21
|
+
pid?: number;
|
|
22
|
+
releasedAt?: string;
|
|
23
|
+
releaseReason?: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface LaunchReservationStoreOptions {
|
|
27
|
+
controllerHome: string;
|
|
28
|
+
repoId: string;
|
|
29
|
+
now?: () => string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function nowIso(options: LaunchReservationStoreOptions): string {
|
|
33
|
+
return options.now?.() ?? new Date().toISOString();
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function record(options: LaunchReservationStoreOptions, workId: string) {
|
|
37
|
+
return readControlPlaneRecord<ExternalControllerLaunchReservation>(
|
|
38
|
+
options.controllerHome,
|
|
39
|
+
NAMESPACE,
|
|
40
|
+
options.repoId,
|
|
41
|
+
workId,
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function active(value: ExternalControllerLaunchReservation | undefined, at = Date.now()): ExternalControllerLaunchReservation | undefined {
|
|
46
|
+
if (!value || value.releasedAt) return undefined;
|
|
47
|
+
return Date.parse(value.expiresAt) > at ? value : undefined;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function getExternalControllerLaunchReservation(
|
|
51
|
+
options: LaunchReservationStoreOptions,
|
|
52
|
+
workId: string,
|
|
53
|
+
): ExternalControllerLaunchReservation | undefined {
|
|
54
|
+
return active(record(options, workId)?.value);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function reserveExternalControllerLaunch(
|
|
58
|
+
options: LaunchReservationStoreOptions,
|
|
59
|
+
input: {
|
|
60
|
+
workId: string;
|
|
61
|
+
controllerType: Exclude<ControllerType, 'human'>;
|
|
62
|
+
ttlMs?: number;
|
|
63
|
+
},
|
|
64
|
+
): ExternalControllerLaunchReservation {
|
|
65
|
+
if (!input.workId.trim()) throw new Error('CONTROLLER_LAUNCH_WORK_REQUIRED');
|
|
66
|
+
return withControllerLock(
|
|
67
|
+
options.controllerHome,
|
|
68
|
+
{ scope: 'task', repoId: options.repoId, taskId: `controller-launch-${input.workId}` },
|
|
69
|
+
`controller-launch-reserve:${input.workId}`,
|
|
70
|
+
() => {
|
|
71
|
+
const existing = record(options, input.workId);
|
|
72
|
+
const current = active(existing?.value);
|
|
73
|
+
if (current) {
|
|
74
|
+
throw new Error(`CONTROLLER_LAUNCH_ALREADY_RESERVED: ${input.workId}:${current.reservationId}`);
|
|
75
|
+
}
|
|
76
|
+
const createdAt = nowIso(options);
|
|
77
|
+
const ttlMs = Math.max(5_000, Math.min(input.ttlMs ?? DEFAULT_TTL_MS, MAX_TTL_MS));
|
|
78
|
+
const reservation: ExternalControllerLaunchReservation = {
|
|
79
|
+
schemaVersion: 1,
|
|
80
|
+
repoId: options.repoId,
|
|
81
|
+
workId: input.workId,
|
|
82
|
+
reservationId: randomUUID(),
|
|
83
|
+
controllerType: input.controllerType,
|
|
84
|
+
createdAt,
|
|
85
|
+
expiresAt: new Date(Date.parse(createdAt) + ttlMs).toISOString(),
|
|
86
|
+
};
|
|
87
|
+
writeControlPlaneRecord(options.controllerHome, {
|
|
88
|
+
namespace: NAMESPACE,
|
|
89
|
+
scope: options.repoId,
|
|
90
|
+
key: input.workId,
|
|
91
|
+
schemaVersion: 1,
|
|
92
|
+
value: reservation,
|
|
93
|
+
action: 'external_controller_launch_reserve',
|
|
94
|
+
expectedRevision: existing?.revision ?? null,
|
|
95
|
+
});
|
|
96
|
+
return reservation;
|
|
97
|
+
},
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function attachExternalControllerLaunchPid(
|
|
102
|
+
options: LaunchReservationStoreOptions,
|
|
103
|
+
workId: string,
|
|
104
|
+
reservationId: string,
|
|
105
|
+
pid: number | undefined,
|
|
106
|
+
): ExternalControllerLaunchReservation {
|
|
107
|
+
return withControllerLock(
|
|
108
|
+
options.controllerHome,
|
|
109
|
+
{ scope: 'task', repoId: options.repoId, taskId: `controller-launch-${workId}` },
|
|
110
|
+
`controller-launch-bind:${reservationId}`,
|
|
111
|
+
() => {
|
|
112
|
+
const existing = record(options, workId);
|
|
113
|
+
if (!existing || existing.value.reservationId !== reservationId) {
|
|
114
|
+
throw new Error(`CONTROLLER_LAUNCH_RESERVATION_STALE: ${workId}:${reservationId}`);
|
|
115
|
+
}
|
|
116
|
+
const next = { ...existing.value, ...(pid ? { pid } : {}) };
|
|
117
|
+
writeControlPlaneRecord(options.controllerHome, {
|
|
118
|
+
namespace: NAMESPACE,
|
|
119
|
+
scope: options.repoId,
|
|
120
|
+
key: workId,
|
|
121
|
+
schemaVersion: 1,
|
|
122
|
+
value: next,
|
|
123
|
+
action: 'external_controller_launch_bind_pid',
|
|
124
|
+
expectedRevision: existing.revision,
|
|
125
|
+
});
|
|
126
|
+
return next;
|
|
127
|
+
},
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function releaseExternalControllerLaunchReservation(
|
|
132
|
+
options: LaunchReservationStoreOptions,
|
|
133
|
+
workId: string,
|
|
134
|
+
reservationId: string,
|
|
135
|
+
reason: string,
|
|
136
|
+
): void {
|
|
137
|
+
withControllerLock(
|
|
138
|
+
options.controllerHome,
|
|
139
|
+
{ scope: 'task', repoId: options.repoId, taskId: `controller-launch-${workId}` },
|
|
140
|
+
`controller-launch-release:${reservationId}`,
|
|
141
|
+
() => {
|
|
142
|
+
const existing = record(options, workId);
|
|
143
|
+
if (!existing || existing.value.reservationId !== reservationId || existing.value.releasedAt) return;
|
|
144
|
+
const releasedAt = nowIso(options);
|
|
145
|
+
writeControlPlaneRecord(options.controllerHome, {
|
|
146
|
+
namespace: NAMESPACE,
|
|
147
|
+
scope: options.repoId,
|
|
148
|
+
key: workId,
|
|
149
|
+
schemaVersion: 1,
|
|
150
|
+
value: {
|
|
151
|
+
...existing.value,
|
|
152
|
+
expiresAt: releasedAt,
|
|
153
|
+
releasedAt,
|
|
154
|
+
releaseReason: reason.slice(0, 240),
|
|
155
|
+
},
|
|
156
|
+
action: 'external_controller_launch_release',
|
|
157
|
+
expectedRevision: existing.revision,
|
|
158
|
+
});
|
|
159
|
+
},
|
|
160
|
+
);
|
|
161
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { readFileSync } from 'fs';
|
|
2
|
+
import { forgeRuntimeServicePaths, readForgeRuntimeServiceConfig } from '../../root/service';
|
|
3
|
+
import { observeRuntimeStatus, readRuntimeStatusSnapshot } from '../../root/status';
|
|
4
|
+
|
|
5
|
+
export const FORGE_RUNTIME_MCP_TOKEN_ENV = 'FORGE_RUNTIME_MCP_TOKEN';
|
|
6
|
+
|
|
7
|
+
export interface ProviderMcpBootstrap {
|
|
8
|
+
url: string;
|
|
9
|
+
bearerTokenEnvVar: typeof FORGE_RUNTIME_MCP_TOKEN_ENV;
|
|
10
|
+
principalId: string;
|
|
11
|
+
sessionId: string;
|
|
12
|
+
env: NodeJS.ProcessEnv;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function runtimeMcpUrl(controllerHome: string): string {
|
|
16
|
+
const observed = observeRuntimeStatus(controllerHome);
|
|
17
|
+
if (!observed.running || !observed.ready) {
|
|
18
|
+
throw new Error(`LAUNCHER_RUNTIME_MCP_NOT_READY: ${observed.reasonCodes.join(',') || 'runtime_not_ready'}`);
|
|
19
|
+
}
|
|
20
|
+
const snapshot = readRuntimeStatusSnapshot(controllerHome);
|
|
21
|
+
if (snapshot?.endpoint?.trim()) return snapshot.endpoint.trim();
|
|
22
|
+
const config = readForgeRuntimeServiceConfig(forgeRuntimeServicePaths(controllerHome).configPath);
|
|
23
|
+
const host = config.host === '0.0.0.0' || config.host === '::' ? '127.0.0.1' : config.host;
|
|
24
|
+
const renderedHost = host.includes(':') && !host.startsWith('[') ? `[${host}]` : host;
|
|
25
|
+
return `http://${renderedHost}:${config.port}/mcp`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function resolveProviderMcpBootstrap(
|
|
29
|
+
controllerHome: string,
|
|
30
|
+
provider: 'codex',
|
|
31
|
+
reservationId: string,
|
|
32
|
+
): ProviderMcpBootstrap {
|
|
33
|
+
const config = readForgeRuntimeServiceConfig(forgeRuntimeServicePaths(controllerHome).configPath);
|
|
34
|
+
const token = readFileSync(config.authTokenFile, 'utf8').trim();
|
|
35
|
+
if (!token) throw new Error('LAUNCHER_RUNTIME_MCP_TOKEN_EMPTY');
|
|
36
|
+
const suffix = reservationId.trim();
|
|
37
|
+
if (!suffix) throw new Error('LAUNCHER_RESERVATION_ID_REQUIRED');
|
|
38
|
+
return {
|
|
39
|
+
url: runtimeMcpUrl(controllerHome),
|
|
40
|
+
bearerTokenEnvVar: FORGE_RUNTIME_MCP_TOKEN_ENV,
|
|
41
|
+
principalId: `external:${provider}:${suffix}`,
|
|
42
|
+
sessionId: `external-session:${provider}:${suffix}`,
|
|
43
|
+
env: {
|
|
44
|
+
...process.env,
|
|
45
|
+
[FORGE_RUNTIME_MCP_TOKEN_ENV]: token,
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function codexMcpConfigArgs(bootstrap: ProviderMcpBootstrap): string[] {
|
|
51
|
+
const headers = {
|
|
52
|
+
'X-Forge-Forwarded-Principal-Id': bootstrap.principalId,
|
|
53
|
+
'X-Forge-Forwarded-Session-Id': bootstrap.sessionId,
|
|
54
|
+
'X-Forge-Forwarded-Controller-Type': 'codex',
|
|
55
|
+
};
|
|
56
|
+
const headerTable = `{${Object.entries(headers)
|
|
57
|
+
.map(([key, value]) => `${JSON.stringify(key)}=${JSON.stringify(value)}`)
|
|
58
|
+
.join(',')}}`;
|
|
59
|
+
return [
|
|
60
|
+
'-c', `mcp_servers.forge.url=${JSON.stringify(bootstrap.url)}`,
|
|
61
|
+
'-c', `mcp_servers.forge.bearer_token_env_var=${JSON.stringify(bootstrap.bearerTokenEnvVar)}`,
|
|
62
|
+
'-c', `mcp_servers.forge.http_headers=${headerTable}`,
|
|
63
|
+
];
|
|
64
|
+
}
|