@zq-silk/yui 0.6.13 → 0.6.14
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/README.md +34 -5
- package/dist/cli/commandCatalog.js +173 -57
- package/dist/cli/helpRenderer.js +3 -1
- package/dist/cli.js +99 -11
- package/dist/commands/configCommands.js +521 -171
- package/dist/commands/deliveryGuardPreflight.js +2 -2
- package/dist/commands/executionAuditCommands.js +56 -3
- package/dist/commands/projectCommands.js +504 -2
- package/dist/commands/releaseCommands.js +0 -1
- package/dist/commands/taskActor.js +17 -0
- package/dist/commands/taskBaseCommands.js +29 -0
- package/dist/commands/taskCommands.js +577 -126
- package/dist/commands/taskContextCommand.js +36 -2
- package/dist/commands/taskNextActionCommand.js +48 -3
- package/dist/commands/taskPublicationCommands.js +319 -0
- package/dist/commands/taskRoleRuntimeStatus.js +83 -59
- package/dist/commands/telemetryCommands.js +14 -13
- package/dist/config/yuiConfig.js +161 -8
- package/dist/context/sessionContextBudget.js +68 -0
- package/dist/context/wakeNotification.js +65 -0
- package/dist/controller/clientRuntime.js +2 -1
- package/dist/controller/ephemeralResourceReaper.js +2 -1
- package/dist/controller/fileSchedulerStoreAdapter.js +183 -16
- package/dist/controller/jobSupervisor.js +5 -4
- package/dist/controller/resourceCleanupLinux.js +6 -6
- package/dist/controller/resourceInventoryLinux.js +3 -3
- package/dist/controller/runtime.js +88 -10
- package/dist/controller/updateReconciliation.js +4 -3
- package/dist/doctor/doctor.js +26 -7
- package/dist/executor/agentConfigurationCatalog.js +18 -0
- package/dist/executor/fileRoleLaunchPlanner.js +3 -3
- package/dist/lifecycle/contextBudgetRollover.js +81 -0
- package/dist/lifecycle/exactRunTerminalization.js +11 -1
- package/dist/lifecycle/providerErrorClass.js +33 -12
- package/dist/observability/executionAudit.js +214 -6
- package/dist/output/table.js +18 -0
- package/dist/repository/gitWorkspace.js +92 -0
- package/dist/repository/project.js +218 -4
- package/dist/repository/taskBaseFreshness.js +318 -0
- package/dist/repository/taskWorkspacePreparer.js +16 -2
- package/dist/review/deltaRecheck.js +232 -0
- package/dist/review/reviewConfig.js +31 -0
- package/dist/review/reviewFindingLedger.js +5 -1
- package/dist/review/reviewRound.js +156 -1
- package/dist/run/providerRetry.js +21 -3
- package/dist/run/providerRetryConfig.js +13 -60
- package/dist/run/recoveryProjection.js +199 -0
- package/dist/runtime/builtinAgentDrivers.js +3 -0
- package/dist/runtime/builtinTranscriptUsage.js +76 -32
- package/dist/runtime/continuationManager.js +17 -0
- package/dist/runtime/index.js +2 -0
- package/dist/runtime/launchDiagnostics.js +154 -0
- package/dist/runtime/lifecycleReservation.js +13 -0
- package/dist/runtime/providerContinuation.js +38 -0
- package/dist/runtime/providerContinuationReconciliationService.js +1 -0
- package/dist/runtime/providerErrorCodes.js +278 -0
- package/dist/runtime/runtimeHealthPolicy.js +20 -0
- package/dist/runtime/runtimeObservation.js +1 -0
- package/dist/runtime/runtimeProjection.js +115 -23
- package/dist/runtime/tmuxAdapters.js +242 -48
- package/dist/scheduler/activeRoleRunDelivery.js +139 -3
- package/dist/scheduler/activeTaskProgress.js +4 -3
- package/dist/scheduler/leaderWakeupProcessor.js +105 -15
- package/dist/scheduler/roleRunLiveness.js +2 -1
- package/dist/scheduler/roleRunStall.js +3 -2
- package/dist/scheduler/taskWake.js +72 -0
- package/dist/scheduler/wakeReason.js +64 -0
- package/dist/scheduler/wakeupQueue.js +2 -1
- package/dist/setup/setupCommand.js +1 -1
- package/dist/storage/migration/productionRegistry.js +325 -1
- package/dist/storage/sqliteSchema.js +61 -2
- package/dist/storage/sqliteStore.js +129 -2
- package/dist/storage/storeRpc.js +1 -0
- package/dist/storage/taskStore.js +262 -5
- package/dist/storage/upgrade/recordVersions.js +6 -1
- package/dist/storage/upgrade/sqliteStateMigration.js +22 -2
- package/dist/task/completionReadiness.js +282 -0
- package/dist/task/publicationReference.js +123 -0
- package/dist/task/taskRecordReference.js +3 -1
- package/dist/telemetry/telemetryConfig.js +23 -18
- package/dist/telemetry/telemetryWiring.js +8 -8
- package/dist/tmux/tmuxManager.js +50 -9
- package/dist/web/assets/client/i18n.js +4 -0
- package/dist/web/assets/client/view.js +18 -0
- package/dist/web/webSnapshot.js +100 -10
- package/i18n/README.zh-CN.md +5 -5
- package/package.json +1 -1
- package/skills/yui-leader/SKILL.md +49 -10
- package/skills/yui-operator/SKILL.md +13 -3
- package/skills/yui-worker/SKILL.md +8 -0
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { join, resolve } from "node:path";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
2
3
|
/** The Yui-owned directory holding a managed Project's canonical repository. */
|
|
3
4
|
export function managedProjectPath(home, projectId) {
|
|
4
5
|
const id = requireIdentity(projectId, "Project id");
|
|
@@ -7,7 +8,7 @@ export function managedProjectPath(home, projectId) {
|
|
|
7
8
|
export function createProject(id, name, path, branches, now, metadata = {}) {
|
|
8
9
|
const timestamp = now.toISOString();
|
|
9
10
|
return validateProject({
|
|
10
|
-
schemaVersion:
|
|
11
|
+
schemaVersion: 4,
|
|
11
12
|
id: requireIdentity(id, "Project id"),
|
|
12
13
|
name: validateProjectName(name),
|
|
13
14
|
aliases: normalizeAliases(metadata.aliases ?? [], name),
|
|
@@ -19,6 +20,7 @@ export function createProject(id, name, path, branches, now, metadata = {}) {
|
|
|
19
20
|
stableBranch: requireGitRef(branches.stable, "Project stable branch"),
|
|
20
21
|
developmentBranch: requireGitRef(branches.development, "Project development branch"),
|
|
21
22
|
knowledge: [],
|
|
23
|
+
knowledgeProposals: [],
|
|
22
24
|
createdAt: timestamp,
|
|
23
25
|
updatedAt: timestamp
|
|
24
26
|
});
|
|
@@ -26,7 +28,7 @@ export function createProject(id, name, path, branches, now, metadata = {}) {
|
|
|
26
28
|
export function validateProjectName(value) {
|
|
27
29
|
return requireIdentity(value, "Project name");
|
|
28
30
|
}
|
|
29
|
-
export function addProjectKnowledge(project, id, title, body, now) {
|
|
31
|
+
export function addProjectKnowledge(project, id, title, body, now, provenance) {
|
|
30
32
|
const timestamp = now.toISOString();
|
|
31
33
|
const knowledge = {
|
|
32
34
|
schemaVersion: 1,
|
|
@@ -34,6 +36,7 @@ export function addProjectKnowledge(project, id, title, body, now) {
|
|
|
34
36
|
title: requireText(title, "Project knowledge title"),
|
|
35
37
|
body: requireText(body, "Project knowledge body"),
|
|
36
38
|
status: "active",
|
|
39
|
+
...(provenance === undefined ? {} : { provenance: validateKnowledgeProvenance(provenance) }),
|
|
37
40
|
createdAt: timestamp,
|
|
38
41
|
updatedAt: timestamp
|
|
39
42
|
};
|
|
@@ -108,6 +111,108 @@ export function retireProjectKnowledge(project, id, now) {
|
|
|
108
111
|
throw new Error(`Project knowledge not found: ${knowledgeId}.`);
|
|
109
112
|
return validateProject({ ...project, knowledge, updatedAt: now.toISOString() });
|
|
110
113
|
}
|
|
114
|
+
/**
|
|
115
|
+
* Stable dedup fingerprint for a promotion candidate. Two proposals with the
|
|
116
|
+
* same source identity, title, and body are the same conclusion regardless of
|
|
117
|
+
* who proposed them or when.
|
|
118
|
+
*/
|
|
119
|
+
export function knowledgeProposalFingerprint(input) {
|
|
120
|
+
const sourceKey = [
|
|
121
|
+
input.projectId,
|
|
122
|
+
input.source.taskId,
|
|
123
|
+
input.source.decisionId ?? "",
|
|
124
|
+
input.source.milestoneId ?? "",
|
|
125
|
+
input.source.commitSha ?? ""
|
|
126
|
+
].join("|");
|
|
127
|
+
return createHash("sha256")
|
|
128
|
+
.update(`${sourceKey}\u0000${input.title}\u0000${input.body}`)
|
|
129
|
+
.digest("hex");
|
|
130
|
+
}
|
|
131
|
+
/** Digest of the cited source evidence, so a later Agent can verify it. */
|
|
132
|
+
export function knowledgeEvidenceDigest(evidence) {
|
|
133
|
+
return createHash("sha256").update(requireText(evidence, "Knowledge evidence")).digest("hex");
|
|
134
|
+
}
|
|
135
|
+
export function findKnowledgeProposal(project, proposalId) {
|
|
136
|
+
return project.knowledgeProposals.find(({ id }) => id === proposalId) ?? null;
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Append a validated proposal. The caller is responsible for deduplication:
|
|
140
|
+
* use {@link findKnowledgeProposalByFingerprint} first and reuse the existing
|
|
141
|
+
* pending candidate instead of storing a second copy.
|
|
142
|
+
*/
|
|
143
|
+
export function addKnowledgeProposal(project, proposal) {
|
|
144
|
+
const stored = validateKnowledgeProposal(proposal, project.id);
|
|
145
|
+
if (project.knowledgeProposals.some((entry) => entry.id === stored.id)) {
|
|
146
|
+
throw new Error(`Knowledge proposal already exists: ${stored.id}.`);
|
|
147
|
+
}
|
|
148
|
+
return validateProject({
|
|
149
|
+
...project,
|
|
150
|
+
knowledgeProposals: [...project.knowledgeProposals, stored],
|
|
151
|
+
updatedAt: stored.proposedAt
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
export function findKnowledgeProposalByFingerprint(project, fingerprint, status) {
|
|
155
|
+
return project.knowledgeProposals.find((entry) => (entry.fingerprint === fingerprint
|
|
156
|
+
&& (status === undefined || entry.status === status))) ?? null;
|
|
157
|
+
}
|
|
158
|
+
/** Record the Operator decision on a pending proposal. */
|
|
159
|
+
export function decideKnowledgeProposal(project, proposalId, decision, now) {
|
|
160
|
+
const id = requireIdentity(proposalId, "Knowledge proposal id");
|
|
161
|
+
const timestamp = now.toISOString();
|
|
162
|
+
let found = false;
|
|
163
|
+
const knowledgeProposals = project.knowledgeProposals.map((entry) => {
|
|
164
|
+
if (entry.id !== id)
|
|
165
|
+
return entry;
|
|
166
|
+
found = true;
|
|
167
|
+
if (entry.status !== "pending") {
|
|
168
|
+
throw new Error(`Knowledge proposal is already ${entry.status}: ${id}.`);
|
|
169
|
+
}
|
|
170
|
+
return {
|
|
171
|
+
...entry,
|
|
172
|
+
status: decision.knowledgeId === undefined ? "rejected" : "accepted",
|
|
173
|
+
decidedBy: decision.by,
|
|
174
|
+
decidedAt: timestamp,
|
|
175
|
+
...(decision.reason === undefined ? {} : { decisionReason: requireText(decision.reason, "Knowledge proposal decision reason") }),
|
|
176
|
+
...(decision.knowledgeId === undefined ? {} : { knowledgeId: requireIdentity(decision.knowledgeId, "Knowledge id") }),
|
|
177
|
+
updatedAt: timestamp
|
|
178
|
+
};
|
|
179
|
+
});
|
|
180
|
+
if (!found)
|
|
181
|
+
throw new Error(`Knowledge proposal not found: ${id}.`);
|
|
182
|
+
return validateProject({ ...project, knowledgeProposals, updatedAt: timestamp });
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* Pure acceptance policy. A proposal is a duplicate when an active Knowledge
|
|
186
|
+
* entry already carries its fingerprint. It conflicts when an active entry has
|
|
187
|
+
* the same title but a different conclusion; the Operator must then choose an
|
|
188
|
+
* explicit update, supersede, or reject instead of a silent overwrite.
|
|
189
|
+
*/
|
|
190
|
+
export function planKnowledgeAcceptance(project, proposal) {
|
|
191
|
+
const active = project.knowledge.filter((entry) => entry.status === "active");
|
|
192
|
+
const duplicate = active.find((entry) => entry.provenance?.fingerprint === proposal.fingerprint);
|
|
193
|
+
if (duplicate !== undefined)
|
|
194
|
+
return { kind: "duplicate", knowledgeId: duplicate.id };
|
|
195
|
+
const conflicting = active.find((entry) => entry.title === proposal.title);
|
|
196
|
+
if (conflicting !== undefined) {
|
|
197
|
+
throw new Error(`Knowledge ${conflicting.id} has the same title but a different conclusion.`
|
|
198
|
+
+ " Accept with --update to replace its body, or reject the proposal.");
|
|
199
|
+
}
|
|
200
|
+
if (proposal.supersedesKnowledgeId !== undefined) {
|
|
201
|
+
const target = project.knowledge.find((entry) => entry.id === proposal.supersedesKnowledgeId);
|
|
202
|
+
if (target === undefined) {
|
|
203
|
+
throw new Error(`Knowledge to supersede not found: ${proposal.supersedesKnowledgeId}.`);
|
|
204
|
+
}
|
|
205
|
+
if (target.status !== "active") {
|
|
206
|
+
throw new Error(`Knowledge to supersede is not active: ${target.id}.`);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
return {
|
|
210
|
+
kind: "create",
|
|
211
|
+
...(proposal.supersedesKnowledgeId === undefined
|
|
212
|
+
? {}
|
|
213
|
+
: { supersedesKnowledgeId: proposal.supersedesKnowledgeId })
|
|
214
|
+
};
|
|
215
|
+
}
|
|
111
216
|
export function resolveProject(projects, reference) {
|
|
112
217
|
const normalized = requireText(reference, "Project reference").toLocaleLowerCase();
|
|
113
218
|
const matches = projects.filter((project) => (project.id.toLocaleLowerCase() === normalized
|
|
@@ -138,8 +243,8 @@ export function assertProjectCatalog(projects) {
|
|
|
138
243
|
}
|
|
139
244
|
}
|
|
140
245
|
export function validateProject(project) {
|
|
141
|
-
if (project.schemaVersion !==
|
|
142
|
-
throw new Error("Project must use schemaVersion
|
|
246
|
+
if (project.schemaVersion !== 4) {
|
|
247
|
+
throw new Error("Project must use schemaVersion 4.");
|
|
143
248
|
}
|
|
144
249
|
requireIdentity(project.id, "Project id");
|
|
145
250
|
requireIdentity(project.name, "Project name");
|
|
@@ -172,16 +277,125 @@ export function validateProject(project) {
|
|
|
172
277
|
if (!["active", "retired"].includes(entry.status)) {
|
|
173
278
|
throw new Error("Project knowledge status is invalid.");
|
|
174
279
|
}
|
|
280
|
+
if (entry.provenance !== undefined) {
|
|
281
|
+
validateKnowledgeProvenance(entry.provenance);
|
|
282
|
+
}
|
|
175
283
|
requireTimestamp(entry.createdAt, "Project knowledge createdAt");
|
|
176
284
|
requireTimestamp(entry.updatedAt, "Project knowledge updatedAt");
|
|
177
285
|
if (knowledgeIds.has(entry.id))
|
|
178
286
|
throw new Error(`Duplicate Project knowledge id: ${entry.id}.`);
|
|
179
287
|
knowledgeIds.add(entry.id);
|
|
180
288
|
}
|
|
289
|
+
const proposalIds = new Set();
|
|
290
|
+
for (const entry of project.knowledgeProposals) {
|
|
291
|
+
validateKnowledgeProposal(entry, project.id);
|
|
292
|
+
if (proposalIds.has(entry.id)) {
|
|
293
|
+
throw new Error(`Duplicate Knowledge proposal id: ${entry.id}.`);
|
|
294
|
+
}
|
|
295
|
+
proposalIds.add(entry.id);
|
|
296
|
+
}
|
|
181
297
|
requireTimestamp(project.createdAt, "Project createdAt");
|
|
182
298
|
requireTimestamp(project.updatedAt, "Project updatedAt");
|
|
183
299
|
return project;
|
|
184
300
|
}
|
|
301
|
+
function validateKnowledgeProvenance(provenance) {
|
|
302
|
+
requireIdentity(provenance.taskId, "Knowledge provenance task id");
|
|
303
|
+
if (provenance.decisionId !== undefined) {
|
|
304
|
+
requireIdentity(provenance.decisionId, "Knowledge provenance decision id");
|
|
305
|
+
}
|
|
306
|
+
if (provenance.milestoneId !== undefined) {
|
|
307
|
+
requireIdentity(provenance.milestoneId, "Knowledge provenance milestone id");
|
|
308
|
+
}
|
|
309
|
+
if (provenance.commitSha !== undefined) {
|
|
310
|
+
requireText(provenance.commitSha, "Knowledge provenance commit");
|
|
311
|
+
}
|
|
312
|
+
if (provenance.evidenceDigest !== undefined) {
|
|
313
|
+
requireText(provenance.evidenceDigest, "Knowledge provenance evidence digest");
|
|
314
|
+
}
|
|
315
|
+
if (provenance.fingerprint !== undefined) {
|
|
316
|
+
requireText(provenance.fingerprint, "Knowledge provenance fingerprint");
|
|
317
|
+
}
|
|
318
|
+
if (provenance.proposalId !== undefined) {
|
|
319
|
+
requireIdentity(provenance.proposalId, "Knowledge provenance proposal id");
|
|
320
|
+
}
|
|
321
|
+
if (provenance.promotedBy !== undefined
|
|
322
|
+
&& provenance.promotedBy !== "user"
|
|
323
|
+
&& provenance.promotedBy !== "operator") {
|
|
324
|
+
throw new Error("Knowledge provenance promotedBy is invalid.");
|
|
325
|
+
}
|
|
326
|
+
if (provenance.promotedAt !== undefined) {
|
|
327
|
+
requireTimestamp(provenance.promotedAt, "Knowledge provenance promotedAt");
|
|
328
|
+
}
|
|
329
|
+
return provenance;
|
|
330
|
+
}
|
|
331
|
+
function validateKnowledgeProposal(proposal, projectId) {
|
|
332
|
+
if (proposal.schemaVersion !== 1) {
|
|
333
|
+
throw new Error("Knowledge proposal must use schemaVersion 1.");
|
|
334
|
+
}
|
|
335
|
+
requireIdentity(proposal.id, "Knowledge proposal id");
|
|
336
|
+
if (proposal.projectId !== projectId) {
|
|
337
|
+
throw new Error(`Knowledge proposal belongs to another Project: ${proposal.projectId}.`);
|
|
338
|
+
}
|
|
339
|
+
requireText(proposal.title, "Knowledge proposal title");
|
|
340
|
+
requireText(proposal.body, "Knowledge proposal body");
|
|
341
|
+
if (!["pending", "accepted", "rejected"].includes(proposal.status)) {
|
|
342
|
+
throw new Error("Knowledge proposal status is invalid.");
|
|
343
|
+
}
|
|
344
|
+
requireIdentity(proposal.source.taskId, "Knowledge proposal source task id");
|
|
345
|
+
if (proposal.source.decisionId !== undefined) {
|
|
346
|
+
requireIdentity(proposal.source.decisionId, "Knowledge proposal source decision id");
|
|
347
|
+
}
|
|
348
|
+
if (proposal.source.milestoneId !== undefined) {
|
|
349
|
+
requireIdentity(proposal.source.milestoneId, "Knowledge proposal source milestone id");
|
|
350
|
+
}
|
|
351
|
+
if (proposal.source.commitSha !== undefined) {
|
|
352
|
+
requireText(proposal.source.commitSha, "Knowledge proposal source commit");
|
|
353
|
+
}
|
|
354
|
+
if (proposal.scope !== undefined)
|
|
355
|
+
requireText(proposal.scope, "Knowledge proposal scope");
|
|
356
|
+
if (proposal.expiresWhen !== undefined) {
|
|
357
|
+
requireText(proposal.expiresWhen, "Knowledge proposal expiry condition");
|
|
358
|
+
}
|
|
359
|
+
if (proposal.supersedesKnowledgeId !== undefined) {
|
|
360
|
+
requireIdentity(proposal.supersedesKnowledgeId, "Knowledge proposal supersedes id");
|
|
361
|
+
}
|
|
362
|
+
requireText(proposal.fingerprint, "Knowledge proposal fingerprint");
|
|
363
|
+
if (proposal.evidenceDigest !== undefined) {
|
|
364
|
+
requireText(proposal.evidenceDigest, "Knowledge proposal evidence digest");
|
|
365
|
+
}
|
|
366
|
+
if (!["leader", "reviewer", "user", "operator"].includes(proposal.proposedBy)) {
|
|
367
|
+
throw new Error("Knowledge proposal proposedBy is invalid.");
|
|
368
|
+
}
|
|
369
|
+
requireTimestamp(proposal.proposedAt, "Knowledge proposal proposedAt");
|
|
370
|
+
if (proposal.decidedBy !== undefined
|
|
371
|
+
&& proposal.decidedBy !== "user"
|
|
372
|
+
&& proposal.decidedBy !== "operator") {
|
|
373
|
+
throw new Error("Knowledge proposal decidedBy is invalid.");
|
|
374
|
+
}
|
|
375
|
+
if (proposal.decidedAt !== undefined) {
|
|
376
|
+
requireTimestamp(proposal.decidedAt, "Knowledge proposal decidedAt");
|
|
377
|
+
}
|
|
378
|
+
if (proposal.decisionReason !== undefined) {
|
|
379
|
+
requireText(proposal.decisionReason, "Knowledge proposal decision reason");
|
|
380
|
+
}
|
|
381
|
+
if (proposal.knowledgeId !== undefined) {
|
|
382
|
+
requireIdentity(proposal.knowledgeId, "Knowledge proposal knowledge id");
|
|
383
|
+
}
|
|
384
|
+
requireTimestamp(proposal.updatedAt, "Knowledge proposal updatedAt");
|
|
385
|
+
if (proposal.status === "pending" && proposal.decidedAt !== undefined) {
|
|
386
|
+
throw new Error("Pending Knowledge proposal must not carry a decision timestamp.");
|
|
387
|
+
}
|
|
388
|
+
if (proposal.status !== "pending" && proposal.decidedAt === undefined) {
|
|
389
|
+
throw new Error("Decided Knowledge proposal must carry a decision timestamp.");
|
|
390
|
+
}
|
|
391
|
+
if (proposal.status === "accepted" && proposal.knowledgeId === undefined) {
|
|
392
|
+
throw new Error("Accepted Knowledge proposal must reference its Knowledge entry.");
|
|
393
|
+
}
|
|
394
|
+
if (proposal.status !== "accepted" && proposal.knowledgeId !== undefined) {
|
|
395
|
+
throw new Error("Only an accepted Knowledge proposal may reference a Knowledge entry.");
|
|
396
|
+
}
|
|
397
|
+
return proposal;
|
|
398
|
+
}
|
|
185
399
|
function normalizeAliases(values, name) {
|
|
186
400
|
const aliases = values.map((value) => requireIdentity(value, "Project alias"));
|
|
187
401
|
const folded = new Set();
|
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
import { usageError } from "../errors/cliError.js";
|
|
2
|
+
import { createTaskEvent } from "../event/taskEvent.js";
|
|
3
|
+
import { workspaceProjectEntry } from "../worktree/managedWorkspace.js";
|
|
4
|
+
import { NodeGitWorkspace } from "./gitWorkspace.js";
|
|
5
|
+
const COMMIT_PATTERN = /^[a-f0-9]{40}(?:[a-f0-9]{24})?$/u;
|
|
6
|
+
const RISK_FILE_PREVIEW = 20;
|
|
7
|
+
export async function captureTaskBaseProvenance(input) {
|
|
8
|
+
const common = {
|
|
9
|
+
projectId: input.project.id,
|
|
10
|
+
directory: input.binding.directory,
|
|
11
|
+
baseRef: input.baseRef,
|
|
12
|
+
baseCommit: input.baseCommit,
|
|
13
|
+
...(input.project.remoteUrl === undefined
|
|
14
|
+
? {}
|
|
15
|
+
: { remoteUrl: redactRemoteUrl(input.project.remoteUrl) }),
|
|
16
|
+
remoteConfigured: input.project.remoteUrl !== undefined
|
|
17
|
+
};
|
|
18
|
+
if (input.project.remoteUrl === undefined) {
|
|
19
|
+
return { ...common, source: "not-applicable" };
|
|
20
|
+
}
|
|
21
|
+
if (input.remoteBaseline !== undefined) {
|
|
22
|
+
return {
|
|
23
|
+
...common,
|
|
24
|
+
source: "remote-fetch",
|
|
25
|
+
trackingRef: `refs/heads/${input.remoteBaseline.branch}`,
|
|
26
|
+
trackingCommit: input.remoteBaseline.commit
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
const tracking = await input.git.inspectRemoteTracking({
|
|
30
|
+
repositoryPath: input.project.path,
|
|
31
|
+
remoteUrl: input.project.remoteUrl,
|
|
32
|
+
branch: input.project.developmentBranch
|
|
33
|
+
});
|
|
34
|
+
if (tracking === null) {
|
|
35
|
+
return { ...common, source: "unknown" };
|
|
36
|
+
}
|
|
37
|
+
return {
|
|
38
|
+
...common,
|
|
39
|
+
source: "local-tracking",
|
|
40
|
+
trackingRef: tracking.ref,
|
|
41
|
+
trackingCommit: tracking.commit
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
export function recordTaskBaseProvenanceEvents(tx, taskId, provenance, now) {
|
|
45
|
+
for (const entry of provenance) {
|
|
46
|
+
const payload = {
|
|
47
|
+
projectId: entry.projectId,
|
|
48
|
+
directory: entry.directory,
|
|
49
|
+
baseRef: entry.baseRef,
|
|
50
|
+
baseCommit: entry.baseCommit,
|
|
51
|
+
source: entry.source,
|
|
52
|
+
remoteConfigured: entry.remoteConfigured ? "true" : "false",
|
|
53
|
+
...(entry.remoteUrl === undefined ? {} : { remoteUrl: entry.remoteUrl }),
|
|
54
|
+
...(entry.trackingRef === undefined ? {} : { trackingRef: entry.trackingRef }),
|
|
55
|
+
...(entry.trackingCommit === undefined ? {} : { trackingCommit: entry.trackingCommit })
|
|
56
|
+
};
|
|
57
|
+
tx.saveEvent(taskId, createTaskEvent(tx.nextEventId(taskId), taskId, "task.base-provenance", payload, now));
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
export async function inspectTaskBaseFreshness(taskId, store, options = {}) {
|
|
61
|
+
const task = requireTask(store, taskId);
|
|
62
|
+
const git = options.git ?? newGitWorkspace();
|
|
63
|
+
const workspace = store.getTaskWorkspace(taskId);
|
|
64
|
+
const entries = await Promise.all(task.projectBindings.map(async (binding) => {
|
|
65
|
+
const project = requireProject(store, binding.projectId);
|
|
66
|
+
const entry = workspace === null ? undefined : workspaceProjectEntry(workspace, project.id);
|
|
67
|
+
const baseCommit = await resolveBaseCommit(git, project, binding, entry);
|
|
68
|
+
const workspacePath = entry?.path ?? project.path;
|
|
69
|
+
const provenance = latestBaseProvenance(store.listEvents(taskId), project.id);
|
|
70
|
+
const tracked = await resolveTracked(git, project, workspacePath, options.refresh === true);
|
|
71
|
+
const status = await classifyStatus(git, workspacePath, project, baseCommit, tracked);
|
|
72
|
+
const remoteOnlyChangedFiles = await remoteOnlyChanges(git, workspacePath, status, baseCommit, tracked?.commit);
|
|
73
|
+
const workspaceClean = await inspectWorkspaceClean(git, workspacePath);
|
|
74
|
+
return {
|
|
75
|
+
projectId: project.id,
|
|
76
|
+
directory: binding.directory,
|
|
77
|
+
baseRef: binding.baseRef,
|
|
78
|
+
baseCommit,
|
|
79
|
+
status,
|
|
80
|
+
source: tracked?.source ?? (project.remoteUrl === undefined ? "not-applicable" : "compatibility-projection"),
|
|
81
|
+
workspacePath,
|
|
82
|
+
workspaceClean,
|
|
83
|
+
...(tracked === undefined ? {} : {
|
|
84
|
+
trackedRef: tracked.ref,
|
|
85
|
+
trackedCommit: tracked.commit,
|
|
86
|
+
...(tracked.error === undefined ? {} : { error: redactText(tracked.error) })
|
|
87
|
+
}),
|
|
88
|
+
remoteOnlyChangedFiles: remoteOnlyChangedFiles.files.slice(0, RISK_FILE_PREVIEW),
|
|
89
|
+
remoteOnlyChangedFileCount: remoteOnlyChangedFiles.files.length,
|
|
90
|
+
...(risk(status, workspaceClean, remoteOnlyChangedFiles.files, tracked?.error) === undefined
|
|
91
|
+
? {}
|
|
92
|
+
: { risk: risk(status, workspaceClean, remoteOnlyChangedFiles.files, tracked?.error) }),
|
|
93
|
+
...(provenance === undefined ? {} : {
|
|
94
|
+
observedAt: provenance.event.createdAt,
|
|
95
|
+
observedSource: provenance.provenance.source,
|
|
96
|
+
...(provenance.provenance.remoteUrl === undefined
|
|
97
|
+
? {}
|
|
98
|
+
: { observedRemoteUrl: provenance.provenance.remoteUrl }),
|
|
99
|
+
...(provenance.provenance.trackingRef === undefined
|
|
100
|
+
? {}
|
|
101
|
+
: { observedTrackingRef: provenance.provenance.trackingRef }),
|
|
102
|
+
...(provenance.provenance.trackingCommit === undefined
|
|
103
|
+
? {}
|
|
104
|
+
: { observedTrackingCommit: provenance.provenance.trackingCommit })
|
|
105
|
+
})
|
|
106
|
+
};
|
|
107
|
+
}));
|
|
108
|
+
return { taskId, refreshed: options.refresh === true, entries };
|
|
109
|
+
}
|
|
110
|
+
export function assertTaskBaseFreshnessForCompletion(report) {
|
|
111
|
+
const warnings = [];
|
|
112
|
+
for (const entry of report.entries) {
|
|
113
|
+
if (entry.status === "behind" || entry.status === "diverged") {
|
|
114
|
+
throw usageError(`Task ${report.taskId} Project ${entry.projectId} base is ${entry.status}; `
|
|
115
|
+
+ `run 'yui task base status ${report.taskId} --refresh' and choose an explicit delivery base. `
|
|
116
|
+
+ "Safe resolutions are to rebase or merge the Task workspace onto the refreshed remote base, "
|
|
117
|
+
+ "create a clean delivery branch from that base and cherry-pick the Task changes, "
|
|
118
|
+
+ "or ask the Leader to resolve it. Continuing without rebasing is allowed only after explicitly "
|
|
119
|
+
+ "recording the unrelated-diff risk.");
|
|
120
|
+
}
|
|
121
|
+
if (entry.workspaceClean === false) {
|
|
122
|
+
throw usageError(`Task ${report.taskId} Project ${entry.projectId} workspace is dirty; `
|
|
123
|
+
+ "commit, integrate, or clean it before completion.");
|
|
124
|
+
}
|
|
125
|
+
if (entry.status === "unknown") {
|
|
126
|
+
if (report.refreshed) {
|
|
127
|
+
throw usageError(`Task ${report.taskId} Project ${entry.projectId} remote base could not be refreshed: `
|
|
128
|
+
+ `${entry.error ?? "unknown error"}`);
|
|
129
|
+
}
|
|
130
|
+
warnings.push(`Project ${entry.projectId} remote base is unknown; local completion continues without a remote claim.`);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return warnings;
|
|
134
|
+
}
|
|
135
|
+
export function renderTaskBaseFreshnessReport(report) {
|
|
136
|
+
if (report.entries.length === 0) {
|
|
137
|
+
return `Task ${report.taskId} has no Projects.\n`;
|
|
138
|
+
}
|
|
139
|
+
const lines = [
|
|
140
|
+
`Task base freshness: ${report.taskId} (source: ${report.refreshed ? "remote refresh" : "local tracking"})`
|
|
141
|
+
];
|
|
142
|
+
for (const entry of report.entries) {
|
|
143
|
+
lines.push(`- ${entry.directory}: ${entry.projectId} @ ${entry.baseCommit}`, ` status: ${entry.status}; workspace: ${entry.workspaceClean === null ? "unknown" : entry.workspaceClean ? "clean" : "dirty"}`, ` observed remote: ${entry.observedRemoteUrl ?? "-"}`, ` tracked: ${entry.trackedCommit ?? "-"}${entry.trackedRef === undefined ? "" : ` (${entry.trackedRef})`}`, ` observed: ${entry.observedTrackingCommit ?? "-"}${entry.observedAt === undefined ? "" : ` at ${entry.observedAt}`}`);
|
|
144
|
+
if (entry.risk !== undefined)
|
|
145
|
+
lines.push(` risk: ${entry.risk}`);
|
|
146
|
+
if (entry.error !== undefined)
|
|
147
|
+
lines.push(` error: ${entry.error}`);
|
|
148
|
+
}
|
|
149
|
+
return lines.join("\n").concat("\n");
|
|
150
|
+
}
|
|
151
|
+
function newGitWorkspace() {
|
|
152
|
+
return new NodeGitWorkspace();
|
|
153
|
+
}
|
|
154
|
+
function requireTask(store, taskId) {
|
|
155
|
+
const task = store.getTask(taskId);
|
|
156
|
+
if (task === null)
|
|
157
|
+
throw usageError(`Task not found: ${taskId}.`);
|
|
158
|
+
return task;
|
|
159
|
+
}
|
|
160
|
+
function requireProject(store, projectId) {
|
|
161
|
+
const project = store.getProject(projectId);
|
|
162
|
+
if (project === null)
|
|
163
|
+
throw usageError(`Project not found: ${projectId}.`);
|
|
164
|
+
return project;
|
|
165
|
+
}
|
|
166
|
+
async function resolveBaseCommit(git, project, binding, entry) {
|
|
167
|
+
if (entry?.baseCommit !== undefined)
|
|
168
|
+
return entry.baseCommit;
|
|
169
|
+
if (COMMIT_PATTERN.test(binding.baseRef))
|
|
170
|
+
return binding.baseRef.toLowerCase();
|
|
171
|
+
return (await git.inspect(project.path, binding.baseRef)).baseCommit;
|
|
172
|
+
}
|
|
173
|
+
async function resolveTracked(git, project, workspacePath, refresh) {
|
|
174
|
+
if (project.remoteUrl === undefined)
|
|
175
|
+
return undefined;
|
|
176
|
+
if (refresh) {
|
|
177
|
+
if (git.fetchRemoteHeadIntoWorktree === undefined) {
|
|
178
|
+
return {
|
|
179
|
+
ref: `refs/heads/${project.developmentBranch}`,
|
|
180
|
+
commit: "",
|
|
181
|
+
source: "remote-refresh",
|
|
182
|
+
error: "managed Git workspace does not support remote refresh"
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
try {
|
|
186
|
+
const remote = await git.fetchRemoteHeadIntoWorktree({
|
|
187
|
+
repositoryPath: workspacePath,
|
|
188
|
+
remoteUrl: project.remoteUrl,
|
|
189
|
+
branch: project.developmentBranch
|
|
190
|
+
});
|
|
191
|
+
return {
|
|
192
|
+
ref: `refs/heads/${remote.branch}`,
|
|
193
|
+
commit: remote.commit,
|
|
194
|
+
source: "remote-refresh"
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
catch (error) {
|
|
198
|
+
return {
|
|
199
|
+
ref: `refs/heads/${project.developmentBranch}`,
|
|
200
|
+
commit: "",
|
|
201
|
+
source: "remote-refresh",
|
|
202
|
+
error: error instanceof Error ? error.message : String(error)
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
const tracking = await git.inspectRemoteTracking({
|
|
207
|
+
repositoryPath: project.path,
|
|
208
|
+
remoteUrl: project.remoteUrl,
|
|
209
|
+
branch: project.developmentBranch
|
|
210
|
+
});
|
|
211
|
+
if (tracking === null)
|
|
212
|
+
return undefined;
|
|
213
|
+
return { ref: tracking.ref, commit: tracking.commit, source: "local-tracking" };
|
|
214
|
+
}
|
|
215
|
+
async function classifyStatus(git, repositoryPath, project, baseCommit, tracked) {
|
|
216
|
+
if (project.remoteUrl === undefined)
|
|
217
|
+
return "not-applicable";
|
|
218
|
+
if (tracked === undefined || tracked.commit === "")
|
|
219
|
+
return "unknown";
|
|
220
|
+
if (tracked.commit === baseCommit)
|
|
221
|
+
return "up-to-date";
|
|
222
|
+
if (await git.isAncestor(repositoryPath, baseCommit, tracked.commit)) {
|
|
223
|
+
return "behind";
|
|
224
|
+
}
|
|
225
|
+
if (await git.isAncestor(repositoryPath, tracked.commit, baseCommit)) {
|
|
226
|
+
return "ahead";
|
|
227
|
+
}
|
|
228
|
+
return "diverged";
|
|
229
|
+
}
|
|
230
|
+
async function remoteOnlyChanges(git, repositoryPath, status, baseCommit, trackedCommit) {
|
|
231
|
+
if (trackedCommit === undefined
|
|
232
|
+
|| trackedCommit === ""
|
|
233
|
+
|| (status !== "behind" && status !== "diverged")) {
|
|
234
|
+
return { files: [] };
|
|
235
|
+
}
|
|
236
|
+
try {
|
|
237
|
+
const fromCommit = status === "behind"
|
|
238
|
+
? baseCommit
|
|
239
|
+
: await git.mergeBase({ repositoryPath, leftCommit: baseCommit, rightCommit: trackedCommit });
|
|
240
|
+
return {
|
|
241
|
+
files: await git.changedFilesBetween({ repositoryPath, fromCommit, toCommit: trackedCommit })
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
catch {
|
|
245
|
+
return { files: [] };
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
async function inspectWorkspaceClean(git, workspacePath) {
|
|
249
|
+
try {
|
|
250
|
+
return await git.isClean(workspacePath);
|
|
251
|
+
}
|
|
252
|
+
catch {
|
|
253
|
+
return null;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
function risk(status, workspaceClean, files, error) {
|
|
257
|
+
if (status === "behind" || status === "diverged") {
|
|
258
|
+
const suffix = files.length === 0
|
|
259
|
+
? "no remote-only file delta is locally provable"
|
|
260
|
+
: `${files.length} remote-only file${files.length === 1 ? "" : "s"} (${files.slice(0, RISK_FILE_PREVIEW).join(", ")}${files.length > RISK_FILE_PREVIEW ? ", ..." : ""}) may appear as unrelated delivery diff`;
|
|
261
|
+
return `base is ${status}; ${suffix}.`;
|
|
262
|
+
}
|
|
263
|
+
if (workspaceClean === false)
|
|
264
|
+
return "worktree has uncommitted changes.";
|
|
265
|
+
if (status === "unknown")
|
|
266
|
+
return error === undefined ? "remote identity is not locally observable." : redactText(error);
|
|
267
|
+
return undefined;
|
|
268
|
+
}
|
|
269
|
+
function latestBaseProvenance(events, projectId) {
|
|
270
|
+
const event = [...events].reverse().find((candidate) => (candidate.type === "task.base-provenance" && candidate.payload.projectId === projectId));
|
|
271
|
+
if (event === undefined)
|
|
272
|
+
return undefined;
|
|
273
|
+
const source = event.payload.source;
|
|
274
|
+
if (source !== "local-tracking"
|
|
275
|
+
&& source !== "remote-fetch"
|
|
276
|
+
&& source !== "unknown"
|
|
277
|
+
&& source !== "not-applicable") {
|
|
278
|
+
return undefined;
|
|
279
|
+
}
|
|
280
|
+
return {
|
|
281
|
+
event,
|
|
282
|
+
provenance: {
|
|
283
|
+
projectId,
|
|
284
|
+
directory: event.payload.directory ?? "",
|
|
285
|
+
baseRef: event.payload.baseRef ?? "",
|
|
286
|
+
baseCommit: event.payload.baseCommit ?? "",
|
|
287
|
+
source,
|
|
288
|
+
remoteConfigured: event.payload.remoteConfigured === "true",
|
|
289
|
+
...(event.payload.remoteUrl === undefined ? {} : { remoteUrl: event.payload.remoteUrl }),
|
|
290
|
+
...(event.payload.trackingRef === undefined ? {} : { trackingRef: event.payload.trackingRef }),
|
|
291
|
+
...(event.payload.trackingCommit === undefined ? {} : { trackingCommit: event.payload.trackingCommit })
|
|
292
|
+
}
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
function redactText(value) {
|
|
296
|
+
return redactRemoteUrl(value);
|
|
297
|
+
}
|
|
298
|
+
export function redactRemoteUrl(value) {
|
|
299
|
+
return value
|
|
300
|
+
.replace(ABSOLUTE_REMOTE_URL_PATTERN, redactAbsoluteRemoteUrl)
|
|
301
|
+
.replace(SCP_REMOTE_URL_PATTERN, (_match, prefix, _user, host, path) => `${prefix}redacted@${host}:${path}`);
|
|
302
|
+
}
|
|
303
|
+
const ABSOLUTE_REMOTE_URL_PATTERN = /[A-Za-z][A-Za-z0-9+.-]*:\/\/[^\s"'<>]+/gu;
|
|
304
|
+
const SCP_REMOTE_URL_PATTERN = /(^|[\s"'(])([A-Za-z0-9._-]+)@([A-Za-z0-9.-]+):([^\s"'<>]+)/gu;
|
|
305
|
+
function redactAbsoluteRemoteUrl(value) {
|
|
306
|
+
try {
|
|
307
|
+
const url = new URL(value);
|
|
308
|
+
if (url.username !== "" || url.password !== "") {
|
|
309
|
+
url.username = "redacted";
|
|
310
|
+
url.password = "";
|
|
311
|
+
return url.toString();
|
|
312
|
+
}
|
|
313
|
+
return value;
|
|
314
|
+
}
|
|
315
|
+
catch {
|
|
316
|
+
return value.replace(/^([A-Za-z][A-Za-z0-9+.-]*:\/\/)[^/@\s]+@/u, "$1redacted@");
|
|
317
|
+
}
|
|
318
|
+
}
|
|
@@ -14,6 +14,7 @@ import { NodeGitWorkspace, worktreeIdentity } from "./gitWorkspace.js";
|
|
|
14
14
|
import { acquireProjectMaintenanceLocks } from "./projectMaintenanceLock.js";
|
|
15
15
|
import { generateTaskWorkspaceIdentity, isLegacyTaskRef, taskArchiveRef, taskMainBranch, taskWorkspaceRefSegment, taskWorkspaceRefSegmentFromIdentity, TASK_WORKSPACE_TOKEN_PATTERN, validateTaskWorkspaceIdentity } from "./taskWorkspaceIdentity.js";
|
|
16
16
|
import { ResourceRegistrar } from "../resources/resourceRegistrar.js";
|
|
17
|
+
import { captureTaskBaseProvenance, recordTaskBaseProvenanceEvents } from "./taskBaseFreshness.js";
|
|
17
18
|
const MAIN_WORKTREE = "main";
|
|
18
19
|
const LEADER_ROLE = "leader";
|
|
19
20
|
/**
|
|
@@ -240,7 +241,8 @@ export class FileTaskWorkspacePreparer {
|
|
|
240
241
|
baseRef: remote.commit,
|
|
241
242
|
recordedBaseRef: remote.commit,
|
|
242
243
|
expectedCommit: remote.commit,
|
|
243
|
-
pinTask: true
|
|
244
|
+
pinTask: true,
|
|
245
|
+
remoteBaseline: remote
|
|
244
246
|
});
|
|
245
247
|
continue;
|
|
246
248
|
}
|
|
@@ -270,6 +272,14 @@ export class FileTaskWorkspacePreparer {
|
|
|
270
272
|
&& physical.baseCommit !== baseline.expectedCommit) {
|
|
271
273
|
throw new Error(`Task Project workspace did not start at the fetched remote baseline: ${project.id}.`);
|
|
272
274
|
}
|
|
275
|
+
const provenance = await captureTaskBaseProvenance({
|
|
276
|
+
git: this.git,
|
|
277
|
+
project,
|
|
278
|
+
binding,
|
|
279
|
+
baseRef: previous?.baseRef ?? baseline.recordedBaseRef,
|
|
280
|
+
baseCommit: physical.baseCommit,
|
|
281
|
+
...(baseline.remoteBaseline === undefined ? {} : { remoteBaseline: baseline.remoteBaseline })
|
|
282
|
+
});
|
|
273
283
|
prepared.push({
|
|
274
284
|
project,
|
|
275
285
|
entry: {
|
|
@@ -280,7 +290,8 @@ export class FileTaskWorkspacePreparer {
|
|
|
280
290
|
branch: physical.branch,
|
|
281
291
|
baseRef: previous?.baseRef ?? baseline.recordedBaseRef,
|
|
282
292
|
baseCommit: physical.baseCommit
|
|
283
|
-
}
|
|
293
|
+
},
|
|
294
|
+
provenance
|
|
284
295
|
});
|
|
285
296
|
}
|
|
286
297
|
await ensureWorkspaceView(root, prepared.map(({ entry }) => entry));
|
|
@@ -351,6 +362,9 @@ export class FileTaskWorkspacePreparer {
|
|
|
351
362
|
tx.saveTask(persistedTask);
|
|
352
363
|
}
|
|
353
364
|
tx.saveManagedWorkspace(preserveWorkspaceCreatedAt(workspace, current));
|
|
365
|
+
if (current === null) {
|
|
366
|
+
recordTaskBaseProvenanceEvents(tx, task.id, prepared.map(({ provenance }) => provenance), timestamp);
|
|
367
|
+
}
|
|
354
368
|
for (const role of tx.listRoles(task.id)) {
|
|
355
369
|
// The Role field is only a cwd/snapshot hint. Preserve the hint for
|
|
356
370
|
// an active WorkItem assignment; the durable owner is the WorkItem,
|