@moretea-labs/forge 1.7.0 → 1.7.2
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 +2 -0
- package/CHANGELOG.md +26 -0
- package/CLAUDE.md +2 -0
- package/THIRD_PARTY_NOTICES.md +1 -1
- package/agents/openai.yaml +1 -1
- package/assets/partials-agents/02-operating-mode.partial.md +2 -0
- package/assets/plugin-registry.v1.json +3 -3
- package/assets/skill-version.json +2 -2
- package/assets/templates/helpers/check-architecture-sync.sh +17 -0
- package/assets/templates/helpers/inspect-project-state.ts +8 -6
- package/assets/templates/helpers/migrate-workflow-docs.ts +9 -7
- package/assets/templates/helpers/workflow-contract.ts +5 -1
- package/docs/architecture/CURRENT.md +12 -4
- package/docs/forge-plugin-management.md +1 -1
- package/docs/operations/controller-performance-and-502.md +19 -0
- package/docs/operations/homebrew.md +2 -2
- package/package.json +5 -2
- package/scripts/architecture-event.ts +2 -820
- package/scripts/architecture-queue.sh +2 -645
- package/scripts/archive-architecture-request.sh +2 -242
- package/scripts/archive-workflow.sh +1 -291
- package/scripts/benchmark-work-efficiency.ts +0 -0
- package/scripts/capability-config.ts +2 -392
- package/scripts/capability-resolver.ts +2 -503
- package/scripts/capture-plan.sh +1 -332
- package/scripts/check-agent-tooling.sh +2 -1531
- package/scripts/check-architecture-sync.sh +2 -302
- package/scripts/check-brain-manifest.sh +2 -253
- package/scripts/check-context-files.sh +2 -47
- package/scripts/check-deploy-sql-order.sh +2 -115
- package/scripts/check-runtime-architecture.mjs +243 -16
- package/scripts/check-skill-version.ts +3 -191
- package/scripts/check-source-duplication.mjs +61 -0
- package/scripts/check-stable-baseline.ts +0 -0
- package/scripts/check-task-sync.sh +2 -63
- package/scripts/check-task-workflow.sh +2 -1000
- package/scripts/codex-handoff-resume.sh +2 -267
- package/scripts/context-contract-sync.sh +2 -514
- package/scripts/contract-run.ts +2 -463
- package/scripts/contract-worktree.sh +1 -702
- package/scripts/ensure-task-workflow.sh +1 -1192
- package/scripts/inspect-project-state.ts +4 -279
- package/scripts/lib/project-init-lib.sh +7 -2
- package/scripts/lib/run-forge-helper.sh +19 -0
- package/scripts/lib/run-forge-helper.ts +26 -0
- package/scripts/migrate-project-template.sh +1 -33
- package/scripts/migrate-workflow-docs.ts +4 -412
- package/scripts/new-plan.sh +1 -180
- package/scripts/new-spec.sh +1 -47
- package/scripts/new-sprint.sh +1 -46
- package/scripts/plan-to-todo.sh +1 -771
- package/scripts/prepare-codex-handoff.sh +2 -201
- package/scripts/prepare-handoff.sh +1 -78
- package/scripts/refresh-current-status.sh +2 -467
- package/scripts/run-governed-gate.ts +3 -0
- package/scripts/select-agent-context-blocks.sh +2 -64
- package/scripts/ship-worktrees.sh +1 -531
- package/scripts/sprint-backlog.sh +1 -834
- package/scripts/stage-runtime-release.ts +2 -1
- package/scripts/summarize-failures.sh +2 -123
- package/scripts/switch-plan.sh +1 -150
- package/scripts/sync-brain-docs.sh +2 -379
- package/scripts/sync-generated-authority.mjs +175 -0
- package/scripts/verify-contract.sh +2 -593
- package/scripts/verify-sprint.sh +1 -500
- package/scripts/workflow-contract.ts +4 -201
- package/scripts/workstream-sync.sh +2 -261
- package/src/cli/agent-jobs/executor-health.ts +1 -1
- package/src/cli/commands/plugin.ts +12 -3
- package/src/cli/controller/check-runner.ts +7 -2
- package/src/cli/controller/context-pack.ts +46 -21
- package/src/cli/github/contracts.ts +16 -0
- package/src/cli/github/github.ts +2 -16
- package/src/cli/github/session.ts +2 -1
- package/src/cli/local-bridge/facade-api.ts +37 -47
- package/src/cli/local-bridge/server.ts +2 -2
- package/src/cli/mcp/legacy-tool-service.ts +10 -33
- package/src/cli/mcp/post-finalize-work-attribution.ts +150 -0
- package/src/cli/mcp/repository-tools.ts +125 -101
- package/src/cli/mcp/server.ts +57 -13
- package/src/cli/mcp/tool-contract.ts +14 -0
- package/src/cli/mcp/toolset.ts +2 -2
- package/src/cli/mcp/transports/http.ts +23 -0
- package/src/cli/repositories/command-classifier.ts +2 -2
- package/src/cli/repositories/command-executor.ts +24 -6
- package/src/cli/repositories/repository-snapshot.ts +5 -2
- package/src/cli/repositories/structured-git.ts +57 -3
- package/src/cli/repository/inspector.ts +11 -3
- package/src/runtime/context/generic-lsp-provider.ts +1 -1
- package/src/runtime/context/semantic-navigation-contract.ts +61 -0
- package/src/runtime/context/semantic-navigation.ts +9 -61
- package/src/runtime/control-plane/execution/direct-edit-work-completion.ts +69 -29
- package/src/runtime/control-plane/execution/repository-work-attribution.ts +106 -0
- package/src/runtime/control-plane/execution/retained-work-resume.ts +203 -0
- package/src/runtime/control-plane/execution/work-completion-authority.ts +58 -0
- package/src/runtime/control-plane/execution/work-evidence-policy.ts +200 -0
- package/src/runtime/control-plane/execution/work-execution-support.ts +386 -0
- package/src/runtime/control-plane/execution/work-finalization-service.ts +1913 -0
- package/src/runtime/control-plane/execution/work-handle-authority.ts +150 -0
- package/src/runtime/control-plane/execution/work-handle-store.ts +1 -1
- package/src/runtime/control-plane/execution/work-operation-service.ts +468 -0
- package/src/runtime/control-plane/execution/work-path-scope.ts +37 -0
- package/src/runtime/control-plane/execution/work-preparation-service.ts +479 -0
- package/src/runtime/control-plane/execution/work-process-evidence.ts +129 -0
- package/src/runtime/control-plane/execution/work-task-receipt.ts +4 -43
- package/src/runtime/control-plane/execution/work-terminal-cleanup.ts +13 -3
- package/src/runtime/control-plane/execution/work-verification-context.ts +56 -0
- package/src/runtime/control-plane/execution/work-verification-service.ts +388 -0
- package/src/runtime/control-plane/execution/work-verification-snapshot.ts +5 -1
- package/src/runtime/control-plane/facade/controller-round-relay.ts +164 -37
- package/src/runtime/control-plane/facade/controller-session-store.ts +183 -24
- package/src/runtime/control-plane/facade/goal-workloop-access.ts +15 -1
- package/src/runtime/control-plane/facade/goal-workloop.ts +329 -148
- package/src/runtime/control-plane/facade/index.ts +2 -0
- package/src/runtime/control-plane/facade/plan-contract-store.ts +90 -22
- package/src/runtime/control-plane/facade/repository-work-admission.ts +147 -0
- package/src/runtime/control-plane/facade/requirement-authority.ts +83 -0
- package/src/runtime/control-plane/facade/types.ts +68 -5
- package/src/runtime/control-plane/facade/work-contract-store.ts +3 -209
- package/src/runtime/control-plane/facade/work-state-machine.ts +250 -0
- package/src/runtime/control-plane/global-scheduler/maintenance.ts +14 -7
- package/src/runtime/control-plane/global-scheduler/scheduler.ts +5 -1
- package/src/runtime/control-plane/launcher/chatgpt-work-binding-store.ts +13 -0
- package/src/runtime/control-plane/launcher/chatgpt-work-continuation.ts +15 -6
- package/src/runtime/control-plane/launcher/launch-reservation-store.ts +54 -5
- package/src/runtime/control-plane/launcher/thin-launcher.ts +56 -7
- package/src/runtime/control-plane/persistence/requirement-store.ts +0 -12
- package/src/runtime/control-plane/repo-actor/registry.ts +4 -2
- package/src/runtime/control-plane/routing/route-policy.ts +18 -6
- package/src/runtime/control-plane/routing/workspace-admission.ts +70 -0
- package/src/runtime/evidence/process-check-execution.ts +18 -0
- package/src/runtime/execution/jobs/child-reference.ts +6 -2
- package/src/runtime/execution/process-runtime/check-result.ts +53 -18
- package/src/runtime/execution/process-runtime/check-runner-sidecar.ts +7 -1
- package/src/runtime/execution/process-runtime/command-facade.ts +39 -17
- package/src/runtime/execution/process-runtime/lightweight-managed.ts +20 -3
- package/src/runtime/execution/process-runtime/persisted-check.ts +4 -1
- package/src/runtime/execution/process-runtime/runtime.ts +10 -0
- package/src/runtime/execution/process-runtime/types.ts +2 -12
- package/src/runtime/execution/work-activity.ts +21 -0
- package/src/runtime/execution/workers/executor.ts +18 -13
- package/src/runtime/gateway/mcp/execution-tools.ts +35 -2930
- package/src/runtime/gateway/mcp/legacy-ios-tool-adapter.ts +61 -0
- package/src/runtime/gateway/mcp/router.ts +2 -2
- package/src/runtime/gateway/mcp/runtime-tool-definitions.ts +21 -2
- package/src/runtime/gateway/mcp/runtime-tools.ts +815 -1015
- package/src/runtime/plugins/app-store-connect-adapter.ts +93 -28
- package/src/runtime/plugins/browser-adapter.ts +256 -1083
- package/src/runtime/plugins/browser-handoff-host.ts +2 -25
- package/src/runtime/plugins/browser-handoff.ts +18 -3
- package/src/runtime/plugins/browser-macos-bridge.ts +121 -16
- package/src/runtime/plugins/browser-manifest-surface.ts +603 -0
- package/src/runtime/plugins/browser-native-session-service.ts +222 -0
- package/src/runtime/plugins/browser-runtime-contract.ts +2 -0
- package/src/runtime/plugins/browser-runtime.ts +10 -0
- package/src/runtime/plugins/browser-session-store.ts +121 -0
- package/src/runtime/plugins/browser-session-types.ts +98 -0
- package/src/runtime/plugins/desktop-operator-registration.ts +4 -4
- package/src/runtime/plugins/external-adapter.ts +92 -58
- package/src/runtime/plugins/macos-capability-broker.ts +0 -8
- package/src/runtime/plugins/store.ts +66 -9
- package/src/runtime/plugins/types.ts +3 -1
- package/src/runtime/recovery/maintenance-executor.ts +61 -25
- package/src/runtime/root/package-connector-service.ts +46 -4
- package/src/runtime/root/package-runtime-release.ts +3 -2
- package/src/runtime/root/package-runtime-service.ts +48 -3
- package/src/runtime/root/release-manifest.ts +137 -16
- package/src/runtime/root/release-materialize.ts +109 -35
- package/src/runtime/root/types.ts +10 -0
- package/src/runtime/standalone-recovery/core.ts +425 -194
- package/src/runtime/standalone-recovery/entry.ts +1 -1
- package/src/runtime/standalone-recovery/installer.ts +1 -1
- package/src/runtime/workflow/schedules/engine.ts +113 -87
- package/src/runtime/workflow/schedules/settlement.ts +26 -23
- package/src/runtime/workflow/schedules/store.ts +79 -10
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { resolve } from 'path';
|
|
2
|
+
import type { RepositoryRecord } from '../../../cli/repositories/types';
|
|
3
|
+
import { resolveRepositorySelection, selectRepositoryCheckout } from '../../../cli/repositories/registry';
|
|
4
|
+
import { repositoryGitStatus } from '../../../cli/repositories/structured-git';
|
|
5
|
+
import { getWorkContract, updateWorkContract } from '../facade/work-contract-store';
|
|
6
|
+
import { controllerSessionPrincipalId, getControllerSession } from '../facade/controller-session-store';
|
|
7
|
+
import { isTerminalWorkContractStatus } from '../facade/types';
|
|
8
|
+
import { currentPermissionSnapshotVersion } from './validation';
|
|
9
|
+
import { readWorkHandle, writeWorkHandle, type WorkHandleState } from './work-handle-store';
|
|
10
|
+
|
|
11
|
+
export interface RepositoryWorkHandleControllerIdentity {
|
|
12
|
+
sessionId: string;
|
|
13
|
+
principalId: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Canonical compatibility repair for a goal-workloop Work whose durable
|
|
18
|
+
* WorkContract exists but whose WorkHandle has not yet been materialized.
|
|
19
|
+
* Transport layers provide only the authenticated Controller identity.
|
|
20
|
+
*/
|
|
21
|
+
export function ensureRepositoryWorkHandle(input: {
|
|
22
|
+
controllerHome: string;
|
|
23
|
+
repository: RepositoryRecord;
|
|
24
|
+
workId: string;
|
|
25
|
+
identity: RepositoryWorkHandleControllerIdentity;
|
|
26
|
+
}): WorkHandleState | undefined {
|
|
27
|
+
const existing = readWorkHandle(input.controllerHome, input.repository.repoId, input.workId);
|
|
28
|
+
if (existing) return existing;
|
|
29
|
+
const contract = getWorkContract(
|
|
30
|
+
{ controllerHome: input.controllerHome, repoId: input.repository.repoId },
|
|
31
|
+
input.workId,
|
|
32
|
+
);
|
|
33
|
+
if (!contract || contract.workKind !== 'repository_change' || contract.mode !== 'goal_workloop' || !contract.checkoutId) {
|
|
34
|
+
return undefined;
|
|
35
|
+
}
|
|
36
|
+
const executionRepository = resolveRepositorySelection({
|
|
37
|
+
repoId: input.repository.repoId,
|
|
38
|
+
checkoutId: contract.checkoutId,
|
|
39
|
+
controllerHome: input.controllerHome,
|
|
40
|
+
allowSoleRepository: false,
|
|
41
|
+
});
|
|
42
|
+
const checkout = selectRepositoryCheckout(executionRepository, contract.checkoutId, { allowArchived: true });
|
|
43
|
+
const status = repositoryGitStatus(checkout);
|
|
44
|
+
if (!status.branch) throw new Error(`WORKTREE_DETACHED: ${contract.checkoutId} has no branch`);
|
|
45
|
+
const at = new Date().toISOString();
|
|
46
|
+
const managedWorktree = Boolean(contract.worktreeRef)
|
|
47
|
+
&& resolve(contract.worktreeRef!) === resolve(checkout.canonicalRoot)
|
|
48
|
+
&& resolve(checkout.canonicalRoot) !== resolve(input.repository.canonicalRoot);
|
|
49
|
+
return writeWorkHandle(input.controllerHome, {
|
|
50
|
+
schemaVersion: 1,
|
|
51
|
+
workId: input.workId,
|
|
52
|
+
sessionId: input.identity.sessionId,
|
|
53
|
+
principalId: input.identity.principalId,
|
|
54
|
+
repositoryId: input.repository.repoId,
|
|
55
|
+
checkoutId: contract.checkoutId,
|
|
56
|
+
worktreePath: checkout.canonicalRoot,
|
|
57
|
+
branch: status.branch,
|
|
58
|
+
sourceCheckoutId: input.repository.activeCheckoutId,
|
|
59
|
+
managedWorktree,
|
|
60
|
+
workContractId: contract.workId,
|
|
61
|
+
baseCommit: contract.baseRevision ?? status.head ?? undefined,
|
|
62
|
+
deliveryBaseCommit: contract.baseRevision ?? status.head ?? undefined,
|
|
63
|
+
expectedHead: status.head ?? contract.baseRevision,
|
|
64
|
+
permissionSnapshotVersion: currentPermissionSnapshotVersion(input.controllerHome, input.repository.repoId),
|
|
65
|
+
state: 'prepared',
|
|
66
|
+
createdAt: at,
|
|
67
|
+
updatedAt: at,
|
|
68
|
+
finalization: {
|
|
69
|
+
validation: 'pending',
|
|
70
|
+
commit: 'pending',
|
|
71
|
+
merge: 'pending',
|
|
72
|
+
branchCleanup: 'pending',
|
|
73
|
+
worktreeCleanup: 'pending',
|
|
74
|
+
},
|
|
75
|
+
cleanupResponsibility: { owner: 'work_finalizer', registeredAt: at },
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Upgrades an effect-only Work before the first governed repository mutation
|
|
81
|
+
* and materializes the existing repository delivery authority. The durable
|
|
82
|
+
* ControllerSession is the ownership source; the MCP transport session is not
|
|
83
|
+
* allowed to become a second mutation authority.
|
|
84
|
+
*/
|
|
85
|
+
export function ensureRepositoryMutationWorkHandle(input: {
|
|
86
|
+
controllerHome: string;
|
|
87
|
+
repository: RepositoryRecord;
|
|
88
|
+
workId: string;
|
|
89
|
+
principalId: string;
|
|
90
|
+
}): { handle: WorkHandleState; promotedFrom?: 'local_effect' | 'remote_effect' } {
|
|
91
|
+
const store = { controllerHome: input.controllerHome, repoId: input.repository.repoId };
|
|
92
|
+
let contract = getWorkContract(store, input.workId);
|
|
93
|
+
if (!contract) throw new Error(`WORK_NOT_FOUND: ${input.workId}`);
|
|
94
|
+
if (isTerminalWorkContractStatus(contract.status) || contract.completionReceipt) {
|
|
95
|
+
throw new Error(`WORK_REPOSITORY_MUTATION_TERMINAL: ${input.workId}`);
|
|
96
|
+
}
|
|
97
|
+
const principalId = input.principalId.trim();
|
|
98
|
+
if (!principalId) throw new Error(`WORK_CONTROLLER_AUTHENTICATED_PRINCIPAL_REQUIRED: ${input.workId}`);
|
|
99
|
+
const owner = getControllerSession(store, input.workId);
|
|
100
|
+
if (!owner) throw new Error(`WORK_CONTROLLER_CLAIM_REQUIRED: ${input.workId}`);
|
|
101
|
+
if (controllerSessionPrincipalId(owner) !== principalId) {
|
|
102
|
+
throw new Error(`WORK_CONTROLLER_OWNERSHIP_MISMATCH: ${input.workId}`);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (!contract.checkoutId) {
|
|
106
|
+
if (contract.worktreeRef?.trim()) {
|
|
107
|
+
throw new Error(`WORK_REPOSITORY_MUTATION_CHECKOUT_REQUIRED: ${input.workId}`);
|
|
108
|
+
}
|
|
109
|
+
contract = updateWorkContract(store, input.workId, { checkoutId: input.repository.activeCheckoutId });
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
let promotedFrom: 'local_effect' | 'remote_effect' | undefined;
|
|
113
|
+
if (contract.workKind === 'local_effect' || contract.workKind === 'remote_effect') {
|
|
114
|
+
promotedFrom = contract.workKind;
|
|
115
|
+
contract = updateWorkContract(store, input.workId, { workKind: 'repository_change' });
|
|
116
|
+
}
|
|
117
|
+
if (contract.workKind !== 'repository_change') {
|
|
118
|
+
throw new Error(`WORK_REPOSITORY_MUTATION_KIND_INVALID: ${input.workId}:${contract.workKind}`);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const handle = ensureRepositoryWorkHandle({
|
|
122
|
+
controllerHome: input.controllerHome,
|
|
123
|
+
repository: input.repository,
|
|
124
|
+
workId: input.workId,
|
|
125
|
+
identity: { sessionId: owner.sessionId, principalId },
|
|
126
|
+
});
|
|
127
|
+
if (!handle) throw new Error(`WORK_REPOSITORY_MUTATION_HANDLE_REQUIRED: ${input.workId}`);
|
|
128
|
+
return { handle, ...(promotedFrom ? { promotedFrom } : {}) };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Rebinds the durable WorkHandle to the Controller session that has already
|
|
133
|
+
* been admitted by the ControllerSession authority. The compare-and-swap
|
|
134
|
+
* revision on the read handle preserves fail-closed concurrent ownership.
|
|
135
|
+
*/
|
|
136
|
+
export function rebindRepositoryWorkHandleControllerIdentity(input: {
|
|
137
|
+
controllerHome: string;
|
|
138
|
+
repositoryId: string;
|
|
139
|
+
workId: string;
|
|
140
|
+
identity: RepositoryWorkHandleControllerIdentity;
|
|
141
|
+
}): WorkHandleState | undefined {
|
|
142
|
+
const existing = readWorkHandle(input.controllerHome, input.repositoryId, input.workId);
|
|
143
|
+
if (!existing) return undefined;
|
|
144
|
+
if (existing.principalId === input.identity.principalId && existing.sessionId === input.identity.sessionId) return existing;
|
|
145
|
+
return writeWorkHandle(input.controllerHome, {
|
|
146
|
+
...existing,
|
|
147
|
+
principalId: input.identity.principalId,
|
|
148
|
+
sessionId: input.identity.sessionId,
|
|
149
|
+
});
|
|
150
|
+
}
|
|
@@ -3,7 +3,7 @@ import { existsSync } from 'fs';
|
|
|
3
3
|
import { join } from 'path';
|
|
4
4
|
import { repositoryControllerRoot } from '../../../cli/repositories/controller-home';
|
|
5
5
|
import { readJsonFile, sanitizeFileComponent } from '../../shared/json-files';
|
|
6
|
-
import type { ProcessCheckExecutionIdentity } from '../../
|
|
6
|
+
import type { ProcessCheckExecutionIdentity } from '../../evidence/process-check-execution';
|
|
7
7
|
import { listControlPlaneRecords, mutateControlPlaneRecord, readOrImportControlPlaneRecord, writeControlPlaneRecord } from '../persistence/sqlite-store';
|
|
8
8
|
|
|
9
9
|
export const WORK_HANDLE_STATES = [
|
|
@@ -0,0 +1,468 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'crypto';
|
|
2
|
+
import { isAbsolute, relative, resolve } from 'path';
|
|
3
|
+
import type { MultiRepositoryMcpToolContext } from '../../../cli/mcp/multi-repository';
|
|
4
|
+
import { repositoryGitStatus, repositoryGitDiff } from '../../../cli/repositories/structured-git';
|
|
5
|
+
import { classifyRepositoryCommand } from '../../../cli/repositories/command-classifier';
|
|
6
|
+
import { listControllerChecks } from '../../../cli/controller/check-runner';
|
|
7
|
+
import { readRepositoryAccessPolicy } from '../governance/access-policy';
|
|
8
|
+
import { appendVerificationRecord } from '../facade/work-contract-store';
|
|
9
|
+
import { validateWorkHandle } from './validation';
|
|
10
|
+
import { commandFingerprint, effectiveVerificationEvidence, verificationInputFingerprint, workspaceValidationFingerprint, workValidationInputFingerprint } from './verification-evidence';
|
|
11
|
+
import { executionIdentityForWork } from './execution-identity';
|
|
12
|
+
import { markWorkHandleFailed, transitionWorkHandle, type WorkHandleState } from './work-handle-store';
|
|
13
|
+
import { assertResolvedAuthorization, decideAuthorization, type AuthorizationDecision, type AuthorizationRiskClass } from '../governance/authorization';
|
|
14
|
+
import { recordMcpTiming } from '../../diagnostics/mcp-timing';
|
|
15
|
+
import { commandValue, normalizeRepositoryCommand, type RepositoryCommandValue } from '../../../cli/repositories/command-normalization';
|
|
16
|
+
import { executeRepositoryCommandViaProcessRuntime } from '../../execution/process-runtime/command-facade';
|
|
17
|
+
import { getCheckProcessHandle } from '../../execution/process-runtime/check-facade';
|
|
18
|
+
import { processCheckCompletionReceipt } from '../../execution/process-runtime/check-receipt';
|
|
19
|
+
import { classifyPersistedCheckTerminalEvidence } from '../../execution/process-runtime/check-result';
|
|
20
|
+
import { claimProcessInvocation, getProcessRecord } from '../../execution/process-runtime/store';
|
|
21
|
+
import { runPersistedCheckViaProcessRuntime } from '../../execution/process-runtime/persisted-check';
|
|
22
|
+
import { markWorkValidationPending, projectWorkValidationOutcome } from './work-validation-reconciler';
|
|
23
|
+
import { assertWorkControllerOwnership, compactHandle, contractFor, gitHead, identityFor, makeBoundedWorkResult, reconcileTerminalCleanup, requireSession, terminalCleanupOutcome, workForSession } from './work-execution-support';
|
|
24
|
+
|
|
25
|
+
function commandInputs(args: Record<string, unknown>): Array<Record<string, unknown>> {
|
|
26
|
+
if (Array.isArray(args.commands)) return args.commands.filter((value): value is Record<string, unknown> => Boolean(value && typeof value === 'object' && !Array.isArray(value)));
|
|
27
|
+
if (args.command !== undefined) return [{ command: args.command, cwd: args.cwd, approval_token: args.approval_token, timeout_ms: args.timeout_ms, max_output_bytes: args.max_output_bytes }];
|
|
28
|
+
throw new Error('COMMAND_REQUIRED: provide command or commands');
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function authorizationRisk(command: RepositoryCommandValue, classification: ReturnType<typeof classifyRepositoryCommand>): AuthorizationRiskClass {
|
|
32
|
+
if (classification.risk === 'readonly') return 'readonly';
|
|
33
|
+
if (classification.risk === 'remote_write') return 'remote_write';
|
|
34
|
+
if (classification.risk === 'destructive') return 'destructive';
|
|
35
|
+
const executable = typeof command === 'string' ? command : command[0] ?? '';
|
|
36
|
+
if (typeof command === 'string' && /\b(?:npm|bun|pnpm|yarn)\s+(?:install|add|remove|update)\b/i.test(command)) return 'dependency_change';
|
|
37
|
+
if (/^\s*(?:git|.*[\\/]git)(?:\s|$)/i.test(executable)) return 'local_git';
|
|
38
|
+
return 'workspace_write';
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export async function executeWork(ctx: MultiRepositoryMcpToolContext, args: Record<string, unknown>): Promise<Record<string, unknown>> {
|
|
42
|
+
const session = requireSession(ctx, args);
|
|
43
|
+
const handle = workForSession(ctx, session, args);
|
|
44
|
+
assertWorkControllerOwnership(ctx, session, handle, args);
|
|
45
|
+
const commands = commandInputs(args);
|
|
46
|
+
if (commands.length > 16) throw new Error('COMMAND_BATCH_TOO_LARGE: at most 16 commands per work_execute');
|
|
47
|
+
const cheap = validateWorkHandle(ctx.controllerHome, handle, identityFor(ctx, args), 'cheap', 'execute');
|
|
48
|
+
const inputs = commands.map((entry) => ({
|
|
49
|
+
command: commandValue(normalizeRepositoryCommand(entry.command)),
|
|
50
|
+
cwd: typeof entry.cwd === 'string' ? entry.cwd : undefined,
|
|
51
|
+
approvalToken: typeof entry.approval_token === 'string' ? entry.approval_token : undefined,
|
|
52
|
+
}));
|
|
53
|
+
const classifications = inputs.map((entry) => classifyRepositoryCommand(entry.command, cheap.repository.defaultBranch));
|
|
54
|
+
const requiresFull = classifications.some((classification) => classification.risk !== 'readonly');
|
|
55
|
+
if (requiresFull) validateWorkHandle(ctx.controllerHome, handle, identityFor(ctx, args), 'full', 'execute');
|
|
56
|
+
const decisions: AuthorizationDecision[] = [];
|
|
57
|
+
const approvalRequestId = typeof args.approval_request_id === 'string' ? args.approval_request_id.trim() : '';
|
|
58
|
+
const resolvedRequest = approvalRequestId
|
|
59
|
+
? assertResolvedAuthorization({ controllerHome: ctx.controllerHome, repositoryId: handle.repositoryId, approvalRequestId, sessionId: session.sessionId, principalId: session.principalId, workId: handle.workId, permissionSnapshotVersion: handle.permissionSnapshotVersion, command: inputs[0]?.command })
|
|
60
|
+
: undefined;
|
|
61
|
+
for (const [index, entry] of inputs.entries()) {
|
|
62
|
+
const classification = classifications[index]!;
|
|
63
|
+
const outsideCwd = Boolean(entry.cwd && (isAbsolute(entry.cwd) || (() => {
|
|
64
|
+
const rel = relative(resolve(handle.worktreePath), resolve(handle.worktreePath, entry.cwd));
|
|
65
|
+
return rel === '..' || rel.startsWith('../') || rel.startsWith('..\\');
|
|
66
|
+
})()));
|
|
67
|
+
const risk = outsideCwd ? 'outside_repository' : authorizationRisk(entry.command, classification);
|
|
68
|
+
const decision = resolvedRequest
|
|
69
|
+
? { decision: 'allow', source: 'user_confirmation', reason: 'The user resolved the exact approval request for this command.' } as const
|
|
70
|
+
: decideAuthorization({
|
|
71
|
+
controllerHome: ctx.controllerHome,
|
|
72
|
+
accessMode: readRepositoryAccessPolicy(ctx.controllerHome, handle.repositoryId).mode,
|
|
73
|
+
risk,
|
|
74
|
+
repositoryId: handle.repositoryId,
|
|
75
|
+
currentRepositoryId: handle.repositoryId,
|
|
76
|
+
workId: handle.workId,
|
|
77
|
+
boundWorkId: handle.workId,
|
|
78
|
+
goalId: handle.goalId,
|
|
79
|
+
boundGoalId: handle.goalId,
|
|
80
|
+
sessionId: session.sessionId,
|
|
81
|
+
principalId: session.principalId,
|
|
82
|
+
permissionSnapshotVersion: handle.permissionSnapshotVersion,
|
|
83
|
+
delegation: session.goalDelegation,
|
|
84
|
+
worktreePath: handle.worktreePath,
|
|
85
|
+
cwd: entry.cwd,
|
|
86
|
+
command: entry.command,
|
|
87
|
+
approvedByUser: Boolean(resolvedRequest),
|
|
88
|
+
});
|
|
89
|
+
decisions.push(decision);
|
|
90
|
+
if (decision.decision !== 'allow') return { authorization: decision, work: compactHandle(handle), command: entry.command };
|
|
91
|
+
}
|
|
92
|
+
const invocationId = typeof args.request_id === 'string' && args.request_id.trim()
|
|
93
|
+
? args.request_id.trim()
|
|
94
|
+
: `invoke-${session.sessionId}-${handle.workId}-${Date.now()}-${randomUUID().slice(0, 8)}`;
|
|
95
|
+
const invocationFingerprint = createHash('sha256')
|
|
96
|
+
.update(JSON.stringify({
|
|
97
|
+
tool: 'work_execute',
|
|
98
|
+
workId: handle.workId,
|
|
99
|
+
commands: inputs.map((entry) => ({ command: entry.command, cwd: entry.cwd ?? null })),
|
|
100
|
+
timeoutMs: typeof args.timeout_ms === 'number' ? args.timeout_ms : null,
|
|
101
|
+
maxOutputBytes: typeof args.max_output_bytes === 'number' ? args.max_output_bytes : null,
|
|
102
|
+
}))
|
|
103
|
+
.digest('hex');
|
|
104
|
+
claimProcessInvocation({
|
|
105
|
+
controllerHome: ctx.controllerHome,
|
|
106
|
+
repoId: handle.repositoryId,
|
|
107
|
+
checkoutId: handle.checkoutId,
|
|
108
|
+
requestId: invocationId,
|
|
109
|
+
invocationFingerprint,
|
|
110
|
+
});
|
|
111
|
+
const run = async (entry: typeof inputs[number], index: number) => {
|
|
112
|
+
const commandId = `${invocationId}:command:${index + 1}`;
|
|
113
|
+
const execution = await executeRepositoryCommandViaProcessRuntime({
|
|
114
|
+
controllerHome: ctx.controllerHome,
|
|
115
|
+
repository: cheap.worktreeRepository,
|
|
116
|
+
executionIdentity: executionIdentityForWork(cheap.worktreeRepository, handle),
|
|
117
|
+
command: entry.command,
|
|
118
|
+
cwd: entry.cwd,
|
|
119
|
+
workId: handle.workId,
|
|
120
|
+
commandId,
|
|
121
|
+
requestId: commandId,
|
|
122
|
+
timeoutMs: typeof args.timeout_ms === 'number' ? args.timeout_ms : undefined,
|
|
123
|
+
maxOutputBytes: typeof args.max_output_bytes === 'number' ? args.max_output_bytes : undefined,
|
|
124
|
+
});
|
|
125
|
+
const process = execution.process;
|
|
126
|
+
const ok = process ? process.ok === true : execution.ok === true;
|
|
127
|
+
const status = process
|
|
128
|
+
? (process.completed
|
|
129
|
+
? (process.cancelled ? 'cancelled' : process.timedOut ? 'timed_out' : ok ? 'executed' : 'failed')
|
|
130
|
+
: 'running')
|
|
131
|
+
: execution.route === 'process_direct'
|
|
132
|
+
? (ok ? 'executed' : 'failed')
|
|
133
|
+
: execution.route === 'durable'
|
|
134
|
+
? 'deferred_durable'
|
|
135
|
+
: 'rejected';
|
|
136
|
+
return {
|
|
137
|
+
processId: process?.processId,
|
|
138
|
+
commandId,
|
|
139
|
+
requestId: commandId,
|
|
140
|
+
status,
|
|
141
|
+
ok,
|
|
142
|
+
exitCode: process?.exitCode ?? execution.exitCode,
|
|
143
|
+
timedOut: process?.timedOut === true,
|
|
144
|
+
cancelled: process?.cancelled === true,
|
|
145
|
+
startedAt: process?.startedAt,
|
|
146
|
+
finishedAt: process?.completed ? process.startedAt : undefined,
|
|
147
|
+
stdout: process?.stdout ?? execution.stdout,
|
|
148
|
+
stderr: process?.stderr ?? execution.stderr,
|
|
149
|
+
logArtifact: process?.processId ? { processId: process.processId, kind: 'process_logs' } : undefined,
|
|
150
|
+
route: execution.route,
|
|
151
|
+
reason: execution.reason,
|
|
152
|
+
authorizationDecision: decisions[index],
|
|
153
|
+
approvalRequestId: resolvedRequest?.approvalRequestId,
|
|
154
|
+
authorization: decisions[index]?.decision === 'allow'
|
|
155
|
+
? (resolvedRequest ? 'confirmed_plan' : decisions[index]?.source)
|
|
156
|
+
: 'explicit_user_request',
|
|
157
|
+
durableSideEffects: execution.durableSideEffects,
|
|
158
|
+
process,
|
|
159
|
+
};
|
|
160
|
+
};
|
|
161
|
+
const started = performance.now();
|
|
162
|
+
const executions = classifications.every((classification) => classification.risk === 'readonly')
|
|
163
|
+
? await Promise.all(inputs.map((entry, index) => run(entry, index)))
|
|
164
|
+
: await (async () => {
|
|
165
|
+
const ordered: Awaited<ReturnType<typeof run>>[] = [];
|
|
166
|
+
for (const [index, entry] of inputs.entries()) {
|
|
167
|
+
const execution = await run(entry, index);
|
|
168
|
+
ordered.push(execution);
|
|
169
|
+
// Do not launch another mutating command while a Work-owned Lightweight
|
|
170
|
+
// process is still changing the workspace. This is sequencing, not a
|
|
171
|
+
// durable Lease boundary; the caller resumes through process_wait.
|
|
172
|
+
if (execution.process && !execution.process.completed) break;
|
|
173
|
+
}
|
|
174
|
+
return ordered;
|
|
175
|
+
})();
|
|
176
|
+
const branch = repositoryGitStatus(cheap.worktreeRepository).branch;
|
|
177
|
+
const head = gitHead(cheap.worktreeRepository.canonicalRoot);
|
|
178
|
+
let nextHandle = handle;
|
|
179
|
+
if (branch !== handle.branch) nextHandle = markWorkHandleFailed(ctx.controllerHome, handle, `command changed the bound branch to ${branch ?? 'detached'}`);
|
|
180
|
+
else nextHandle = transitionWorkHandle(ctx.controllerHome, handle, 'editing', { expectedHead: head, failureReason: undefined });
|
|
181
|
+
const value = {
|
|
182
|
+
work: compactHandle(nextHandle),
|
|
183
|
+
commands: executions,
|
|
184
|
+
executedCount: executions.filter((entry) => entry.status === 'executed' && entry.ok === true).length,
|
|
185
|
+
managedProcessCount: executions.filter((entry) => entry.process && !entry.process.completed).length,
|
|
186
|
+
deferredCommandCount: Math.max(0, inputs.length - executions.length),
|
|
187
|
+
authorization: decisions[0],
|
|
188
|
+
requestId: invocationId,
|
|
189
|
+
};
|
|
190
|
+
const response = makeBoundedWorkResult(ctx, session, handle.repositoryId, handle.workId, 'command', value);
|
|
191
|
+
recordMcpTiming(ctx.controllerHome, { tool: 'work_execute', workHandleValidationMs: 0, commandExecutionMs: Math.round((performance.now() - started) * 100) / 100, totalToolDurationMs: Math.round((performance.now() - started) * 100) / 100, sessionId: session.sessionId, repoId: handle.repositoryId, workId: handle.workId });
|
|
192
|
+
return response;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export function selectDefaultWorkValidationChecks(
|
|
196
|
+
contract: ReturnType<typeof contractFor>,
|
|
197
|
+
changedPaths: string[],
|
|
198
|
+
): string[] {
|
|
199
|
+
if (!contract || changedPaths.length === 0) return [];
|
|
200
|
+
if (contract.risk === 'medium' || contract.risk === 'high' || contract.risk === 'destructive') {
|
|
201
|
+
return [...contract.checks];
|
|
202
|
+
}
|
|
203
|
+
const sourceTypeScriptChanged = changedPaths.some((path) =>
|
|
204
|
+
/\.(?:ts|tsx|mts|cts)$/.test(path)
|
|
205
|
+
&& !/(?:^|\/)(?:tests?|__tests__|fixtures)(?:\/|$)/.test(path));
|
|
206
|
+
return contract.checks.filter((checkId) => {
|
|
207
|
+
const normalized = checkId.toLowerCase();
|
|
208
|
+
if (normalized.includes('package:test') || normalized.includes('full') || normalized.includes('architecture') || normalized.includes('release') || normalized.includes('runtime')) {
|
|
209
|
+
return false;
|
|
210
|
+
}
|
|
211
|
+
if (normalized.includes('type')) return sourceTypeScriptChanged;
|
|
212
|
+
return normalized.includes('focused') || normalized.includes('unit') || normalized.includes('changed') || normalized.includes('target');
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export async function validateWork(ctx: MultiRepositoryMcpToolContext, args: Record<string, unknown>): Promise<Record<string, unknown>> {
|
|
217
|
+
const session = requireSession(ctx, args);
|
|
218
|
+
const handle = workForSession(ctx, session, args, {
|
|
219
|
+
reconcileValidation: false,
|
|
220
|
+
allowClaimedTerminalCleanup: args.cleanup !== false,
|
|
221
|
+
});
|
|
222
|
+
const terminalOutcome = terminalCleanupOutcome(ctx, handle);
|
|
223
|
+
if (terminalOutcome && args.cleanup !== false) {
|
|
224
|
+
return await reconcileTerminalCleanup(ctx, session, handle, args, terminalOutcome);
|
|
225
|
+
}
|
|
226
|
+
const validated = validateWorkHandle(ctx.controllerHome, handle, identityFor(ctx, args), 'full', 'validate');
|
|
227
|
+
const contract = contractFor(ctx, handle);
|
|
228
|
+
const changed = repositoryGitDiff(validated.worktreeRepository, { maxBytes: 64 * 1024 });
|
|
229
|
+
const changedPaths = Array.isArray(changed.nameOnly) ? changed.nameOnly.map(String) : [];
|
|
230
|
+
const requestedChecks = Array.isArray(args.check_ids)
|
|
231
|
+
? args.check_ids.map(String).filter(Boolean)
|
|
232
|
+
: selectDefaultWorkValidationChecks(contract, changedPaths);
|
|
233
|
+
const validationInvocationId = typeof args.request_id === 'string' && args.request_id.trim()
|
|
234
|
+
? args.request_id.trim()
|
|
235
|
+
: `validate-${session.sessionId}-${handle.workId}-${Date.now()}-${randomUUID().slice(0, 8)}`;
|
|
236
|
+
const validationStatus = repositoryGitStatus(validated.worktreeRepository);
|
|
237
|
+
const validationHead = validationStatus.head
|
|
238
|
+
?? handle.expectedHead
|
|
239
|
+
?? handle.baseCommit
|
|
240
|
+
?? 'unknown';
|
|
241
|
+
const workspaceFingerprint = workspaceValidationFingerprint(
|
|
242
|
+
validated.worktreeRepository.canonicalRoot,
|
|
243
|
+
validationStatus,
|
|
244
|
+
);
|
|
245
|
+
const validationFingerprint = workValidationInputFingerprint(
|
|
246
|
+
validationHead,
|
|
247
|
+
workspaceFingerprint,
|
|
248
|
+
requestedChecks,
|
|
249
|
+
);
|
|
250
|
+
const previousRun = handle.validationRun?.fingerprint === validationFingerprint
|
|
251
|
+
? handle.validationRun
|
|
252
|
+
: undefined;
|
|
253
|
+
let validationRun: NonNullable<WorkHandleState['validationRun']> = previousRun ?? {
|
|
254
|
+
fingerprint: validationFingerprint,
|
|
255
|
+
head: validationHead,
|
|
256
|
+
workspaceFingerprint,
|
|
257
|
+
requestedChecks,
|
|
258
|
+
resumeState: handle.state === 'committed' || handle.state === 'merged' ? handle.state : 'editing',
|
|
259
|
+
processes: {},
|
|
260
|
+
};
|
|
261
|
+
let current = transitionWorkHandle(ctx.controllerHome, handle, 'validating', {
|
|
262
|
+
finalization: { ...handle.finalization, validation: 'pending', lastError: undefined },
|
|
263
|
+
validationRun,
|
|
264
|
+
});
|
|
265
|
+
markWorkValidationPending(ctx.controllerHome, current);
|
|
266
|
+
const available = new Set(listControllerChecks(validated.worktreeRepository.canonicalRoot).map((check) => check.id));
|
|
267
|
+
const checks: Array<Record<string, unknown>> = [];
|
|
268
|
+
for (const [index, checkId] of requestedChecks.entries()) {
|
|
269
|
+
if (!available.has(checkId)) {
|
|
270
|
+
checks.push({ checkId, ok: false, status: 'missing', summary: `Check not found: ${checkId}` });
|
|
271
|
+
break;
|
|
272
|
+
}
|
|
273
|
+
const reusablePass = contract
|
|
274
|
+
? [...effectiveVerificationEvidence(contract.checkRefs, {
|
|
275
|
+
sourceRevision: validationHead,
|
|
276
|
+
workspaceFingerprint,
|
|
277
|
+
checkId,
|
|
278
|
+
requestedChecks,
|
|
279
|
+
})].reverse().find((entry) => entry.current && entry.record.outcome === 'valid_pass' && Boolean(entry.record.receipt))
|
|
280
|
+
: undefined;
|
|
281
|
+
if (reusablePass) {
|
|
282
|
+
checks.push({
|
|
283
|
+
checkId,
|
|
284
|
+
ok: true,
|
|
285
|
+
status: 'passed',
|
|
286
|
+
reusedEvidence: true,
|
|
287
|
+
receipt: reusablePass.record.receipt,
|
|
288
|
+
});
|
|
289
|
+
continue;
|
|
290
|
+
}
|
|
291
|
+
const existingBinding = validationRun.processes[checkId];
|
|
292
|
+
let process = existingBinding
|
|
293
|
+
? getCheckProcessHandle(ctx.controllerHome, handle.repositoryId, existingBinding.processId)
|
|
294
|
+
: undefined;
|
|
295
|
+
if (existingBinding && !process) {
|
|
296
|
+
checks.push({
|
|
297
|
+
checkId,
|
|
298
|
+
ok: false,
|
|
299
|
+
status: 'infrastructure_failure',
|
|
300
|
+
summary: `Validation process record is unavailable: ${existingBinding.processId}`,
|
|
301
|
+
});
|
|
302
|
+
break;
|
|
303
|
+
}
|
|
304
|
+
if (!process) {
|
|
305
|
+
const processRequestId = `${validationInvocationId}:check:${index + 1}`;
|
|
306
|
+
const executed = await runPersistedCheckViaProcessRuntime({
|
|
307
|
+
controllerHome: ctx.controllerHome,
|
|
308
|
+
repoId: handle.repositoryId,
|
|
309
|
+
checkoutId: handle.checkoutId,
|
|
310
|
+
repoRoot: validated.worktreeRepository.canonicalRoot,
|
|
311
|
+
executionIdentity: Object.freeze({
|
|
312
|
+
...executionIdentityForWork(validated.worktreeRepository, handle),
|
|
313
|
+
expectedHead: validationHead === 'unknown' ? handle.expectedHead : validationHead,
|
|
314
|
+
}),
|
|
315
|
+
checkId,
|
|
316
|
+
requestId: processRequestId,
|
|
317
|
+
workId: handle.workId,
|
|
318
|
+
commandId: processRequestId,
|
|
319
|
+
verificationBinding: { executionSessionId: session.sessionId },
|
|
320
|
+
verificationSnapshot: contract && contract.allowedPaths.length > 0 ? {
|
|
321
|
+
workId: contract.workId,
|
|
322
|
+
allowedPaths: contract.allowedPaths,
|
|
323
|
+
forbiddenPaths: contract.forbiddenPaths,
|
|
324
|
+
} : undefined,
|
|
325
|
+
});
|
|
326
|
+
if (executed.mode === 'durable') {
|
|
327
|
+
checks.push({ checkId, ok: undefined, status: 'deferred', summary: executed.durable?.reason, durable: executed.durable });
|
|
328
|
+
break;
|
|
329
|
+
}
|
|
330
|
+
process = executed.process!;
|
|
331
|
+
const launchedRecord = getProcessRecord(ctx.controllerHome, handle.repositoryId, process.processId);
|
|
332
|
+
validationRun = {
|
|
333
|
+
...validationRun,
|
|
334
|
+
processes: {
|
|
335
|
+
...validationRun.processes,
|
|
336
|
+
[checkId]: {
|
|
337
|
+
processId: process.processId,
|
|
338
|
+
requestId: processRequestId,
|
|
339
|
+
...(launchedRecord?.checkExecution ? { checkExecution: { ...launchedRecord.checkExecution } } : {}),
|
|
340
|
+
},
|
|
341
|
+
},
|
|
342
|
+
};
|
|
343
|
+
current = transitionWorkHandle(ctx.controllerHome, current, 'validating', { validationRun });
|
|
344
|
+
}
|
|
345
|
+
if (!process.completed) {
|
|
346
|
+
checks.push({ checkId, ok: undefined, status: 'running', process });
|
|
347
|
+
break;
|
|
348
|
+
}
|
|
349
|
+
const record = getProcessRecord(ctx.controllerHome, handle.repositoryId, process.processId);
|
|
350
|
+
if (!record) {
|
|
351
|
+
checks.push({ checkId, ok: false, status: 'infrastructure_failure', summary: `Validation process record is unavailable: ${process.processId}` });
|
|
352
|
+
break;
|
|
353
|
+
}
|
|
354
|
+
const boundCheckExecution = validationRun.processes[checkId]?.checkExecution;
|
|
355
|
+
const receipt = processCheckCompletionReceipt(record, {
|
|
356
|
+
repoId: handle.repositoryId,
|
|
357
|
+
checkoutId: handle.checkoutId,
|
|
358
|
+
workId: handle.workId,
|
|
359
|
+
checkId,
|
|
360
|
+
processId: process.processId,
|
|
361
|
+
...(boundCheckExecution ? {
|
|
362
|
+
checkExecution: {
|
|
363
|
+
cacheKey: boundCheckExecution.cacheKey,
|
|
364
|
+
revision: boundCheckExecution.revision,
|
|
365
|
+
definitionDigest: boundCheckExecution.definitionDigest,
|
|
366
|
+
environmentFingerprint: boundCheckExecution.environmentFingerprint,
|
|
367
|
+
timeoutMs: boundCheckExecution.timeoutMs,
|
|
368
|
+
scopeKey: boundCheckExecution.scopeKey,
|
|
369
|
+
},
|
|
370
|
+
} : {}),
|
|
371
|
+
});
|
|
372
|
+
const terminalEvidence = classifyPersistedCheckTerminalEvidence(record, checkId);
|
|
373
|
+
const infrastructureFailed = receipt.timedOut
|
|
374
|
+
|| receipt.cancelled
|
|
375
|
+
|| terminalEvidence.state !== 'matched'
|
|
376
|
+
|| (!receipt.ok && terminalEvidence.failureClass !== 'acceptance_failure');
|
|
377
|
+
appendVerificationRecord({ controllerHome: ctx.controllerHome, repoId: handle.repositoryId }, handle.workId, {
|
|
378
|
+
checkId,
|
|
379
|
+
outcome: infrastructureFailed ? 'infrastructure_failure' : receipt.ok ? 'valid_pass' : 'valid_fail',
|
|
380
|
+
summary: receipt.summary,
|
|
381
|
+
recordedAt: receipt.finishedAt,
|
|
382
|
+
sourceRevision: validationHead,
|
|
383
|
+
workspaceFingerprint,
|
|
384
|
+
verificationInputFingerprint: verificationInputFingerprint({
|
|
385
|
+
sourceRevision: validationHead,
|
|
386
|
+
workspaceFingerprint,
|
|
387
|
+
checkId,
|
|
388
|
+
requestedChecks,
|
|
389
|
+
}),
|
|
390
|
+
commandFingerprint: commandFingerprint(checkId, receipt.commandId),
|
|
391
|
+
resultArtifactId: receipt.receiptId,
|
|
392
|
+
startedAt: receipt.startedAt,
|
|
393
|
+
completedAt: receipt.finishedAt,
|
|
394
|
+
evidenceRef: { title: checkId, summary: `${receipt.receiptId}; artifact=${receipt.artifactPath}`, detailLevel: 'summary' },
|
|
395
|
+
receipt,
|
|
396
|
+
});
|
|
397
|
+
checks.push({
|
|
398
|
+
checkId,
|
|
399
|
+
ok: receipt.ok,
|
|
400
|
+
status: infrastructureFailed ? 'infrastructure_failure' : receipt.ok ? 'passed' : 'failed',
|
|
401
|
+
process,
|
|
402
|
+
receipt,
|
|
403
|
+
...(terminalEvidence.warning ? { warning: terminalEvidence.warning } : {}),
|
|
404
|
+
});
|
|
405
|
+
if (!receipt.ok) break;
|
|
406
|
+
}
|
|
407
|
+
const infrastructureFailure = checks.find((check) => (
|
|
408
|
+
check.status === 'missing'
|
|
409
|
+
|| check.status === 'infrastructure_failure'
|
|
410
|
+
|| check.status === 'deferred'
|
|
411
|
+
));
|
|
412
|
+
const acceptedFailure = checks.find((check) => check.status === 'failed');
|
|
413
|
+
const allObserved = checks.length === requestedChecks.length && checks.every((check) => check.ok !== undefined);
|
|
414
|
+
const completed = Boolean(infrastructureFailure || acceptedFailure || allObserved);
|
|
415
|
+
const passed = completed
|
|
416
|
+
&& !infrastructureFailure
|
|
417
|
+
&& !acceptedFailure
|
|
418
|
+
&& checks.every((check) => check.ok === true);
|
|
419
|
+
const failureSummary = infrastructureFailure
|
|
420
|
+
? String(infrastructureFailure.summary ?? 'validation infrastructure failure')
|
|
421
|
+
: acceptedFailure
|
|
422
|
+
? String(acceptedFailure.summary ?? 'targeted validation failed')
|
|
423
|
+
: undefined;
|
|
424
|
+
const nextState = !completed
|
|
425
|
+
? 'validating'
|
|
426
|
+
: passed
|
|
427
|
+
? validationRun.resumeState
|
|
428
|
+
: 'failed';
|
|
429
|
+
const validation = !completed ? 'pending' : passed ? 'done' : 'failed';
|
|
430
|
+
const next = transitionWorkHandle(ctx.controllerHome, current, nextState, {
|
|
431
|
+
finalization: {
|
|
432
|
+
...current.finalization,
|
|
433
|
+
validation,
|
|
434
|
+
lastError: failureSummary,
|
|
435
|
+
},
|
|
436
|
+
validationRun: completed ? undefined : validationRun,
|
|
437
|
+
...(passed ? { validatedInputFingerprint: validationFingerprint } : {}),
|
|
438
|
+
...(failureSummary ? { failureReason: failureSummary } : { failureReason: undefined }),
|
|
439
|
+
});
|
|
440
|
+
if (completed) {
|
|
441
|
+
projectWorkValidationOutcome(
|
|
442
|
+
ctx.controllerHome,
|
|
443
|
+
next,
|
|
444
|
+
infrastructureFailure ? 'infrastructure_failure' : passed ? 'passed' : 'failed',
|
|
445
|
+
failureSummary,
|
|
446
|
+
);
|
|
447
|
+
}
|
|
448
|
+
if (completed && acceptedFailure) {
|
|
449
|
+
const cleanup = await reconcileTerminalCleanup(ctx, session, next, args, 'validation_failed');
|
|
450
|
+
const value = {
|
|
451
|
+
...cleanup,
|
|
452
|
+
validation: { passed, completed, checks, targeted: true, changedPaths, cleanupTriggered: true },
|
|
453
|
+
};
|
|
454
|
+
return makeBoundedWorkResult(ctx, session, handle.repositoryId, handle.workId, 'validation', value);
|
|
455
|
+
}
|
|
456
|
+
const value = {
|
|
457
|
+
work: compactHandle(next),
|
|
458
|
+
validation: {
|
|
459
|
+
passed,
|
|
460
|
+
completed,
|
|
461
|
+
checks,
|
|
462
|
+
targeted: true,
|
|
463
|
+
changedPaths,
|
|
464
|
+
...(infrastructureFailure ? { retryable: true, cleanupTriggered: false } : {}),
|
|
465
|
+
},
|
|
466
|
+
};
|
|
467
|
+
return makeBoundedWorkResult(ctx, session, handle.repositoryId, handle.workId, 'validation', value);
|
|
468
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { globMatches } from '../../../cli/mcp/paths';
|
|
2
|
+
import type { WorkContract } from '../facade/types';
|
|
3
|
+
|
|
4
|
+
export interface WorkPathScopeViolation {
|
|
5
|
+
kind: 'forbidden' | 'out_of_scope';
|
|
6
|
+
path: string;
|
|
7
|
+
pattern?: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* WorkContract path scope is the durable mutation authority. Edit-session or
|
|
12
|
+
* caller-local path filters may narrow this scope, but they can never widen it.
|
|
13
|
+
*/
|
|
14
|
+
export function findWorkPathScopeViolation(
|
|
15
|
+
work: Pick<WorkContract, 'allowedPaths' | 'forbiddenPaths'>,
|
|
16
|
+
paths: readonly string[],
|
|
17
|
+
): WorkPathScopeViolation | undefined {
|
|
18
|
+
for (const path of [...new Set(paths.map((value) => value.trim()).filter(Boolean))]) {
|
|
19
|
+
const forbidden = work.forbiddenPaths.find((pattern) => globMatches(pattern, path));
|
|
20
|
+
if (forbidden) return { kind: 'forbidden', path, pattern: forbidden };
|
|
21
|
+
if (work.allowedPaths.length > 0 && !work.allowedPaths.some((pattern) => globMatches(pattern, path))) {
|
|
22
|
+
return { kind: 'out_of_scope', path };
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
return undefined;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function assertWorkPathsWithinScope(
|
|
29
|
+
work: Pick<WorkContract, 'allowedPaths' | 'forbiddenPaths'>,
|
|
30
|
+
paths: readonly string[],
|
|
31
|
+
codes: { forbidden: string; outOfScope: string },
|
|
32
|
+
): void {
|
|
33
|
+
const violation = findWorkPathScopeViolation(work, paths);
|
|
34
|
+
if (!violation) return;
|
|
35
|
+
if (violation.kind === 'forbidden') throw new Error(`${codes.forbidden}: ${violation.path}`);
|
|
36
|
+
throw new Error(`${codes.outOfScope}: ${violation.path}`);
|
|
37
|
+
}
|