@zq-silk/yui 0.15.9 → 0.15.11
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/dist/agent/launchEnvironment.js +7 -0
- package/dist/cli/commandCatalog.js +26 -6
- package/dist/cli/interactionPolicy.js +3 -3
- package/dist/cli/updateOrchestrator.js +24 -1
- package/dist/cli/updatePorts.js +7 -3
- package/dist/cli/upgradeCommand.js +42 -2
- package/dist/cli.js +336 -93
- package/dist/commands/globalRoleCommands.js +314 -4
- package/dist/commands/projectCommands.js +6 -7
- package/dist/commands/releaseCommands.js +18 -0
- package/dist/commands/taskActor.js +25 -0
- package/dist/commands/taskCommands.js +511 -83
- package/dist/commands/taskIntegrationCommands.js +16 -38
- package/dist/commands/taskIntegrationQueueCommands.js +1 -1
- package/dist/commands/taskRemoteDeliveryCommand.js +6 -6
- package/dist/commands/taskRoleRuntimeStatus.js +35 -0
- package/dist/context/taskContext.js +38 -2
- package/dist/controller/agentHostObservation.js +155 -0
- package/dist/controller/clientRuntime.js +17 -2
- package/dist/controller/controller.js +3 -0
- package/dist/controller/fileSchedulerStoreAdapter.js +446 -13
- package/dist/controller/globalInputDelivery.js +119 -0
- package/dist/controller/jobControl.js +6 -2
- package/dist/controller/resourceInventory.js +14 -4
- package/dist/controller/resourceInventoryLinux.js +2 -6
- package/dist/controller/runtime.js +81 -6
- package/dist/controller/runtimeEventInbox.js +32 -3
- package/dist/controller/runtimeEventProcessor.js +26 -6
- package/dist/controller/runtimeHookRunFence.js +75 -19
- package/dist/controller/structuredProviderObservation.js +133 -70
- package/dist/coordination/workMailboxQueue.js +5 -0
- package/dist/execution/workItemExecutionProjection.js +1 -1
- package/dist/executor/agentExecutor.js +64 -4
- package/dist/executor/executorRegistry.js +3 -0
- package/dist/executor/fileRoleLaunchPlanner.js +78 -118
- package/dist/integration/deliveryObligation.js +2 -1
- package/dist/integration/gitIntegrationService.js +312 -382
- package/dist/integration/integrationAttempt.js +30 -4
- package/dist/integration/integrationQueueService.js +7 -7
- package/dist/integration/integrationSourceApplication.js +323 -0
- package/dist/message/globalInterrupt.js +33 -0
- package/dist/message/inputControlResolution.js +106 -0
- package/dist/message/message.js +367 -0
- package/dist/message/messageContinuation.js +126 -3
- package/dist/message/taskInterrupt.js +34 -0
- package/dist/observability/orchestrationMetrics.js +1 -1
- package/dist/release/releaseHandover.js +22 -0
- package/dist/release/releaseWorkflowPorts.js +15 -7
- package/dist/repository/gitWorkspace.js +72 -15
- package/dist/repository/taskWorkspaceCoordinator.js +134 -0
- package/dist/repository/taskWorkspacePreparer.js +120 -49
- package/dist/repository/workItemCandidateSnapshot.js +34 -0
- package/dist/resources/resourceDiscovery.js +3 -2
- package/dist/runtime/agentHost.js +152 -72
- package/dist/runtime/agentHostCompatibility.js +127 -0
- package/dist/runtime/agentHostProtocol.js +53 -0
- package/dist/runtime/executionEnvironment.js +0 -19
- package/dist/runtime/launchBroker.js +6 -0
- package/dist/runtime/sessionReconciliation.js +4 -4
- package/dist/runtime/taskRuntimeIsolation.js +30 -6
- package/dist/runtime/tmuxAdapters.js +5 -3
- package/dist/scheduler/operatorEvent.js +4 -0
- package/dist/scheduler/taskExecutionProjection.js +12 -1
- package/dist/scheduler/wakeReason.js +7 -1
- package/dist/scheduler/wakeupQueue.js +2 -0
- package/dist/setup/setupCommand.js +26 -8
- package/dist/storage/homeLayout.js +130 -0
- package/dist/storage/migrations/collapseWorktreeLayout.js +963 -0
- package/dist/storage/migrations/integrationContinuation.js +104 -0
- package/dist/storage/migrations/unifyHomeLayout.js +925 -0
- package/dist/storage/sqliteSchema.js +136 -4
- package/dist/storage/sqliteStore.js +40 -1
- package/dist/storage/storageVersions.js +1 -1
- package/dist/storage/storeRpc.js +1 -0
- package/dist/storage/upgrade/upgradeOrchestrator.js +95 -2
- package/dist/task/archiveDiagnostics.js +128 -0
- package/dist/task/nextAction.js +44 -11
- package/dist/web/assets/client/app.js +55 -0
- package/dist/web/assets/client/components.js +1 -0
- package/dist/web/assets/client/i18n.js +6 -0
- package/dist/web/assets/client/taskSurface.js +106 -0
- package/dist/web/assets/client/view.js +7 -4
- package/dist/web/assets/shell.js +23 -0
- package/dist/web/assets/styles/layout.js +1 -1
- package/dist/web/assets/styles/widgets.js +12 -0
- package/dist/web/webServer.js +117 -1
- package/dist/web/webSnapshot.js +4 -3
- package/dist/web/webTaskSurface.js +222 -5
- package/dist/workspace/workItemChangeSetManager.js +18 -2
- package/docs/release-workflow.md +39 -0
- package/docs/release-workflow.zh-CN.md +29 -0
- package/docs/sqlite-control-plane-design.md +223 -1
- package/docs/testing/verification-levels.md +24 -0
- package/docs/testing/verification-levels.zh-CN.md +11 -0
- package/package.json +1 -1
- package/skills/yui-leader/references/execution.md +151 -49
- package/skills/yui-leader/references/integration.md +52 -2
- package/skills/yui-operator/SKILL.md +6 -1
- package/skills/yui-runtime/SKILL.md +27 -0
- package/skills/yui-runtime/references/publication.md +20 -0
|
@@ -6,18 +6,21 @@ import { dirname, join, relative, resolve, sep } from "node:path";
|
|
|
6
6
|
import { promisify } from "node:util";
|
|
7
7
|
import { selectEnvironment } from "../agent/launchEnvironment.js";
|
|
8
8
|
import { controllerSocketPath } from "../core/controllerEndpoint.js";
|
|
9
|
+
import { durableJobIdempotencyKey } from "../job/durableJob.js";
|
|
10
|
+
import { applyIntegrationSource, assertIntegrationCandidate, assertRecordedSourceCandidate } from "./integrationSourceApplication.js";
|
|
9
11
|
import { planBootstrapJobSteps, planL2JobSteps } from "../verification/verificationPlan.js";
|
|
10
12
|
import { assertNoAdHocFullSuiteChecks, checkResultsFromGateArtifact, checkResultsFromGateJob, gateIdentityForCandidate, lookupReusableGateArtifact, recordGateArtifactFromJob, recordGateArtifactFromStepOutcomes, resolveVerificationGate, runGateStepsInProcess } from "../verification/verificationGateService.js";
|
|
11
13
|
import { recordGateArtifactPotentialReuse, recordGateArtifactReuse } from "../verification/gateArtifact.js";
|
|
12
14
|
import { touchGateArtifact } from "../verification/gateArtifactStore.js";
|
|
13
|
-
import { NodeGitWorkspace
|
|
14
|
-
import {
|
|
15
|
+
import { NodeGitWorkspace } from "../repository/gitWorkspace.js";
|
|
16
|
+
import { integrationWorkspaceRoot } from "../repository/taskWorkspacePreparer.js";
|
|
15
17
|
import { acquireProjectMaintenanceLocks } from "../repository/projectMaintenanceLock.js";
|
|
16
18
|
import { taskWorkspaceRefSegment } from "../repository/taskWorkspaceIdentity.js";
|
|
17
19
|
import { FileTaskRuntimeIsolation } from "../runtime/taskRuntimeIsolation.js";
|
|
20
|
+
import { managedIntegrationRuntimeRoot } from "../storage/homeLayout.js";
|
|
18
21
|
import { advanceTaskProjectCommit } from "../task/task.js";
|
|
19
22
|
import { yuiTmuxServerName } from "../tmux/tmuxManager.js";
|
|
20
|
-
import { recordIntegrationCheckJob,
|
|
23
|
+
import { recordIntegrationCheckJob, updateIntegrationAttempt } from "./integrationAttempt.js";
|
|
21
24
|
import { createManagedWorkspace } from "../worktree/managedWorkspace.js";
|
|
22
25
|
import { ResourceRegistrar } from "../resources/resourceRegistrar.js";
|
|
23
26
|
import { readRuntimeIdentity } from "../release/runtimeRelease.js";
|
|
@@ -52,16 +55,12 @@ const INTEGRATION_OPERATIONAL_ENVIRONMENT_NAMES = [
|
|
|
52
55
|
"CURL_CA_BUNDLE",
|
|
53
56
|
"REQUESTS_CA_BUNDLE"
|
|
54
57
|
];
|
|
55
|
-
export const REMOTE_BASELINE_CONFLICT_PREFIX = "Upstream rebase conflicts";
|
|
56
|
-
const WORK_ITEM_MERGE_CONFLICT_PREFIX = "WorkItem merge conflicts";
|
|
57
|
-
const MANUAL_INTEGRATION_PREFIX = "Manual WorkItem integration";
|
|
58
58
|
export class GitIntegrationService {
|
|
59
59
|
store;
|
|
60
60
|
git;
|
|
61
61
|
now;
|
|
62
62
|
jobPort;
|
|
63
63
|
home;
|
|
64
|
-
worktreeRoot;
|
|
65
64
|
environment;
|
|
66
65
|
runtimeIsolation;
|
|
67
66
|
#resourceRegistrarValue;
|
|
@@ -71,7 +70,6 @@ export class GitIntegrationService {
|
|
|
71
70
|
this.now = now;
|
|
72
71
|
this.jobPort = jobPort;
|
|
73
72
|
this.home = resolve(home);
|
|
74
|
-
this.worktreeRoot = resolveWorktreeRoot(home, store.getConfig().defaultWorkspace);
|
|
75
73
|
this.environment = { ...environment };
|
|
76
74
|
this.runtimeIsolation = runtimeIsolation;
|
|
77
75
|
}
|
|
@@ -79,7 +77,10 @@ export class GitIntegrationService {
|
|
|
79
77
|
return this.#resourceRegistrarValue ??= new ResourceRegistrar(this.home, this.now);
|
|
80
78
|
}
|
|
81
79
|
async integrate(taskId, integrationId) {
|
|
82
|
-
|
|
80
|
+
let initial = requireIntegration(this.store, taskId, integrationId);
|
|
81
|
+
if (!["running", "conflicted", "blocked", "validating"].includes(initial.status)) {
|
|
82
|
+
throw new Error(`Integration cannot continue from ${initial.status}.`);
|
|
83
|
+
}
|
|
83
84
|
// The whole Integration is one Git transaction against the Project's
|
|
84
85
|
// repository: worktree creation, cherry-picks, checks, and the target ref
|
|
85
86
|
// CAS. A concurrent `project migrate` must not switch the catalog path or
|
|
@@ -87,34 +88,46 @@ export class GitIntegrationService {
|
|
|
87
88
|
// fence is held across every Git effect and released only on exit.
|
|
88
89
|
const release = acquireProjectMaintenanceLocks(this.home, [initial.projectId]);
|
|
89
90
|
try {
|
|
91
|
+
// A concurrent abort may have settled the attempt while this caller was
|
|
92
|
+
// acquiring the same Project fence. Never execute a pre-fence snapshot.
|
|
93
|
+
const currentUnderFence = requireIntegration(this.store, taskId, integrationId);
|
|
94
|
+
if (currentUnderFence.projectId !== initial.projectId
|
|
95
|
+
|| !["running", "conflicted", "blocked", "validating"].includes(currentUnderFence.status)) {
|
|
96
|
+
throw new Error("Integration changed before execution admission; read its current record.");
|
|
97
|
+
}
|
|
98
|
+
initial = currentUnderFence;
|
|
90
99
|
const task = this.store.getTask(initial.taskId);
|
|
91
100
|
if (task === null || !task.projectBindings.some(({ projectId }) => projectId === initial.projectId)) {
|
|
92
101
|
throw new Error(`Integration Task Project is unavailable: ${initial.taskId}.`);
|
|
93
102
|
}
|
|
94
|
-
if (task.status !== "active") {
|
|
103
|
+
if (task.status !== "active" || task.executionGate.state !== "enabled") {
|
|
95
104
|
throw new Error(`Integration Task is not active: ${task.id}/${task.status}.`);
|
|
96
105
|
}
|
|
97
106
|
const project = this.store.getProject(initial.projectId);
|
|
98
107
|
if (project === null)
|
|
99
108
|
throw new Error(`Project not found: ${initial.projectId}.`);
|
|
109
|
+
if (project.status !== "active")
|
|
110
|
+
throw new Error(`Integration Project is not active: ${project.id}.`);
|
|
100
111
|
const taskWorkspace = this.store.getTaskWorkspace(task.id);
|
|
101
|
-
const
|
|
112
|
+
const taskEntry = taskWorkspace?.entries.find(({ projectId }) => projectId === project.id);
|
|
113
|
+
const taskRepository = taskEntry?.path;
|
|
102
114
|
if (taskWorkspace === null
|
|
103
115
|
|| taskWorkspace.owner.type !== "task"
|
|
116
|
+
|| taskEntry === undefined
|
|
104
117
|
|| taskRepository === undefined) {
|
|
105
118
|
throw new Error(`Integration Task clone is unavailable: ${task.id}/${project.id}.`);
|
|
106
119
|
}
|
|
107
120
|
// Issue 08: a Project with a VerificationPlan gates through its plan
|
|
108
121
|
// (bootstrap + L2) and reuses exact-SHA artifacts; an unconfigured
|
|
109
122
|
// Project keeps the existing explicit check path unchanged.
|
|
110
|
-
const gate = resolveVerificationGate(project, this.environment);
|
|
111
123
|
let prepared;
|
|
112
124
|
let workspace;
|
|
113
125
|
let managedWorkspace;
|
|
114
126
|
try {
|
|
115
127
|
prepared = await this.git.ensureIntegrationWorktree({
|
|
116
128
|
repositoryPath: taskRepository,
|
|
117
|
-
container:
|
|
129
|
+
container: integrationWorkspaceRoot(this.home, task.id, initial.id),
|
|
130
|
+
directory: taskEntry.directory,
|
|
118
131
|
taskSegment: taskWorkspaceRefSegment(task),
|
|
119
132
|
integrationId: initial.id,
|
|
120
133
|
baseRef: initial.beforeCommit
|
|
@@ -126,6 +139,12 @@ export class GitIntegrationService {
|
|
|
126
139
|
baseCommit: prepared.baseCommit
|
|
127
140
|
};
|
|
128
141
|
const existingWorkspace = this.store.getIntegrationWorkspace(task.id, initial.id);
|
|
142
|
+
if (existingWorkspace !== null && (resolve(existingWorkspace.root) !== resolve(prepared.path)
|
|
143
|
+
|| existingWorkspace.entries.length !== 1
|
|
144
|
+
|| existingWorkspace.entries[0]?.projectId !== project.id
|
|
145
|
+
|| existingWorkspace.entries[0]?.access !== "write"
|
|
146
|
+
|| existingWorkspace.entries[0]?.branch !== prepared.branch))
|
|
147
|
+
throw new Error("Integration managed workspace identity changed.");
|
|
129
148
|
managedWorkspace = existingWorkspace ?? createManagedWorkspace({
|
|
130
149
|
owner: {
|
|
131
150
|
type: "integration-attempt",
|
|
@@ -135,7 +154,7 @@ export class GitIntegrationService {
|
|
|
135
154
|
root: prepared.path,
|
|
136
155
|
entries: [{
|
|
137
156
|
projectId: project.id,
|
|
138
|
-
directory:
|
|
157
|
+
directory: taskEntry.directory,
|
|
139
158
|
access: "write",
|
|
140
159
|
path: prepared.path,
|
|
141
160
|
branch: prepared.branch,
|
|
@@ -150,19 +169,26 @@ export class GitIntegrationService {
|
|
|
150
169
|
return this.#fail(initial, error, "integration-preparation");
|
|
151
170
|
}
|
|
152
171
|
if (initial.status === "validating") {
|
|
153
|
-
return this.#recoverValidating(initial, workspace, taskRepository);
|
|
172
|
+
return await this.#recoverValidating(initial, workspace, managedWorkspace, taskRepository);
|
|
154
173
|
}
|
|
174
|
+
const gate = resolveVerificationGate(project, this.environment);
|
|
155
175
|
let current = initial;
|
|
156
176
|
// A check DurableJob is the source of truth for a running attempt that
|
|
157
177
|
// already bound one: never re-apply commits or spawn a second job. The
|
|
158
178
|
// job's terminal wakeup drives the resume through `integration continue`.
|
|
159
|
-
if (current.status === "running" && current.jobId !== undefined) {
|
|
160
|
-
return this.#resumeCheckJob(current, workspace, prepared.path, taskRepository, gate);
|
|
161
|
-
}
|
|
162
179
|
try {
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
180
|
+
current = await applyIntegrationSource({
|
|
181
|
+
attempt: current, workspace, remoteUrl: project.remoteUrl,
|
|
182
|
+
git: this.git, store: this.store, now: this.now
|
|
183
|
+
});
|
|
184
|
+
if (current.status === "conflicted" || current.status === "blocked") {
|
|
185
|
+
return { status: current.status, attempt: current, workspace };
|
|
186
|
+
}
|
|
187
|
+
await assertIntegrationCandidate(prepared.path, current.candidateCommit, workspace.branch);
|
|
188
|
+
if (current.jobId !== undefined || current.checkInputDigest !== undefined) {
|
|
189
|
+
if (this.jobPort === undefined)
|
|
190
|
+
throw new Error("Integration requires its Controller Job port to resume.");
|
|
191
|
+
return await this.#startCheckJob(current, workspace, prepared.path, managedWorkspace, taskRepository, gate);
|
|
166
192
|
}
|
|
167
193
|
// Static preflight: fail before any expensive check when the target
|
|
168
194
|
// moved or its worktree is dirty, so the check commands never run on a
|
|
@@ -170,17 +196,17 @@ export class GitIntegrationService {
|
|
|
170
196
|
// after the checks, so a move during the gate is still fenced at CAS.
|
|
171
197
|
await assertTargetReadyForChecks(taskRepository, current.targetRef, current.beforeCommit);
|
|
172
198
|
if (gate !== undefined) {
|
|
173
|
-
return this.#runVerificationGate(current, workspace, prepared.path, managedWorkspace, taskRepository, gate);
|
|
199
|
+
return await this.#runVerificationGate(current, workspace, prepared.path, managedWorkspace, taskRepository, gate);
|
|
174
200
|
}
|
|
175
201
|
if (this.jobPort !== undefined && current.checkCommands.length > 0) {
|
|
176
|
-
return this.#startCheckJob(current, workspace, prepared.path, managedWorkspace, taskRepository);
|
|
202
|
+
return await this.#startCheckJob(current, workspace, prepared.path, managedWorkspace, taskRepository);
|
|
177
203
|
}
|
|
204
|
+
const spec = await this.#checkSpecification(current, managedWorkspace, gate);
|
|
205
|
+
current = updateIntegrationAttempt(current, { checkInputDigest: spec.inputDigest }, this.now());
|
|
206
|
+
this.store.saveIntegrationAttempt(task.id, current);
|
|
178
207
|
const checkedHead = await gitLine(["-C", prepared.path, "rev-parse", "HEAD^{commit}"]);
|
|
179
|
-
const checkResults = await this.#runChecks(current, managedWorkspace, prepared.path);
|
|
180
|
-
|
|
181
|
-
if (afterHead !== checkedHead) {
|
|
182
|
-
return this.#fail(current, new Error(`Integration workspace moved during the checks: ${afterHead} != checked ${checkedHead}.`), "integration", workspace);
|
|
183
|
-
}
|
|
208
|
+
const checkResults = await this.#runChecks(current, managedWorkspace, prepared.path, spec.environment);
|
|
209
|
+
await assertIntegrationCandidate(prepared.path, checkedHead, workspace.branch);
|
|
184
210
|
if (checkResults.some((check) => check.outcome === "failed")) {
|
|
185
211
|
current = updateIntegrationAttempt(current, {
|
|
186
212
|
status: "failed",
|
|
@@ -201,18 +227,14 @@ export class GitIntegrationService {
|
|
|
201
227
|
return this.#terminalResult("committed", committed, workspace);
|
|
202
228
|
}
|
|
203
229
|
catch (error) {
|
|
204
|
-
if
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
summary: error.message
|
|
208
|
-
}, this.now());
|
|
209
|
-
this.store.saveIntegrationAttempt(task.id, pending);
|
|
210
|
-
return { status: "blocked", attempt: pending, workspace };
|
|
211
|
-
}
|
|
230
|
+
// Git/Job effects may already exist even if the following save failed.
|
|
231
|
+
// Keep the last durable cursor/candidate for exact Agent-directed retry.
|
|
232
|
+
current = requireIntegration(this.store, taskId, integrationId);
|
|
212
233
|
if (current.status === "validating" && current.candidateCommit !== undefined) {
|
|
213
234
|
const target = await resolveRef(taskRepository, current.targetRef);
|
|
214
235
|
if (target === current.candidateCommit) {
|
|
215
236
|
try {
|
|
237
|
+
await assertIntegrationCandidate(workspace.path, current.candidateCommit, workspace.branch);
|
|
216
238
|
await assertTargetReadyForChecks(taskRepository, current.targetRef, current.candidateCommit);
|
|
217
239
|
const committed = this.#recordCommitted(current);
|
|
218
240
|
return this.#terminalResult("committed", committed, workspace);
|
|
@@ -224,8 +246,93 @@ export class GitIntegrationService {
|
|
|
224
246
|
}
|
|
225
247
|
}
|
|
226
248
|
}
|
|
227
|
-
|
|
249
|
+
const diagnosed = updateIntegrationAttempt(current, {
|
|
250
|
+
summary: error instanceof Error ? error.message : String(error)
|
|
251
|
+
}, this.now());
|
|
252
|
+
this.store.saveIntegrationAttempt(taskId, diagnosed);
|
|
253
|
+
return { status: diagnosed.status === "conflicted" ? "conflicted" : "blocked", attempt: diagnosed, workspace };
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
finally {
|
|
257
|
+
release();
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
/** Abandon under the same fence as Git/CAS, not just a status whitelist.
|
|
261
|
+
* A delivered validating candidate is recorded as committed; a provably
|
|
262
|
+
* unadvanced one may be abandoned without requiring obsolete check settings. */
|
|
263
|
+
async abort(taskId, integrationId, reason, authorize) {
|
|
264
|
+
const initial = requireIntegration(this.store, taskId, integrationId);
|
|
265
|
+
const release = acquireProjectMaintenanceLocks(this.home, [initial.projectId]);
|
|
266
|
+
try {
|
|
267
|
+
const attempt = requireIntegration(this.store, taskId, integrationId);
|
|
268
|
+
authorize(this.store, taskId);
|
|
269
|
+
const task = this.store.getTask(taskId);
|
|
270
|
+
if (task?.status !== "active" || attempt.projectId !== initial.projectId
|
|
271
|
+
|| !task.projectBindings.some(binding => binding.projectId === attempt.projectId)) {
|
|
272
|
+
throw new Error("Integration Task/Project is not available for abort.");
|
|
273
|
+
}
|
|
274
|
+
if (!["running", "blocked", "conflicted", "validating"].includes(attempt.status)) {
|
|
275
|
+
throw new Error(`Integration cannot be aborted from ${attempt.status}: ${attempt.id}.`);
|
|
276
|
+
}
|
|
277
|
+
if (reason.trim() === "")
|
|
278
|
+
throw new Error("Integration abort reason is required.");
|
|
279
|
+
const jobs = this.store.listDurableJobs(taskId).filter(job => job.owner.kind === "integration-attempt" && job.owner.integrationAttemptId === integrationId);
|
|
280
|
+
const workspace = this.store.getIntegrationWorkspace(taskId, integrationId);
|
|
281
|
+
if (jobs.some(job => job.taskId !== taskId || job.projectId !== attempt.projectId
|
|
282
|
+
|| job.workspace !== workspace?.root)) {
|
|
283
|
+
throw new Error("Integration Job Project/workspace identity changed; inspect before abort.");
|
|
284
|
+
}
|
|
285
|
+
if (attempt.jobId !== undefined && !jobs.some(job => job.id === attempt.jobId)) {
|
|
286
|
+
throw new Error("Integration Job binding does not match its owner; inspect before abort.");
|
|
287
|
+
}
|
|
288
|
+
if (attempt.status === "validating") {
|
|
289
|
+
if (attempt.candidateCommit === undefined || attempt.checks === undefined) {
|
|
290
|
+
throw new Error("Validating Integration has no candidate/check evidence; inspect before settlement.");
|
|
291
|
+
}
|
|
292
|
+
const main = this.store.getTaskWorkspace(taskId);
|
|
293
|
+
const mainEntry = main?.entries.find(entry => entry.projectId === attempt.projectId && entry.access === "write");
|
|
294
|
+
const workspaceEntry = workspace?.entries.find(entry => entry.projectId === attempt.projectId && entry.access === "write");
|
|
295
|
+
const project = this.store.getProject(attempt.projectId);
|
|
296
|
+
if (main?.owner.type !== "task" || mainEntry === undefined || workspace === null
|
|
297
|
+
|| project === null || project.status !== "active"
|
|
298
|
+
|| workspace.owner.type !== "integration-attempt" || workspace.owner.taskId !== taskId
|
|
299
|
+
|| workspace.owner.integrationAttemptId !== integrationId
|
|
300
|
+
|| workspaceEntry?.path !== workspace.root) {
|
|
301
|
+
throw new Error("Integration target/workspace ownership is unavailable for settlement.");
|
|
302
|
+
}
|
|
303
|
+
if (jobs.some(job => ["queued", "running", "unknown-needs-attention"].includes(job.status))) {
|
|
304
|
+
throw new Error("Validating Integration still has an active or unproven Job; settle that exact Job first.");
|
|
305
|
+
}
|
|
306
|
+
if (attempt.jobId !== undefined) {
|
|
307
|
+
assertCheckJobIdentity(attempt, jobs.find(job => job.id === attempt.jobId), workspace.root);
|
|
308
|
+
}
|
|
309
|
+
const target = await resolveRef(mainEntry.path, attempt.targetRef);
|
|
310
|
+
if (target === attempt.candidateCommit && target !== attempt.beforeCommit) {
|
|
311
|
+
await assertTargetReadyForChecks(mainEntry.path, attempt.targetRef, target);
|
|
312
|
+
return this.#recordCommitted(attempt, authorize);
|
|
313
|
+
}
|
|
314
|
+
if (target !== attempt.beforeCommit) {
|
|
315
|
+
throw new Error(`Target moved to ${target}; cannot prove this Integration is unadvanced. Inspect before settlement.`);
|
|
316
|
+
}
|
|
228
317
|
}
|
|
318
|
+
for (const job of jobs) {
|
|
319
|
+
if (this.jobPort !== undefined && (job.status === "queued" || job.status === "running")) {
|
|
320
|
+
await this.jobPort.cancelJob(taskId, job.id);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
return this.store.transaction(tx => {
|
|
324
|
+
authorize(tx, taskId);
|
|
325
|
+
const current = requireIntegration(tx, taskId, integrationId);
|
|
326
|
+
if (JSON.stringify(current) !== JSON.stringify(attempt)) {
|
|
327
|
+
throw new Error("Integration changed during abort; inspect its current record.");
|
|
328
|
+
}
|
|
329
|
+
const aborted = updateIntegrationAttempt(current, {
|
|
330
|
+
status: "failed",
|
|
331
|
+
checks: [...(current.checks ?? []), { name: "aborted", outcome: "failed", details: reason }]
|
|
332
|
+
}, this.now());
|
|
333
|
+
tx.saveIntegrationAttempt(taskId, aborted);
|
|
334
|
+
return aborted;
|
|
335
|
+
});
|
|
229
336
|
}
|
|
230
337
|
finally {
|
|
231
338
|
release();
|
|
@@ -253,9 +360,18 @@ export class GitIntegrationService {
|
|
|
253
360
|
const runtime = this.#runtimePreparation(integration, managedWorkspace);
|
|
254
361
|
this.runtimeIsolation.cleanup(runtime, integration.status === "committed" ? "completion" : "failure");
|
|
255
362
|
}
|
|
363
|
+
const recorded = managedWorkspace?.entries[0];
|
|
364
|
+
const directory = recorded?.directory
|
|
365
|
+
?? task.projectBindings.find(({ projectId }) => projectId === integration.projectId)?.directory;
|
|
366
|
+
if (directory === undefined) {
|
|
367
|
+
throw new Error(`Integration Project binding is unavailable: ${task.id}/${project.id}.`);
|
|
368
|
+
}
|
|
256
369
|
const result = await this.git.removeIntegrationWorktree({
|
|
257
370
|
repositoryPath: taskRepository,
|
|
258
|
-
container:
|
|
371
|
+
container: recorded !== undefined
|
|
372
|
+
? dirname(recorded.path)
|
|
373
|
+
: integrationWorkspaceRoot(this.home, task.id, integration.id),
|
|
374
|
+
directory,
|
|
259
375
|
taskSegment: taskWorkspaceRefSegment(task),
|
|
260
376
|
integrationId: integration.id,
|
|
261
377
|
discardChanges: integration.status === "failed"
|
|
@@ -287,26 +403,15 @@ export class GitIntegrationService {
|
|
|
287
403
|
* it, so a Leader exit mid-checks leaves no running/no-check zombie.
|
|
288
404
|
*/
|
|
289
405
|
async #startCheckJob(attempt, workspace, path, managedWorkspace, repositoryPath, gate) {
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
...planBootstrapJobSteps(gate.plan).map((step) => ({
|
|
300
|
-
...step,
|
|
301
|
-
timeoutMs: 30 * 60_000
|
|
302
|
-
})),
|
|
303
|
-
...planL2JobSteps(gate.plan).map((step) => ({
|
|
304
|
-
...step,
|
|
305
|
-
timeoutMs: 30 * 60_000
|
|
306
|
-
}))
|
|
307
|
-
];
|
|
308
|
-
const releaseId = integrationRuntimeReleaseIdentity(this.home);
|
|
309
|
-
if (gate === undefined && releaseId !== null) {
|
|
406
|
+
if (attempt.status !== "running" || attempt.candidateCommit === undefined) {
|
|
407
|
+
throw new Error(`Integration check admission requires a running, proven candidate: ${attempt.id}.`);
|
|
408
|
+
}
|
|
409
|
+
await assertIntegrationCandidate(path, attempt.candidateCommit, workspace.branch);
|
|
410
|
+
await assertTargetReadyForChecks(repositoryPath, attempt.targetRef, attempt.beforeCommit);
|
|
411
|
+
const { runtime, head, steps, environment, inputDigest, releaseId } = await this.#checkSpecification(attempt, managedWorkspace, gate);
|
|
412
|
+
const ownedJobs = this.store.listDurableJobs(attempt.taskId).filter(job => job.owner.kind === "integration-attempt" && job.owner.integrationAttemptId === attempt.id);
|
|
413
|
+
if (gate === undefined && releaseId !== null && attempt.checkInputDigest === undefined
|
|
414
|
+
&& ownedJobs.length === 0 && attempt.jobId === undefined) {
|
|
310
415
|
const reusable = findReusableIntegrationCheckEvidence({
|
|
311
416
|
taskId: attempt.taskId,
|
|
312
417
|
projectId: attempt.projectId,
|
|
@@ -320,25 +425,28 @@ export class GitIntegrationService {
|
|
|
320
425
|
logPathFor: (job, relativeLogPath) => (join(job.artifactsLocator, "logs", relativeLogPath))
|
|
321
426
|
});
|
|
322
427
|
if (reusable !== null) {
|
|
323
|
-
return this.#finalizeGateSuccess(attempt, workspace, repositoryPath, head, [...reusable.checks]);
|
|
428
|
+
return this.#finalizeGateSuccess(updateIntegrationAttempt(attempt, { checkInputDigest: inputDigest }, this.now()), workspace, repositoryPath, head, [...reusable.checks]);
|
|
324
429
|
}
|
|
325
430
|
}
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
}
|
|
431
|
+
if (attempt.checkInputDigest !== undefined && attempt.checkInputDigest !== inputDigest) {
|
|
432
|
+
throw new Error("Integration check conditions changed since admission; inspect the original Job, then abort or start a new attempt.");
|
|
433
|
+
}
|
|
434
|
+
if (attempt.jobId !== undefined) {
|
|
435
|
+
return this.#resumeCheckJob(attempt, workspace, path, repositoryPath, gate);
|
|
436
|
+
}
|
|
332
437
|
// Persist the gate identity before starting the job so a plan edit
|
|
333
438
|
// during the gate never misattributes the evidence on resume.
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
439
|
+
const persisted = updateIntegrationAttempt(attempt, {
|
|
440
|
+
checkInputDigest: inputDigest,
|
|
441
|
+
...(gate === undefined ? {} : {
|
|
337
442
|
gatePlanDigest: gate.planDigest,
|
|
338
443
|
gateToolchainDigest: gate.toolchainDigest
|
|
339
|
-
}
|
|
340
|
-
|
|
341
|
-
|
|
444
|
+
})
|
|
445
|
+
}, this.now());
|
|
446
|
+
this.store.saveIntegrationAttempt(attempt.taskId, persisted);
|
|
447
|
+
// All domain admission and durable candidate identity precede Job effects.
|
|
448
|
+
this.runtimeIsolation.activate(runtime);
|
|
449
|
+
await prepareIntegrationRuntimeHome(runtime, this.home);
|
|
342
450
|
const job = await this.jobPort.startCheckJob({
|
|
343
451
|
taskId: attempt.taskId,
|
|
344
452
|
integrationId: attempt.id,
|
|
@@ -348,6 +456,7 @@ export class GitIntegrationService {
|
|
|
348
456
|
env: environment,
|
|
349
457
|
steps
|
|
350
458
|
});
|
|
459
|
+
assertCheckJobIdentity(persisted, job, path);
|
|
351
460
|
const bound = recordIntegrationCheckJob(persisted, job.id, this.now());
|
|
352
461
|
this.store.saveIntegrationAttempt(attempt.taskId, bound);
|
|
353
462
|
if (job.status !== "queued" && job.status !== "running") {
|
|
@@ -367,12 +476,17 @@ export class GitIntegrationService {
|
|
|
367
476
|
*/
|
|
368
477
|
async #resumeCheckJob(attempt, workspace, path, repositoryPath, gate) {
|
|
369
478
|
const job = await this.jobPort.getJob(attempt.taskId, attempt.jobId);
|
|
479
|
+
assertCheckJobIdentity(attempt, job, path);
|
|
480
|
+
await assertIntegrationCandidate(path, job.head, workspace.branch);
|
|
481
|
+
await assertTargetReadyForChecks(repositoryPath, attempt.targetRef, attempt.beforeCommit);
|
|
482
|
+
if (gate?.planDigest !== attempt.gatePlanDigest
|
|
483
|
+
|| gate?.toolchainDigest !== attempt.gateToolchainDigest) {
|
|
484
|
+
throw new Error("Integration verification plan/toolchain changed; the original Job cannot prove the current gate.");
|
|
485
|
+
}
|
|
370
486
|
if (job.status === "queued" || job.status === "running") {
|
|
371
487
|
return { status: "checks-running", attempt, workspace, job };
|
|
372
488
|
}
|
|
373
|
-
const
|
|
374
|
-
|| (job.steps?.some((step) => step.name.startsWith("bootstrap-") || step.name.startsWith("gate-")) ?? false);
|
|
375
|
-
const checks = planStyle
|
|
489
|
+
const checks = gate !== undefined
|
|
376
490
|
? checkResultsFromGateJob(job, this.home)
|
|
377
491
|
: checkResultsFromJob(attempt, job);
|
|
378
492
|
const managedWorkspace = this.store.getIntegrationWorkspace(attempt.taskId, attempt.id);
|
|
@@ -380,25 +494,13 @@ export class GitIntegrationService {
|
|
|
380
494
|
const runtime = this.#runtimePreparation(attempt, managedWorkspace);
|
|
381
495
|
this.runtimeIsolation.cleanup(runtime, checks.some((check) => check.outcome === "failed") ? "failure" : "completion");
|
|
382
496
|
}
|
|
383
|
-
//
|
|
384
|
-
// identity
|
|
385
|
-
// checked head; a plan/toolchain change since the job started yields a
|
|
386
|
-
// different key, so the artifact is never misattributed (the attempt
|
|
387
|
-
// still converges from the job's own evidence).
|
|
497
|
+
// The current plan/toolchain was matched to admission above. Record only
|
|
498
|
+
// that exact identity, never reinterpret an old Job under a changed plan.
|
|
388
499
|
if (gate !== undefined
|
|
389
500
|
&& (job.result?.outcome === "succeeded" || job.result?.outcome === "failed")) {
|
|
390
|
-
// Use the digests captured at job start so a plan edit during the
|
|
391
|
-
// gate never misattributes the evidence.
|
|
392
|
-
const recordGate = attempt.gatePlanDigest !== undefined
|
|
393
|
-
? Object.freeze({
|
|
394
|
-
...gate,
|
|
395
|
-
planDigest: attempt.gatePlanDigest,
|
|
396
|
-
toolchainDigest: attempt.gateToolchainDigest ?? gate.toolchainDigest
|
|
397
|
-
})
|
|
398
|
-
: gate;
|
|
399
501
|
const identity = gateIdentityForCandidate({
|
|
400
502
|
projectId: attempt.projectId,
|
|
401
|
-
gate
|
|
503
|
+
gate,
|
|
402
504
|
level: "L2",
|
|
403
505
|
commit: job.head,
|
|
404
506
|
targetRef: attempt.targetRef,
|
|
@@ -424,30 +526,16 @@ export class GitIntegrationService {
|
|
|
424
526
|
this.store.saveIntegrationAttempt(attempt.taskId, failed);
|
|
425
527
|
return this.#terminalResult("failed", failed, workspace);
|
|
426
528
|
}
|
|
427
|
-
|
|
428
|
-
if (candidateCommit !== job.head) {
|
|
429
|
-
// The job proved the checks at one SHA; the workspace has since moved.
|
|
430
|
-
// Advancing the target ref would publish unchecked code, so fail closed.
|
|
431
|
-
return this.#fail(attempt, new Error(`Integration workspace moved since the check ran: ${candidateCommit} != checked ${job.head}.`), "integration", workspace);
|
|
432
|
-
}
|
|
433
|
-
const validating = updateIntegrationAttempt(attempt, {
|
|
434
|
-
status: "validating",
|
|
435
|
-
candidateCommit,
|
|
436
|
-
checks
|
|
437
|
-
}, this.now());
|
|
438
|
-
this.store.saveIntegrationAttempt(attempt.taskId, validating);
|
|
439
|
-
await advanceTargetRef(repositoryPath, validating.targetRef, candidateCommit, validating.beforeCommit);
|
|
440
|
-
const committed = this.#recordCommitted(validating);
|
|
441
|
-
return this.#terminalResult("committed", committed, workspace);
|
|
529
|
+
return this.#finalizeGateSuccess(attempt, workspace, repositoryPath, job.head, checks);
|
|
442
530
|
}
|
|
443
|
-
async #runChecks(attempt, workspace, path) {
|
|
531
|
+
async #runChecks(attempt, workspace, path, environment) {
|
|
444
532
|
if (attempt.checkCommands.length === 0)
|
|
445
533
|
return [];
|
|
446
534
|
const runtime = this.#runtimePreparation(attempt, workspace);
|
|
447
535
|
this.runtimeIsolation.activate(runtime);
|
|
448
536
|
let cleanupReason = "failure";
|
|
449
537
|
try {
|
|
450
|
-
|
|
538
|
+
await prepareIntegrationRuntimeHome(runtime, this.home);
|
|
451
539
|
const checks = await runChecks(path, attempt.checkCommands, this.home, attempt.taskId, attempt.id, environment);
|
|
452
540
|
cleanupReason = checks.some(({ outcome }) => outcome === "failed")
|
|
453
541
|
? "failure"
|
|
@@ -472,6 +560,12 @@ export class GitIntegrationService {
|
|
|
472
560
|
* gate.
|
|
473
561
|
*/
|
|
474
562
|
async #runVerificationGate(attempt, workspace, path, managedWorkspace, repositoryPath, gate) {
|
|
563
|
+
const spec = await this.#checkSpecification(attempt, managedWorkspace, gate);
|
|
564
|
+
attempt = updateIntegrationAttempt(attempt, {
|
|
565
|
+
checkInputDigest: spec.inputDigest,
|
|
566
|
+
gatePlanDigest: gate.planDigest, gateToolchainDigest: gate.toolchainDigest
|
|
567
|
+
}, this.now());
|
|
568
|
+
this.store.saveIntegrationAttempt(attempt.taskId, attempt);
|
|
475
569
|
if (gate.mode === "enforce") {
|
|
476
570
|
try {
|
|
477
571
|
assertNoAdHocFullSuiteChecks(gate.plan, attempt.checkCommands);
|
|
@@ -513,12 +607,12 @@ export class GitIntegrationService {
|
|
|
513
607
|
this.runtimeIsolation.activate(runtime);
|
|
514
608
|
let cleanupReason = "failure";
|
|
515
609
|
try {
|
|
516
|
-
|
|
610
|
+
await prepareIntegrationRuntimeHome(runtime, this.home);
|
|
517
611
|
const steps = [
|
|
518
612
|
...planBootstrapJobSteps(gate.plan),
|
|
519
613
|
...planL2JobSteps(gate.plan)
|
|
520
614
|
];
|
|
521
|
-
const outcomes = await runGateStepsInProcess(path, steps, environment, integrationCheckDirectory(this.home, attempt.taskId, attempt.id), candidateCommit);
|
|
615
|
+
const outcomes = await runGateStepsInProcess(path, steps, spec.environment, integrationCheckDirectory(this.home, attempt.taskId, attempt.id), candidateCommit);
|
|
522
616
|
const succeeded = outcomes.length === steps.length
|
|
523
617
|
&& outcomes.every((outcome) => outcome.exitCode === 0 && outcome.signal === null && !outcome.timedOut);
|
|
524
618
|
const artifact = await recordGateArtifactFromStepOutcomes(this.store, identity, gate.plan, outcomes, succeeded, this.now());
|
|
@@ -536,6 +630,7 @@ export class GitIntegrationService {
|
|
|
536
630
|
}
|
|
537
631
|
}
|
|
538
632
|
async #finalizeGateSuccess(attempt, workspace, repositoryPath, candidateCommit, checks) {
|
|
633
|
+
await assertIntegrationCandidate(workspace.path, candidateCommit, workspace.branch);
|
|
539
634
|
const validating = updateIntegrationAttempt(attempt, {
|
|
540
635
|
status: "validating",
|
|
541
636
|
candidateCommit,
|
|
@@ -552,175 +647,75 @@ export class GitIntegrationService {
|
|
|
552
647
|
allowExactActive: true
|
|
553
648
|
});
|
|
554
649
|
}
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
if (await this.git.isAncestor(candidatePath, attempt.source.remoteCommit, attempt.beforeCommit)) {
|
|
581
|
-
return undefined;
|
|
582
|
-
}
|
|
583
|
-
try {
|
|
584
|
-
await git([
|
|
585
|
-
"-C", candidatePath,
|
|
586
|
-
"-c", "user.name=Yui",
|
|
587
|
-
"-c", "user.email=yui@local",
|
|
588
|
-
"rebase", "--onto",
|
|
589
|
-
attempt.source.remoteCommit,
|
|
590
|
-
attempt.source.taskBaseCommit,
|
|
591
|
-
workspace.branch
|
|
592
|
-
]);
|
|
593
|
-
}
|
|
594
|
-
catch (error) {
|
|
595
|
-
const affectedPaths = await conflictedPaths(candidatePath);
|
|
596
|
-
if (affectedPaths.length === 0)
|
|
597
|
-
throw error;
|
|
598
|
-
const pending = requireResolutionDecision(attempt, {
|
|
599
|
-
affectedPaths,
|
|
600
|
-
summary: `${REMOTE_BASELINE_CONFLICT_PREFIX} in ${attempt.targetRef}.`
|
|
601
|
-
}, this.now());
|
|
602
|
-
this.store.saveIntegrationAttempt(attempt.taskId, pending);
|
|
603
|
-
return { status: "blocked", attempt: pending, workspace };
|
|
604
|
-
}
|
|
605
|
-
return undefined;
|
|
606
|
-
}
|
|
607
|
-
if (attempt.source.strategy === "manual") {
|
|
608
|
-
if (attempt.resolution?.action === "manual-resolution"
|
|
609
|
-
&& attempt.conflict?.summary.startsWith(MANUAL_INTEGRATION_PREFIX)) {
|
|
610
|
-
if (!await this.git.isClean(candidatePath)) {
|
|
611
|
-
throw new Error("Manual Integration workspace must be clean and committed.");
|
|
612
|
-
}
|
|
613
|
-
return undefined;
|
|
614
|
-
}
|
|
615
|
-
const pending = requireResolutionDecision(attempt, {
|
|
616
|
-
affectedPaths: [],
|
|
617
|
-
summary: `${MANUAL_INTEGRATION_PREFIX}; apply the selected WorkItem result in the Integration workspace.`
|
|
618
|
-
}, this.now());
|
|
619
|
-
this.store.saveIntegrationAttempt(attempt.taskId, pending);
|
|
620
|
-
return { status: "blocked", attempt: pending, workspace };
|
|
621
|
-
}
|
|
622
|
-
if (attempt.source.strategy === "ff") {
|
|
623
|
-
await git([
|
|
624
|
-
"-C", candidatePath,
|
|
625
|
-
"merge", "--ff-only",
|
|
626
|
-
attempt.source.resultCommit
|
|
627
|
-
]);
|
|
628
|
-
return undefined;
|
|
629
|
-
}
|
|
630
|
-
if (attempt.source.strategy === "merge") {
|
|
631
|
-
if (attempt.resolution?.action === "manual-resolution"
|
|
632
|
-
&& attempt.conflict?.summary.startsWith(WORK_ITEM_MERGE_CONFLICT_PREFIX)) {
|
|
633
|
-
await completeMergeResolution(candidatePath);
|
|
634
|
-
return undefined;
|
|
635
|
-
}
|
|
636
|
-
try {
|
|
637
|
-
await git([
|
|
638
|
-
"-C", candidatePath,
|
|
639
|
-
"-c", "user.name=Yui",
|
|
640
|
-
"-c", "user.email=yui@local",
|
|
641
|
-
"merge", "--no-edit", "--no-ff",
|
|
642
|
-
attempt.source.resultCommit
|
|
643
|
-
]);
|
|
644
|
-
}
|
|
645
|
-
catch (error) {
|
|
646
|
-
const affectedPaths = await conflictedPaths(candidatePath);
|
|
647
|
-
if (affectedPaths.length === 0)
|
|
648
|
-
throw error;
|
|
649
|
-
const pending = requireResolutionDecision(attempt, {
|
|
650
|
-
affectedPaths,
|
|
651
|
-
summary: `${WORK_ITEM_MERGE_CONFLICT_PREFIX} in ${attempt.targetRef}.`
|
|
652
|
-
}, this.now());
|
|
653
|
-
this.store.saveIntegrationAttempt(attempt.taskId, pending);
|
|
654
|
-
return { status: "blocked", attempt: pending, workspace };
|
|
655
|
-
}
|
|
656
|
-
return undefined;
|
|
657
|
-
}
|
|
658
|
-
const commits = await commitsBetween(candidatePath, attempt.source.startCommit, attempt.source.resultCommit, attempt.source.workItemId);
|
|
659
|
-
let remaining = commits;
|
|
660
|
-
if (attempt.resolution?.action === "manual-resolution") {
|
|
661
|
-
const resolvedCommit = await completeCherryPickResolution(candidatePath);
|
|
662
|
-
const resolvedIndex = commits.findIndex(({ commit }) => commit === resolvedCommit);
|
|
663
|
-
if (resolvedIndex < 0) {
|
|
664
|
-
throw new Error(`Manual resolution commit is not part of the WorkItem result: ${resolvedCommit}.`);
|
|
665
|
-
}
|
|
666
|
-
remaining = commits.slice(resolvedIndex + 1);
|
|
667
|
-
}
|
|
668
|
-
for (const { label, commit } of remaining) {
|
|
669
|
-
// Fast-forward when the commit is a direct descendant of HEAD: this
|
|
670
|
-
// preserves the original commit SHA, which exact-SHA review evidence
|
|
671
|
-
// relies on. Fall back to cherry-pick when the target moved since the
|
|
672
|
-
// WorkItem was based (the commit is no longer a direct descendant).
|
|
673
|
-
if (await gitSucceeds(["-C", candidatePath, "merge", "--ff-only", commit])) {
|
|
674
|
-
continue;
|
|
675
|
-
}
|
|
676
|
-
try {
|
|
677
|
-
await git(["-C", candidatePath, "cherry-pick", commit]);
|
|
678
|
-
}
|
|
679
|
-
catch {
|
|
680
|
-
const affectedPaths = (await git([
|
|
681
|
-
"-C", candidatePath, "diff", "--name-only", "--diff-filter=U"
|
|
682
|
-
])).trim().split("\n").filter(Boolean);
|
|
683
|
-
if (affectedPaths.length === 0
|
|
684
|
-
&& await isEmptyCherryPick(candidatePath, commit)) {
|
|
685
|
-
await git(["-C", candidatePath, "cherry-pick", "--skip"]);
|
|
686
|
-
continue;
|
|
687
|
-
}
|
|
688
|
-
const pending = requireResolutionDecision(attempt, {
|
|
689
|
-
affectedPaths,
|
|
690
|
-
summary: `${label} conflicts with ${attempt.targetRef}.`
|
|
691
|
-
}, this.now());
|
|
692
|
-
this.store.saveIntegrationAttempt(attempt.taskId, pending);
|
|
693
|
-
return { status: "blocked", attempt: pending, workspace };
|
|
694
|
-
}
|
|
695
|
-
}
|
|
696
|
-
return undefined;
|
|
650
|
+
/** The same specification is compared before first execution, bound-Job
|
|
651
|
+
* consumption and an unadvanced validating CAS. Computing it is read-only:
|
|
652
|
+
* a changed condition must not recreate a completed runtime or start a Job. */
|
|
653
|
+
async #checkSpecification(attempt, managedWorkspace, gate) {
|
|
654
|
+
const runtime = this.#runtimePreparation(attempt, managedWorkspace);
|
|
655
|
+
const head = attempt.candidateCommit;
|
|
656
|
+
if (head === undefined)
|
|
657
|
+
throw new Error("Integration check specification requires a candidate.");
|
|
658
|
+
const steps = gate === undefined
|
|
659
|
+
? attempt.checkCommands.map((command, index) => ({
|
|
660
|
+
name: `check-${index + 1}`, command, timeoutMs: 30 * 60_000
|
|
661
|
+
}))
|
|
662
|
+
: [...planBootstrapJobSteps(gate.plan), ...planL2JobSteps(gate.plan)]
|
|
663
|
+
.map(step => ({ ...step, timeoutMs: 30 * 60_000 }));
|
|
664
|
+
const releaseId = integrationRuntimeReleaseIdentity(this.home);
|
|
665
|
+
const environment = Object.freeze({
|
|
666
|
+
...integrationCheckEnvironment(this.environment, runtime, this.home),
|
|
667
|
+
...(releaseId === null ? {} : { [INTEGRATION_RUNTIME_RELEASE_ENV]: releaseId })
|
|
668
|
+
});
|
|
669
|
+
const inputDigest = durableJobIdempotencyKey({
|
|
670
|
+
owner: { kind: "integration-attempt", integrationAttemptId: attempt.id },
|
|
671
|
+
projectId: attempt.projectId, head, workspace: managedWorkspace.root,
|
|
672
|
+
env: environment, steps
|
|
673
|
+
});
|
|
674
|
+
return { runtime, head, steps, environment, inputDigest, releaseId };
|
|
697
675
|
}
|
|
698
|
-
async #recoverValidating(attempt, workspace, repositoryPath) {
|
|
676
|
+
async #recoverValidating(attempt, workspace, managedWorkspace, repositoryPath) {
|
|
699
677
|
if (attempt.candidateCommit === undefined || attempt.checks === undefined) {
|
|
700
678
|
return this.#fail(attempt, new Error("Validating Integration is missing its candidate commit or checks."), "integration-recovery", workspace);
|
|
701
679
|
}
|
|
702
680
|
const target = await resolveRef(repositoryPath, attempt.targetRef);
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
681
|
+
// An already-applied CAS is a delivery fact, not a request to validate
|
|
682
|
+
// again under today's settings. No-op candidates still need current checks.
|
|
683
|
+
if (target !== attempt.candidateCommit || target === attempt.beforeCommit) {
|
|
684
|
+
if (target !== attempt.beforeCommit) {
|
|
685
|
+
throw new Error(`Target moved to ${target}; Integration CAS outcome needs inspection.`);
|
|
706
686
|
}
|
|
707
|
-
|
|
708
|
-
|
|
687
|
+
await assertIntegrationCandidate(workspace.path, attempt.candidateCommit, workspace.branch);
|
|
688
|
+
assertRecordedSourceCandidate(attempt, workspace);
|
|
689
|
+
const project = this.store.getProject(attempt.projectId);
|
|
690
|
+
if (project === null)
|
|
691
|
+
throw new Error("Integration Project is unavailable for check validation.");
|
|
692
|
+
const gate = resolveVerificationGate(project, this.environment);
|
|
693
|
+
const spec = await this.#checkSpecification(attempt, managedWorkspace, gate);
|
|
694
|
+
if (attempt.checkInputDigest !== spec.inputDigest
|
|
695
|
+
|| gate?.planDigest !== attempt.gatePlanDigest
|
|
696
|
+
|| gate?.toolchainDigest !== attempt.gateToolchainDigest) {
|
|
697
|
+
throw new Error("Integration check conditions changed after validation; preserve the evidence and abort or use a new attempt.");
|
|
709
698
|
}
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
699
|
+
if (attempt.jobId !== undefined) {
|
|
700
|
+
const job = this.store.getDurableJob(attempt.taskId, attempt.jobId);
|
|
701
|
+
if (job === null || job.status !== "succeeded") {
|
|
702
|
+
throw new Error("Validating Integration has no successful bound Job evidence.");
|
|
703
|
+
}
|
|
704
|
+
assertCheckJobIdentity(attempt, job, workspace.path);
|
|
705
|
+
}
|
|
706
|
+
await advanceTargetRef(repositoryPath, attempt.targetRef, attempt.candidateCommit, attempt.beforeCommit);
|
|
713
707
|
}
|
|
714
708
|
await assertTargetReadyForChecks(repositoryPath, attempt.targetRef, attempt.candidateCommit);
|
|
715
709
|
const committed = this.#recordCommitted(attempt);
|
|
716
710
|
return this.#terminalResult("committed", committed, workspace);
|
|
717
711
|
}
|
|
718
|
-
#recordCommitted(attempt) {
|
|
712
|
+
#recordCommitted(attempt, authorize) {
|
|
719
713
|
if (attempt.candidateCommit === undefined) {
|
|
720
714
|
throw new Error(`Committed Integration has no candidate commit: ${attempt.id}.`);
|
|
721
715
|
}
|
|
722
716
|
const candidateCommit = attempt.candidateCommit;
|
|
723
717
|
return this.store.transaction((tx) => {
|
|
718
|
+
authorize?.(tx, attempt.taskId);
|
|
724
719
|
const task = tx.getTask(attempt.taskId);
|
|
725
720
|
if (task === null)
|
|
726
721
|
throw new Error(`Task not found: ${attempt.taskId}.`);
|
|
@@ -769,6 +764,27 @@ export class GitIntegrationService {
|
|
|
769
764
|
};
|
|
770
765
|
}
|
|
771
766
|
}
|
|
767
|
+
function assertCheckJobIdentity(attempt, job, path) {
|
|
768
|
+
if (job.taskId !== attempt.taskId
|
|
769
|
+
|| job.owner.kind !== "integration-attempt" || job.owner.integrationAttemptId !== attempt.id
|
|
770
|
+
|| job.projectId !== attempt.projectId || job.workspace !== path
|
|
771
|
+
|| job.head !== attempt.candidateCommit
|
|
772
|
+
|| (attempt.jobId !== undefined && job.id !== attempt.jobId)
|
|
773
|
+
|| attempt.checkInputDigest === undefined
|
|
774
|
+
|| durableJobIdempotencyKey(job) !== attempt.checkInputDigest) {
|
|
775
|
+
throw new Error("Integration Job identity does not match its admitted Task, source candidate, Project, workspace and check specification.");
|
|
776
|
+
}
|
|
777
|
+
if (job.status === "succeeded") {
|
|
778
|
+
const results = job.result?.steps;
|
|
779
|
+
if (job.result?.outcome !== "succeeded" || results?.length !== job.steps.length
|
|
780
|
+
|| job.steps.some((step, index) => {
|
|
781
|
+
const result = results[index];
|
|
782
|
+
return result?.name !== step.name || result.head !== job.head
|
|
783
|
+
|| result.exitCode !== 0 || result.signal !== null || result.timedOut;
|
|
784
|
+
}))
|
|
785
|
+
throw new Error("Integration Job success lacks exact candidate step evidence.");
|
|
786
|
+
}
|
|
787
|
+
}
|
|
772
788
|
function integrationSummary(attempt) {
|
|
773
789
|
const unchanged = attempt.beforeCommit === attempt.candidateCommit;
|
|
774
790
|
if (attempt.source.kind === "upstream") {
|
|
@@ -796,111 +812,6 @@ function integrationSummary(attempt) {
|
|
|
796
812
|
? `WorkItem ${attempt.source.workItemId} result was already represented; Task head was unchanged.`
|
|
797
813
|
: `Integrated WorkItem ${attempt.source.workItemId} with ${attempt.source.strategy}.`;
|
|
798
814
|
}
|
|
799
|
-
async function isEmptyCherryPick(path, commit) {
|
|
800
|
-
let cherryPickHead;
|
|
801
|
-
try {
|
|
802
|
-
cherryPickHead = await gitLine([
|
|
803
|
-
"-C", path, "rev-parse", "--verify", "CHERRY_PICK_HEAD^{commit}"
|
|
804
|
-
]);
|
|
805
|
-
}
|
|
806
|
-
catch {
|
|
807
|
-
return false;
|
|
808
|
-
}
|
|
809
|
-
return cherryPickHead === commit
|
|
810
|
-
&& await gitSucceeds(["-C", path, "diff", "--cached", "--quiet"]);
|
|
811
|
-
}
|
|
812
|
-
async function commitsBetween(repositoryPath, startCommit, resultCommit, workItemId) {
|
|
813
|
-
const commits = (await git([
|
|
814
|
-
"-C", repositoryPath, "rev-list", "--reverse",
|
|
815
|
-
`${startCommit}..${resultCommit}`
|
|
816
|
-
])).trim().split("\n").filter(Boolean);
|
|
817
|
-
const plan = [];
|
|
818
|
-
for (const commit of commits) {
|
|
819
|
-
if (await gitSucceeds([
|
|
820
|
-
"-C", repositoryPath,
|
|
821
|
-
"merge-base", "--is-ancestor", commit, "HEAD"
|
|
822
|
-
]))
|
|
823
|
-
continue;
|
|
824
|
-
plan.push({ label: `WorkItem ${workItemId} commit ${commit}`, commit });
|
|
825
|
-
}
|
|
826
|
-
return plan;
|
|
827
|
-
}
|
|
828
|
-
async function completeCherryPickResolution(path) {
|
|
829
|
-
const unmerged = (await git(["-C", path, "diff", "--name-only", "--diff-filter=U"])).trim();
|
|
830
|
-
if (unmerged.length > 0) {
|
|
831
|
-
throw new Error(`Manual resolution is incomplete: ${unmerged.split("\n").join(", ")}.`);
|
|
832
|
-
}
|
|
833
|
-
let cherryPickHead;
|
|
834
|
-
try {
|
|
835
|
-
cherryPickHead = await gitLine([
|
|
836
|
-
"-C", path, "rev-parse", "--verify", "CHERRY_PICK_HEAD"
|
|
837
|
-
]);
|
|
838
|
-
}
|
|
839
|
-
catch {
|
|
840
|
-
throw new Error("Manual resolution has no active cherry-pick.");
|
|
841
|
-
}
|
|
842
|
-
const emptyResolution = await gitSucceeds(["-C", path, "diff", "--cached", "--quiet"]);
|
|
843
|
-
if (emptyResolution) {
|
|
844
|
-
await git(["-C", path, "cherry-pick", "--skip"]);
|
|
845
|
-
}
|
|
846
|
-
else {
|
|
847
|
-
await git(["-C", path, "-c", "user.name=Yui", "-c", "user.email=yui@local",
|
|
848
|
-
"cherry-pick", "--continue"]);
|
|
849
|
-
}
|
|
850
|
-
return cherryPickHead;
|
|
851
|
-
}
|
|
852
|
-
async function completeMergeResolution(path) {
|
|
853
|
-
const affected = await conflictedPaths(path);
|
|
854
|
-
if (affected.length > 0) {
|
|
855
|
-
throw new Error(`Manual merge resolution is incomplete: ${affected.join(", ")}.`);
|
|
856
|
-
}
|
|
857
|
-
try {
|
|
858
|
-
await gitLine(["-C", path, "rev-parse", "--verify", "MERGE_HEAD"]);
|
|
859
|
-
}
|
|
860
|
-
catch {
|
|
861
|
-
throw new Error("Manual WorkItem resolution has no active merge.");
|
|
862
|
-
}
|
|
863
|
-
await git([
|
|
864
|
-
"-C", path,
|
|
865
|
-
"-c", "user.name=Yui",
|
|
866
|
-
"-c", "user.email=yui@local",
|
|
867
|
-
"commit", "--no-edit"
|
|
868
|
-
]);
|
|
869
|
-
}
|
|
870
|
-
async function continueUpstreamRebase(path) {
|
|
871
|
-
const affected = await conflictedPaths(path);
|
|
872
|
-
if (affected.length > 0) {
|
|
873
|
-
throw new RemoteBaselineConflictError(affected, `${REMOTE_BASELINE_CONFLICT_PREFIX}: ${affected.join(", ")}.`);
|
|
874
|
-
}
|
|
875
|
-
try {
|
|
876
|
-
await gitLine(["-C", path, "rev-parse", "--verify", "REBASE_HEAD"]);
|
|
877
|
-
}
|
|
878
|
-
catch {
|
|
879
|
-
throw new Error("Manual upstream resolution has no active rebase.");
|
|
880
|
-
}
|
|
881
|
-
try {
|
|
882
|
-
await git([
|
|
883
|
-
"-C", path,
|
|
884
|
-
"-c", "user.name=Yui",
|
|
885
|
-
"-c", "user.email=yui@local",
|
|
886
|
-
"-c", "core.editor=true",
|
|
887
|
-
"rebase", "--continue"
|
|
888
|
-
]);
|
|
889
|
-
}
|
|
890
|
-
catch (error) {
|
|
891
|
-
const nextAffected = await conflictedPaths(path);
|
|
892
|
-
if (nextAffected.length > 0) {
|
|
893
|
-
throw new RemoteBaselineConflictError(nextAffected, `${REMOTE_BASELINE_CONFLICT_PREFIX}: ${nextAffected.join(", ")}.`, { cause: error });
|
|
894
|
-
}
|
|
895
|
-
throw error;
|
|
896
|
-
}
|
|
897
|
-
}
|
|
898
|
-
async function conflictedPaths(path) {
|
|
899
|
-
return (await git([
|
|
900
|
-
"-C", path,
|
|
901
|
-
"diff", "--name-only", "--diff-filter=U"
|
|
902
|
-
])).trim().split("\n").filter(Boolean);
|
|
903
|
-
}
|
|
904
815
|
/**
|
|
905
816
|
* Map a terminal check job back to the attempt's CheckResult[] shape. The
|
|
906
817
|
* runner stops at the first failing step, so unreached checks are "skipped"
|
|
@@ -1048,27 +959,41 @@ async function spawnCheck(command, cwd, outputFd, environment) {
|
|
|
1048
959
|
clearTimeout(timeout);
|
|
1049
960
|
return { ...completion, timedOut };
|
|
1050
961
|
}
|
|
1051
|
-
async function
|
|
1052
|
-
const
|
|
962
|
+
async function prepareIntegrationRuntimeHome(runtime, home) {
|
|
963
|
+
const homeDirectory = join(runtime.descriptor.roots.data, "home");
|
|
1053
964
|
try {
|
|
1054
|
-
await mkdir(
|
|
965
|
+
await mkdir(homeDirectory, { mode: 0o700 });
|
|
1055
966
|
}
|
|
1056
967
|
catch (error) {
|
|
1057
968
|
if (!isNodeCode(error, "EEXIST"))
|
|
1058
969
|
throw error;
|
|
1059
970
|
}
|
|
1060
|
-
const homeMetadata = await lstat(
|
|
971
|
+
const homeMetadata = await lstat(homeDirectory);
|
|
1061
972
|
if (!homeMetadata.isDirectory() || homeMetadata.isSymbolicLink()) {
|
|
1062
973
|
throw new Error("Integration runtime HOME is not an owned directory.");
|
|
1063
974
|
}
|
|
975
|
+
// The tmux socket endpoint is the one approved short-path IPC exception; every
|
|
976
|
+
// other temp consumer (TMPDIR/TMP/TEMP) stays on the Home-side runtime tmp.
|
|
977
|
+
const tmuxSocketRoot = integrationTmuxSocketRoot(home);
|
|
978
|
+
try {
|
|
979
|
+
await mkdir(tmuxSocketRoot, { mode: 0o700 });
|
|
980
|
+
}
|
|
981
|
+
catch (error) {
|
|
982
|
+
if (!isNodeCode(error, "EEXIST"))
|
|
983
|
+
throw error;
|
|
984
|
+
}
|
|
985
|
+
}
|
|
986
|
+
function integrationCheckEnvironment(source, runtime, home) {
|
|
987
|
+
const homeDirectory = join(runtime.descriptor.roots.data, "home");
|
|
988
|
+
const tmuxSocketRoot = integrationTmuxSocketRoot(home);
|
|
1064
989
|
return Object.freeze({
|
|
1065
990
|
...selectEnvironment(source, INTEGRATION_OPERATIONAL_ENVIRONMENT_NAMES),
|
|
1066
991
|
PATH: source.PATH || `${dirname(process.execPath)}:/usr/local/bin:/usr/bin:/bin`,
|
|
1067
|
-
HOME:
|
|
992
|
+
HOME: homeDirectory,
|
|
1068
993
|
TMPDIR: runtime.descriptor.roots.temporary,
|
|
1069
994
|
TMP: runtime.descriptor.roots.temporary,
|
|
1070
995
|
TEMP: runtime.descriptor.roots.temporary,
|
|
1071
|
-
TMUX_TMPDIR:
|
|
996
|
+
TMUX_TMPDIR: tmuxSocketRoot,
|
|
1072
997
|
...runtime.environment
|
|
1073
998
|
});
|
|
1074
999
|
}
|
|
@@ -1080,18 +1005,32 @@ function isNodeCode(error, code) {
|
|
|
1080
1005
|
}
|
|
1081
1006
|
function defaultIntegrationRuntimeIsolation(home, homeId) {
|
|
1082
1007
|
const controlHome = resolve(home);
|
|
1008
|
+
const runtimeRoot = managedIntegrationRuntimeRoot(controlHome);
|
|
1083
1009
|
return new FileTaskRuntimeIsolation({
|
|
1084
|
-
|
|
1010
|
+
// The integration check's provider data/cache/tmp live in a dedicated Home
|
|
1011
|
+
// partition — the same isolation contract every Task runtime obeys — not in
|
|
1012
|
+
// a system-wide `/tmp` root. Only the tmux socket ENDPOINT stays a short
|
|
1013
|
+
// `/tmp` path (see `integrationCheckEnvironment`) for the `sockaddr_un`
|
|
1014
|
+
// budget; ordinary runtime state is now Yui-managed under Home.
|
|
1015
|
+
runtimeRoot,
|
|
1085
1016
|
pathLayout: "compact",
|
|
1086
1017
|
controlPlane: {
|
|
1087
1018
|
yuiHome: controlHome,
|
|
1019
|
+
managedRuntimeRoot: runtimeRoot,
|
|
1088
1020
|
controllerSocketPath: controllerSocketPath(homeId),
|
|
1089
1021
|
tmuxNamespace: yuiTmuxServerName(controlHome),
|
|
1090
1022
|
globalInstallPaths: [process.execPath]
|
|
1091
1023
|
}
|
|
1092
1024
|
});
|
|
1093
1025
|
}
|
|
1094
|
-
|
|
1026
|
+
/**
|
|
1027
|
+
* Short `/tmp` directory that holds ONLY the integration check's tmux socket
|
|
1028
|
+
* endpoint. tmux uses a `sockaddr_un` path whose length budget cannot absorb a
|
|
1029
|
+
* deep Home path, so this single IPC endpoint is the one approved exception to
|
|
1030
|
+
* the unified-Home contract. All other integration runtime state (data, cache,
|
|
1031
|
+
* ordinary temp) lives in the Home partition from `managedIntegrationRuntimeRoot`.
|
|
1032
|
+
*/
|
|
1033
|
+
function integrationTmuxSocketRoot(home) {
|
|
1095
1034
|
const uid = typeof process.getuid === "function" ? process.getuid() : 0;
|
|
1096
1035
|
const homeDigest = createHash("sha256").update(resolve(home)).digest("hex").slice(0, 16);
|
|
1097
1036
|
return join("/tmp", `yi-${uid.toString(36)}-${homeDigest}`);
|
|
@@ -1175,15 +1114,6 @@ async function git(args) {
|
|
|
1175
1114
|
});
|
|
1176
1115
|
}
|
|
1177
1116
|
}
|
|
1178
|
-
async function gitSucceeds(args) {
|
|
1179
|
-
try {
|
|
1180
|
-
await git(args);
|
|
1181
|
-
return true;
|
|
1182
|
-
}
|
|
1183
|
-
catch {
|
|
1184
|
-
return false;
|
|
1185
|
-
}
|
|
1186
|
-
}
|
|
1187
1117
|
async function gitLine(args) {
|
|
1188
1118
|
const value = (await git(args)).trim();
|
|
1189
1119
|
if (!/^[a-f0-9]{40}(?:[a-f0-9]{24})?$/u.test(value)) {
|