@zq-silk/yui 2.9.0 → 2.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -3
- package/dist/cli/commandCatalog.js +21 -9
- package/dist/cli/helpRenderer.js +3 -1
- package/dist/commands/globalRoleCommands.js +30 -9
- package/dist/commands/inboxCommands.js +79 -22
- package/dist/commands/taskCommands.js +6 -1
- package/dist/commands/taskProjectDeliveryCommand.js +56 -0
- package/dist/commands/taskRemoteDeliveryCommand.js +5 -3
- package/dist/context/runContextPack.js +1 -0
- package/dist/context/runInputContract.js +1 -0
- package/dist/context/taskContext.js +1 -1
- package/dist/context/taskPhase.js +1 -1
- package/dist/controlPlaneCli.js +32 -17
- package/dist/controller/fileSchedulerStoreAdapter.js +59 -15
- package/dist/controller/globalInputDelivery.js +17 -7
- package/dist/controller/inboxTurnCheck.js +64 -0
- package/dist/controller/providerRetryAdmission.js +2 -0
- package/dist/external/gitlabPublicationVerifier.js +10 -28
- package/dist/grant/taskAuthorization.js +7 -2
- package/dist/interaction/notificationPresentation.js +9 -0
- package/dist/message/globalInboxRecovery.js +44 -0
- package/dist/message/globalInterrupt.js +1 -1
- package/dist/message/inboxReceipt.js +14 -6
- package/dist/message/inboxTurn.js +86 -0
- package/dist/message/message.js +11 -0
- package/dist/message/taskInboxOwnership.js +20 -6
- package/dist/repository/workspaceCleanupInspection.js +6 -1
- package/dist/storage/sqliteSchema.js +21 -0
- package/dist/storage/sqliteStore.js +13 -1
- package/dist/storage/storageVersions.js +1 -1
- package/dist/task/archiveDiagnostics.js +1 -1
- package/dist/task/archivePreflight.js +1 -1
- package/dist/task/projectDeliveryScope.js +20 -0
- package/dist/task/publicationToolObservation.js +4 -6
- package/dist/task/remoteDelivery.js +19 -8
- package/dist/task/remoteDeliveryService.js +5 -4
- package/dist/web/assets/client/i18n/en.js +4 -2
- package/dist/web/assets/client/i18n/zh-CN.js +4 -2
- package/dist/web/assets/client/views/task/deliveryResults.js +6 -2
- package/dist/web/controllerWeb.js +73 -21
- package/dist/web/taskPreviews.js +21 -13
- package/dist/web/webAccess.js +109 -0
- package/dist/web/webServer.js +45 -44
- package/docs/architecture/capabilities-and-resources.md +6 -4
- package/docs/architecture/capabilities-and-resources.zh-CN.md +5 -4
- package/docs/cli-information-contract.md +14 -2
- package/docs/cli-information-contract.zh-CN.md +9 -1
- package/docs/project-delivery-scope.md +59 -0
- package/docs/release-workflow.md +43 -0
- package/docs/release-workflow.zh-CN.md +35 -0
- package/docs/storage-baseline.md +14 -1
- package/docs/storage-baseline.zh-CN.md +11 -1
- package/docs/task-delivery.md +8 -2
- package/docs/task-delivery.zh-CN.md +6 -0
- package/docs/testing/verification-levels.md +2 -2
- package/docs/testing/verification-levels.zh-CN.md +2 -2
- package/i18n/README.zh-CN.md +6 -3
- package/package.json +1 -1
- package/skills/yui-leader/SKILL.md +26 -0
- package/skills/yui-leader/VERSION +1 -1
- package/skills/yui-operator/SKILL.md +31 -0
- package/skills/yui-operator/VERSION +1 -1
- package/skills/yui-runtime/SKILL.md +58 -5
- package/skills/yui-runtime/VERSION +1 -1
- package/skills/yui-runtime/references/recovery.md +21 -0
|
@@ -3,7 +3,8 @@ import { isDeepStrictEqual } from "node:util";
|
|
|
3
3
|
/** Semantic handling and transport delivery are independent facts. Projections
|
|
4
4
|
* must not infer handling from delivery, a native terminal, or an empty queue. */
|
|
5
5
|
export function inboxHandlingState(receipt) {
|
|
6
|
-
return receipt?.
|
|
6
|
+
return receipt?.retirement !== undefined ? "retired" : receipt?.ack !== undefined ? "handled"
|
|
7
|
+
: receipt !== undefined ? "claimed-unacknowledged" : "unclaimed";
|
|
7
8
|
}
|
|
8
9
|
export function isGlobalInboxMessage(message) {
|
|
9
10
|
return message.inputControl !== undefined || message.interruptThen !== undefined;
|
|
@@ -11,7 +12,8 @@ export function isGlobalInboxMessage(message) {
|
|
|
11
12
|
/** Unhandled own-role input, including accepted and unknown delivery. This is
|
|
12
13
|
* NOT the scheduler's deliverable queue; selecting it never authorizes replay. */
|
|
13
14
|
export function pendingGlobalInboxMessages(messages) {
|
|
14
|
-
return messages.filter(message => isGlobalInboxMessage(message)
|
|
15
|
+
return messages.filter(message => isGlobalInboxMessage(message)
|
|
16
|
+
&& !["handled", "retired"].includes(inboxHandlingState(message.inbox)));
|
|
15
17
|
}
|
|
16
18
|
/** Original message evidence only. A missing receipt is not proof of
|
|
17
19
|
* non-submission; admission additionally inspects the exact native attempt. */
|
|
@@ -22,20 +24,22 @@ export function globalInboxDeliveryState(message) {
|
|
|
22
24
|
export function validateInboxReceipt(receipt) {
|
|
23
25
|
if (receipt === undefined)
|
|
24
26
|
return;
|
|
25
|
-
requireKnownFields(receipt, ["claim", "ack"], "Inbox receipt");
|
|
27
|
+
requireKnownFields(receipt, ["claim", "ack", "retirement"], "Inbox receipt");
|
|
28
|
+
if (receipt.ack && receipt.retirement)
|
|
29
|
+
throw new Error("Inbox disposition cannot be both acknowledgement and retirement.");
|
|
26
30
|
for (const [kind, entry] of Object.entries(receipt)) {
|
|
27
31
|
requireKnownFields(entry, kind === "claim" ? ["requestId", "actor", "at"]
|
|
28
32
|
: ["requestId", "actor", "at", "reason"], "Inbox action");
|
|
29
33
|
requireText(entry.requestId, "Inbox request id");
|
|
30
34
|
requireTimestamp(entry.at, "Inbox timestamp");
|
|
31
35
|
const actor = entry.actor;
|
|
32
|
-
requireKnownFields(actor, ["scope", "taskId", "roleName", "agentId", "nativeSessionId", "runId"], "Inbox actor");
|
|
36
|
+
requireKnownFields(actor, ["scope", "taskId", "roleName", "agentId", "nativeSessionId", "runId", "attemptId"], "Inbox actor");
|
|
33
37
|
if (!["task", "global"].includes(actor.scope)
|
|
34
38
|
|| (actor.scope === "task") !== (actor.taskId !== undefined))
|
|
35
39
|
throw new Error("Inbox actor scope is invalid.");
|
|
36
40
|
for (const value of Object.values(actor))
|
|
37
41
|
requireText(value, "Inbox actor identity");
|
|
38
|
-
if (kind
|
|
42
|
+
if (kind !== "claim")
|
|
39
43
|
requireText(entry.reason, "Inbox disposition");
|
|
40
44
|
}
|
|
41
45
|
if (!receipt.claim)
|
|
@@ -45,7 +49,8 @@ export function validateInboxReceipt(receipt) {
|
|
|
45
49
|
* Host admission. The original claim and any ack are immutable audit facts. */
|
|
46
50
|
export function assertInboxReceiptPreserved(previous, next) {
|
|
47
51
|
if (previous !== undefined && (!isDeepStrictEqual(previous.claim, next?.claim)
|
|
48
|
-
|| previous.ack !== undefined && !isDeepStrictEqual(previous.ack, next?.ack)
|
|
52
|
+
|| previous.ack !== undefined && !isDeepStrictEqual(previous.ack, next?.ack)
|
|
53
|
+
|| previous.retirement !== undefined && !isDeepStrictEqual(previous.retirement, next?.retirement))) {
|
|
49
54
|
throw new Error("Inbox handling ownership changed; reread the original instead of overwriting its receipt.");
|
|
50
55
|
}
|
|
51
56
|
}
|
|
@@ -55,6 +60,9 @@ export function assertInboxReceiptPreserved(previous, next) {
|
|
|
55
60
|
*/
|
|
56
61
|
export function assertInboxOriginalPreserved(previous, next) {
|
|
57
62
|
assertInboxReceiptPreserved(previous?.inbox, next.inbox);
|
|
63
|
+
if (previous?.control?.target !== undefined && !isDeepStrictEqual(previous.control.target, next.control?.target)) {
|
|
64
|
+
throw new Error("The exact admitted control target cannot be replaced or erased.");
|
|
65
|
+
}
|
|
58
66
|
if (previous?.inbox !== undefined && (previous.body !== next.body || previous.kind !== next.kind
|
|
59
67
|
|| !isDeepStrictEqual(previous.author, next.author) || !isDeepStrictEqual(previous.inputControl, next.inputControl))) {
|
|
60
68
|
throw new Error("Inbox receipt binds the exact original Message; submit changed content as a new Message.");
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { globalInboxDeliveryState } from "./inboxReceipt.js";
|
|
2
|
+
export const INBOX_CHECK_PREFIX = "inbox-check:";
|
|
3
|
+
export const INBOX_CHECK_REASON = "inbox-handling-missing";
|
|
4
|
+
export const isInboxWake = (entry) => "fromCursor" in entry;
|
|
5
|
+
export const isHandlingReminder = (entry) => isInboxWake(entry)
|
|
6
|
+
? entry.reasons.every(reason => reason === INBOX_CHECK_REASON)
|
|
7
|
+
: entry.kind === "system" && entry.inputControl?.requestId.startsWith(INBOX_CHECK_PREFIX) === true;
|
|
8
|
+
/** Correlation uses existing input identities, not timestamps, output text or
|
|
9
|
+
* Task state. Active consumption adds only the exact attempt to its claim. */
|
|
10
|
+
export function belongsToInboxTurn(entry, actor, turn) {
|
|
11
|
+
if (!turn)
|
|
12
|
+
return false;
|
|
13
|
+
const claim = entry.inbox?.claim.actor;
|
|
14
|
+
if (claim?.attemptId === turn.attemptId && claim.scope === actor.scope && claim.taskId === actor.taskId
|
|
15
|
+
&& claim.roleName === actor.roleName && claim.agentId === actor.agentId
|
|
16
|
+
&& claim.nativeSessionId === actor.nativeSessionId)
|
|
17
|
+
return true;
|
|
18
|
+
if (isInboxWake(entry))
|
|
19
|
+
return actor.scope === "task" && actor.roleName === "leader"
|
|
20
|
+
&& entry.taskId === actor.taskId && (turn.input?.kind === "wake" && turn.input.wakeId === entry.id
|
|
21
|
+
&& turn.input.taskId === entry.taskId
|
|
22
|
+
|| turn.attemptId.startsWith(`notification:${entry.taskId}/${entry.id}/`));
|
|
23
|
+
if ("roleName" in entry) {
|
|
24
|
+
if (actor.scope !== "global" || entry.roleName !== actor.roleName)
|
|
25
|
+
return false;
|
|
26
|
+
if (entry.deliveryTarget && (entry.deliveryTarget.agentId !== actor.agentId
|
|
27
|
+
|| entry.deliveryTarget.nativeSessionId !== actor.nativeSessionId))
|
|
28
|
+
return false;
|
|
29
|
+
if (turn.input?.kind === "message" && turn.input.roleName === actor.roleName
|
|
30
|
+
&& turn.input.messageId === entry.id || turn.attemptId === `global-input:${actor.roleName}/${entry.id}`)
|
|
31
|
+
return true;
|
|
32
|
+
}
|
|
33
|
+
else {
|
|
34
|
+
if (actor.scope !== "task" || entry.taskId !== actor.taskId)
|
|
35
|
+
return false;
|
|
36
|
+
if (turn.runId !== undefined && entry.recipient?.roleName === actor.roleName
|
|
37
|
+
&& entry.continuation?.runId === turn.runId)
|
|
38
|
+
return true;
|
|
39
|
+
}
|
|
40
|
+
const target = entry.control?.target;
|
|
41
|
+
return entry.control?.outcome === "accepted" && target?.agentId === actor.agentId
|
|
42
|
+
&& target.nativeSessionId === actor.nativeSessionId && target.attemptId === turn.attemptId;
|
|
43
|
+
}
|
|
44
|
+
/** Historical absence of ACK is not fresh work. Current unsubmitted input
|
|
45
|
+
* remains discoverable, but never steals an unsettled transport owner. */
|
|
46
|
+
export function inboxEntryCategory(entry, actor, turn) {
|
|
47
|
+
if (belongsToInboxTurn(entry, actor, turn))
|
|
48
|
+
return "current";
|
|
49
|
+
if (entry.inbox !== undefined)
|
|
50
|
+
return "history";
|
|
51
|
+
if (isInboxWake(entry))
|
|
52
|
+
return "history";
|
|
53
|
+
if ("roleName" in entry) {
|
|
54
|
+
if (entry.delivery !== undefined || entry.notDelivered !== undefined
|
|
55
|
+
|| entry.deliveryTarget && (entry.deliveryTarget.agentId !== actor.agentId
|
|
56
|
+
|| entry.deliveryTarget.nativeSessionId !== actor.nativeSessionId))
|
|
57
|
+
return "history";
|
|
58
|
+
}
|
|
59
|
+
else if (entry.continuation?.runId !== undefined)
|
|
60
|
+
return "history";
|
|
61
|
+
if (entry.inputControl?.expectedSessionId !== undefined
|
|
62
|
+
&& entry.inputControl.expectedSessionId !== actor.nativeSessionId)
|
|
63
|
+
return "history";
|
|
64
|
+
if (entry.control?.outcome === "accepted")
|
|
65
|
+
return "history";
|
|
66
|
+
return "available";
|
|
67
|
+
}
|
|
68
|
+
export function inboxSettled(receipt) {
|
|
69
|
+
return receipt?.ack !== undefined || receipt?.retirement !== undefined;
|
|
70
|
+
}
|
|
71
|
+
export function missingTurnHandling(entries, actor, turn) {
|
|
72
|
+
return entries.filter(entry => !inboxSettled(entry.inbox) && !isHandlingReminder(entry)
|
|
73
|
+
&& belongsToInboxTurn(entry, actor, turn));
|
|
74
|
+
}
|
|
75
|
+
/** Accepted historical input and explicit nondelivery do not impose an input
|
|
76
|
+
* order on independent new messages. Unknown delivery still does. */
|
|
77
|
+
export function blocksInboxOrder(entry, actor, turn) {
|
|
78
|
+
if (isInboxWake(entry) || inboxSettled(entry.inbox))
|
|
79
|
+
return false;
|
|
80
|
+
if ("roleName" in entry && (globalInboxDeliveryState(entry) === "accepted" || entry.notDelivered !== undefined))
|
|
81
|
+
return false;
|
|
82
|
+
if (entry.control?.outcome === "accepted")
|
|
83
|
+
return false;
|
|
84
|
+
return inboxEntryCategory(entry, actor, turn) !== "history"
|
|
85
|
+
|| entry.control !== undefined && ["pending", "delivery-unknown"].includes(entry.control.outcome);
|
|
86
|
+
}
|
package/dist/message/message.js
CHANGED
|
@@ -147,6 +147,7 @@ export function recordTaskMessageControlOutcome(message, record) {
|
|
|
147
147
|
requestId: requireSafeIdentity(record.requestId, "Message control requestId"),
|
|
148
148
|
receiptId: requireText(record.receiptId, "Message control receiptId"),
|
|
149
149
|
outcome: record.outcome,
|
|
150
|
+
...(existing?.target ?? record.target ? { target: existing?.target ?? record.target } : {}),
|
|
150
151
|
observedAt: record.observedAt.toISOString()
|
|
151
152
|
}
|
|
152
153
|
};
|
|
@@ -282,6 +283,7 @@ export function validateTaskMessage(message) {
|
|
|
282
283
|
}
|
|
283
284
|
}
|
|
284
285
|
if (message.control !== undefined) {
|
|
286
|
+
validateControlTarget(message.control);
|
|
285
287
|
requireSafeIdentity(message.control.requestId, "Message control requestId");
|
|
286
288
|
requireText(message.control.receiptId, "Message control receiptId");
|
|
287
289
|
if (!TASK_MESSAGE_INPUT_CONTROL_OUTCOMES.includes(message.control.outcome)) {
|
|
@@ -408,6 +410,7 @@ export function validateGlobalRoleMessage(message) {
|
|
|
408
410
|
requireText(message.deliveryTarget.nativeSessionId, "Global input Session");
|
|
409
411
|
}
|
|
410
412
|
if (message.control !== undefined) {
|
|
413
|
+
validateControlTarget(message.control);
|
|
411
414
|
requireSafeIdentity(message.control.requestId, "Global input requestId");
|
|
412
415
|
requireText(message.control.receiptId, "Global input receiptId");
|
|
413
416
|
if (!TASK_MESSAGE_INPUT_CONTROL_OUTCOMES.includes(message.control.outcome)
|
|
@@ -479,6 +482,14 @@ export function validateGlobalRoleMessage(message) {
|
|
|
479
482
|
throw new Error("Message createdAt is invalid.");
|
|
480
483
|
}
|
|
481
484
|
}
|
|
485
|
+
function validateControlTarget(control) {
|
|
486
|
+
if (control.target === undefined)
|
|
487
|
+
return;
|
|
488
|
+
requireKnownFields(control.target, ["agentId", "nativeSessionId", "attemptId"], "Control target");
|
|
489
|
+
requireText(control.target.agentId, "Control target Agent");
|
|
490
|
+
requireText(control.target.nativeSessionId, "Control target Session");
|
|
491
|
+
requireText(control.target.attemptId, "Control target attempt");
|
|
492
|
+
}
|
|
482
493
|
/** A Global message local id is `global-message-<n>`; it carries no Task id. */
|
|
483
494
|
export function validateGlobalRoleMessageId(localId) {
|
|
484
495
|
const normalized = requireSafeIdentity(localId, "Global message id");
|
|
@@ -26,10 +26,15 @@ export function isLeaderSteer(message) {
|
|
|
26
26
|
export function taskMessageInboxReceipt(store, message) {
|
|
27
27
|
return message.inbox ?? messageRevisionWakes(store, message).find(wake => wake.inbox !== undefined)?.inbox;
|
|
28
28
|
}
|
|
29
|
-
/**
|
|
30
|
-
*
|
|
31
|
-
export function
|
|
32
|
-
return messageRevisionWakes(store, message).find(wake => wake.status === "consumed");
|
|
29
|
+
/** Acceptance or explicit no-replay release suppresses repeat notification,
|
|
30
|
+
* never handling. The original fixed window stays in the own inbox. */
|
|
31
|
+
export function nonReplayableMessageWake(store, message) {
|
|
32
|
+
return messageRevisionWakes(store, message).find(wake => wake.status === "consumed" || releasedNotificationWake(store, message.taskId, wake.id));
|
|
33
|
+
}
|
|
34
|
+
/** Operational release is not acceptance. Keep its originals out of automatic
|
|
35
|
+
* delivery while permitting explicit inspection and settlement in the inbox. */
|
|
36
|
+
export function releasedNotificationWake(store, taskId, wakeId) {
|
|
37
|
+
return store.listEventsByType(taskId, ["notification.resolved"]).some(event => event.payload.wakeId === wakeId && event.payload.outcome === "released-without-replay");
|
|
33
38
|
}
|
|
34
39
|
function messageRevisionWakes(store, message) {
|
|
35
40
|
const revision = latestMessageUpdate(store, message);
|
|
@@ -37,7 +42,16 @@ function messageRevisionWakes(store, message) {
|
|
|
37
42
|
&& wake.refs?.some(ref => ref.type === "message" && ref.taskId === message.taskId && ref.id === message.id));
|
|
38
43
|
}
|
|
39
44
|
export function unfinishedLeaderInbox(store, taskId) {
|
|
40
|
-
|
|
45
|
+
const sessions = store.getTaskRoleSessionSet(taskId, "leader");
|
|
46
|
+
const binding = sessions?.providerBinding;
|
|
47
|
+
const turn = binding?.run;
|
|
48
|
+
const nativeSessionId = binding?.conversations.find(c => c.epoch === binding.currentConversationEpoch)?.conversationId;
|
|
49
|
+
const active = (receipt) => receipt !== undefined && receipt.ack === undefined
|
|
50
|
+
&& (receipt.claim.actor.attemptId === undefined || turn?.attemptId === receipt.claim.actor.attemptId
|
|
51
|
+
&& receipt.claim.actor.agentId === sessions?.activeAgentId
|
|
52
|
+
&& receipt.claim.actor.nativeSessionId === nativeSessionId
|
|
53
|
+
&& ["submitting", "accepted", "delivery-unknown"].includes(turn.status));
|
|
54
|
+
return store.listTaskWakes(taskId).some(wake => active(wake.inbox))
|
|
41
55
|
|| operationalTaskRecords(store.listMessages(taskId), store.listEvents(taskId), "message").some(message => isLeaderSteer(message)
|
|
42
|
-
&& message.inbox
|
|
56
|
+
&& active(message.inbox));
|
|
43
57
|
}
|
|
@@ -157,7 +157,12 @@ export async function inspectWorkspaceCleanup(store, git, workspace, disposition
|
|
|
157
157
|
const observed = (await git.inspect(entry.path, "HEAD")).baseCommit;
|
|
158
158
|
const baseline = task.projectBindings.find(binding => binding.projectId === entry.projectId)?.baseCommit;
|
|
159
159
|
const unchangedAbandonment = disposition === "abandoned" && baseline !== undefined && observed === baseline;
|
|
160
|
-
if (
|
|
160
|
+
if (covered?.deliveryRequirement === "excluded"
|
|
161
|
+
&& (baseline === undefined || observed !== baseline)
|
|
162
|
+
&& (!covered.merged || !covered.verified)) {
|
|
163
|
+
check("excluded-project-retained", "Excluding delivery does not authorize discarding unique or unverified local commits.", { baseline: baseline ?? null, verifiedDelivery: true }, observed);
|
|
164
|
+
}
|
|
165
|
+
else if (!unchangedAbandonment && disposition === "abandoned" && (!covered?.merged || !covered.verified)) {
|
|
161
166
|
check("delivery-coverage", "Abandonment does not discard unverified local commits; only the exact unchanged baseline is disposable.", { baseline: baseline ?? null, verifiedDelivery: true }, observed);
|
|
162
167
|
}
|
|
163
168
|
else if (!unchangedAbandonment && expected != null && expected !== observed && covered?.deliveryLocalCommit !== observed) {
|
|
@@ -133,6 +133,27 @@ const MINOR_UPGRADES = Object.freeze([{
|
|
|
133
133
|
// New Task Job semantic writes also append job.state-changed events; no old
|
|
134
134
|
// Job history is backfilled and the SQL layout remains unchanged.
|
|
135
135
|
sql: ""
|
|
136
|
+
}, {
|
|
137
|
+
fromVersion: "1.11",
|
|
138
|
+
toVersion: "1.12",
|
|
139
|
+
name: "project-delivery-scope",
|
|
140
|
+
introducedIn: "next",
|
|
141
|
+
sourceChecksum: CURRENT_SCHEMA_CHECKSUM,
|
|
142
|
+
targetChecksum: CURRENT_SCHEMA_CHECKSUM,
|
|
143
|
+
// New append-only Project scope decisions use Task events. Preserve all old
|
|
144
|
+
// completion, binding and publication records; absence still means required.
|
|
145
|
+
sql: ""
|
|
146
|
+
}, {
|
|
147
|
+
fromVersion: "1.12",
|
|
148
|
+
toVersion: "1.13",
|
|
149
|
+
name: "inbox-turn-handling",
|
|
150
|
+
introducedIn: "next",
|
|
151
|
+
sourceChecksum: CURRENT_SCHEMA_CHECKSUM,
|
|
152
|
+
targetChecksum: CURRENT_SCHEMA_CHECKSUM,
|
|
153
|
+
// Claims may name their exact current attempt; explicit Global retirement
|
|
154
|
+
// and terminal-check notices preserve original records. Never backfill
|
|
155
|
+
// historical turn ownership, handling evidence or acknowledgements.
|
|
156
|
+
sql: ""
|
|
136
157
|
}]);
|
|
137
158
|
export function storageMinorUpgradePlan(from) {
|
|
138
159
|
if (from === CURRENT_STORAGE_VERSION)
|
|
@@ -989,10 +989,22 @@ export class SqliteTaskStore {
|
|
|
989
989
|
const params = { name: roleName };
|
|
990
990
|
if (query.handling)
|
|
991
991
|
where.push(`json_extract(payload, '$.inbox.claim') IS NOT NULL
|
|
992
|
-
AND json_extract(payload, '$.inbox.ack') IS NULL
|
|
992
|
+
AND json_extract(payload, '$.inbox.ack') IS NULL
|
|
993
|
+
AND json_extract(payload, '$.inbox.retirement') IS NULL`);
|
|
994
|
+
if (query.unclaimed)
|
|
995
|
+
where.push("json_extract(payload, '$.inbox') IS NULL");
|
|
996
|
+
if (query.handlingExceptSession !== undefined) {
|
|
997
|
+
if (!query.handling)
|
|
998
|
+
throw new Error("A handling Session filter requires a handling query.");
|
|
999
|
+
where.push(`(json_extract(payload, '$.inbox.claim.actor.agentId') != @handlingAgent
|
|
1000
|
+
OR json_extract(payload, '$.inbox.claim.actor.nativeSessionId') != @handlingSession)`);
|
|
1001
|
+
params.handlingAgent = query.handlingExceptSession.agentId;
|
|
1002
|
+
params.handlingSession = query.handlingExceptSession.nativeSessionId;
|
|
1003
|
+
}
|
|
993
1004
|
if (query.pending)
|
|
994
1005
|
where.push(`json_extract(payload, '$.delivery') IS NULL
|
|
995
1006
|
AND json_extract(payload, '$.inbox.ack') IS NULL
|
|
1007
|
+
AND json_extract(payload, '$.inbox.retirement') IS NULL
|
|
996
1008
|
AND json_extract(payload, '$.notDelivered') IS NULL
|
|
997
1009
|
AND (json_extract(payload, '$.inputControl.action') = 'queue'
|
|
998
1010
|
OR json_extract(payload, '$.interruptThen') IS NOT NULL)`);
|
|
@@ -76,7 +76,7 @@ export function archiveRetainedResources(store, task) {
|
|
|
76
76
|
return resources;
|
|
77
77
|
}
|
|
78
78
|
export function archiveDeliveryWarnings(delivery) {
|
|
79
|
-
return delivery.projects.filter(p => p.
|
|
79
|
+
return delivery.projects.filter(p => p.deliveryRequired && (!p.merged || !p.verified))
|
|
80
80
|
.map(p => ({ resource: `delivery:${delivery.taskId}/${p.projectId}`,
|
|
81
81
|
detail: `${p.coverage}: ${p.reason} Merged=${p.merged}; verified=${p.verified}.` }));
|
|
82
82
|
}
|
|
@@ -65,7 +65,7 @@ export async function inspectTaskArchive(coordinator, request) {
|
|
|
65
65
|
throw new Error(`Task not found: ${request.taskId}.`);
|
|
66
66
|
const settlement = archiveSettlementChecks(store, task);
|
|
67
67
|
const delivery = await readArchiveDelivery(store, preparer.git, task, request.force);
|
|
68
|
-
const deliveryChecks = delivery.projects.filter(p => p.
|
|
68
|
+
const deliveryChecks = delivery.projects.filter(p => p.deliveryRequired && (!p.merged || !p.verified))
|
|
69
69
|
.map(p => ({ resource: `delivery:${task.id}/${p.projectId}`, reason: "delivery-coverage", status: "blocked",
|
|
70
70
|
detail: p.reason, expected: { merged: true, verified: true, localCommit: p.expectedLocalCommit },
|
|
71
71
|
observed: { coverage: p.coverage, merged: p.merged, verified: p.verified, localCommit: p.publication?.localCommit ?? null },
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/** An append-only Task decision, not a Project permission or a merge fact. */
|
|
2
|
+
export const PROJECT_DELIVERY_DECIDED_EVENT = "task.project-delivery-decided";
|
|
3
|
+
export function projectDeliveryDecision(events, taskId, projectId) {
|
|
4
|
+
return events.filter(event => event.taskId === taskId
|
|
5
|
+
&& event.type === PROJECT_DELIVERY_DECIDED_EVENT && event.payload.projectId === projectId)
|
|
6
|
+
// Task event IDs are monotonic durable sequence numbers; wall clocks can move backwards.
|
|
7
|
+
.sort((a, b) => a.id.localeCompare(b.id, undefined, { numeric: true })).at(-1) ?? null;
|
|
8
|
+
}
|
|
9
|
+
export function projectDeliveryRequirement(decision) {
|
|
10
|
+
if (decision === null)
|
|
11
|
+
return "required";
|
|
12
|
+
const p = decision.payload;
|
|
13
|
+
if (!["required", "excluded"].includes(p.requirement ?? "")
|
|
14
|
+
|| !p.reason?.trim() || !p.sourceMessageId?.trim()
|
|
15
|
+
|| !/^[a-f0-9]{64}$/u.test(p.sourceDigest ?? "") || !p.purpose?.trim()
|
|
16
|
+
|| !["user", "operator", "leader"].includes(p.by ?? "")) {
|
|
17
|
+
throw new Error(`Project delivery decision ${decision.id} has incomplete evidence; preserve it for diagnosis.`);
|
|
18
|
+
}
|
|
19
|
+
return p.requirement;
|
|
20
|
+
}
|
|
@@ -74,12 +74,10 @@ export function assertPublicationToolObservation(publication, observation, now,
|
|
|
74
74
|
throw usageError("Tool observation externalUrl must match the recorded Publication service and object.");
|
|
75
75
|
}
|
|
76
76
|
const url = new URL(canonicalPublicationUrl(observation.externalUrl, publication.provider));
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
// GitLab may be served below a relative root such as /gitlab.
|
|
82
|
-
if (publication.provider === "gitlab" ? !url.pathname.endsWith(path) : url.pathname !== path) {
|
|
77
|
+
// GitLab UI routes are deployment-specific. Structured identity above and
|
|
78
|
+
// the complete registered URL bind the object without inventing a web route.
|
|
79
|
+
if (publication.provider === "github"
|
|
80
|
+
&& url.pathname !== `/${publication.repository.toLowerCase()}/pull/${publication.externalId}`) {
|
|
83
81
|
throw usageError("Tool observation URL does not match the repository and PR/MR identity.");
|
|
84
82
|
}
|
|
85
83
|
const observed = Date.parse(observation.observedAt);
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { publicationAdoption } from "./publicationAdoption.js";
|
|
2
|
+
import { projectDeliveryDecision, projectDeliveryRequirement } from "./projectDeliveryScope.js";
|
|
2
3
|
/**
|
|
3
4
|
* One read-only delivery projection over existing Task, workspace, completion,
|
|
4
5
|
* and Publication facts. It never writes a merged flag or queries a provider.
|
|
@@ -10,6 +11,8 @@ export function projectTaskRemoteDelivery(facts) {
|
|
|
10
11
|
const currentPublications = currentPublicationReferences(facts.publications);
|
|
11
12
|
const projects = facts.task.projectBindings.map((binding) => {
|
|
12
13
|
const expectedLocalCommit = expected.commits.get(binding.projectId) ?? null;
|
|
14
|
+
const scopeDecision = projectDeliveryDecision(facts.events, facts.task.id, binding.projectId);
|
|
15
|
+
const deliveryRequirement = projectDeliveryRequirement(scopeDecision);
|
|
13
16
|
const baseCommit = baseCommits.get(binding.projectId) ?? null;
|
|
14
17
|
const codeDelivery = expectedLocalCommit === null
|
|
15
18
|
? "unknown"
|
|
@@ -44,6 +47,9 @@ export function projectTaskRemoteDelivery(facts) {
|
|
|
44
47
|
deliveryLocalCommit: covers ? publication.localCommit ?? null : null,
|
|
45
48
|
adoption,
|
|
46
49
|
codeDelivery,
|
|
50
|
+
deliveryRequirement,
|
|
51
|
+
scopeDecision,
|
|
52
|
+
deliveryRequired: deliveryRequirement === "required" && codeDelivery !== "none",
|
|
47
53
|
publication,
|
|
48
54
|
state: publication?.state ?? null,
|
|
49
55
|
verification: publication?.verification ?? null,
|
|
@@ -51,23 +57,26 @@ export function projectTaskRemoteDelivery(facts) {
|
|
|
51
57
|
coverage,
|
|
52
58
|
merged,
|
|
53
59
|
verified,
|
|
54
|
-
reason:
|
|
60
|
+
reason: (deliveryRequirement === "excluded"
|
|
61
|
+
? `No delivery obligation: ${scopeDecision.payload.reason} (${scopeDecision.id}). Merge and cleanup facts remain separate. `
|
|
62
|
+
: "") + projectCoverageReason(expectedLocalCommit, publication, coverage, adoption) + (coverage === "stale" && adoptionEvidence?.reason
|
|
55
63
|
? ` ${adoptionEvidence.reason}` : "")
|
|
56
64
|
};
|
|
57
65
|
});
|
|
58
66
|
const codeProjects = projects.filter((project) => project.codeDelivery !== "none");
|
|
59
|
-
const
|
|
60
|
-
const
|
|
61
|
-
const
|
|
62
|
-
const
|
|
63
|
-
const
|
|
67
|
+
const requiredProjects = projects.filter(project => project.deliveryRequired);
|
|
68
|
+
const mergedProjectCount = requiredProjects.filter((project) => project.merged).length;
|
|
69
|
+
const verifiedProjectCount = requiredProjects.filter((project) => project.verified).length;
|
|
70
|
+
const allMerged = requiredProjects.every((project) => project.merged);
|
|
71
|
+
const allVerified = requiredProjects.every((project) => project.verified);
|
|
72
|
+
const status = requiredProjects.length === 0
|
|
64
73
|
? "none"
|
|
65
|
-
:
|
|
74
|
+
: requiredProjects.some((project) => project.coverage === "head-unavailable")
|
|
66
75
|
? "unavailable"
|
|
67
76
|
: allMerged
|
|
68
77
|
? allVerified ? "merged" : "unverified"
|
|
69
78
|
: mergedProjectCount > 0 ? "partial"
|
|
70
|
-
:
|
|
79
|
+
: requiredProjects.some(p => p.state === "merged") ? "uncovered" : "pending";
|
|
71
80
|
return {
|
|
72
81
|
taskId: facts.task.id,
|
|
73
82
|
source: expected.source,
|
|
@@ -81,6 +90,8 @@ export function projectTaskRemoteDelivery(facts) {
|
|
|
81
90
|
allVerified,
|
|
82
91
|
totalProjectCount: projects.length,
|
|
83
92
|
codeProjectCount: codeProjects.length,
|
|
93
|
+
requiredCodeProjectCount: requiredProjects.length,
|
|
94
|
+
excludedProjectCount: projects.filter(project => project.deliveryRequirement === "excluded").length,
|
|
84
95
|
mergedProjectCount,
|
|
85
96
|
verifiedProjectCount,
|
|
86
97
|
integratedCoverageSatisfied: allMerged && allVerified,
|
|
@@ -2,6 +2,7 @@ import { isDeepStrictEqual } from "node:util";
|
|
|
2
2
|
import { usageError } from "../errors/cliError.js";
|
|
3
3
|
import { projectTaskRemoteDelivery } from "../task/remoteDelivery.js";
|
|
4
4
|
import { PUBLICATION_ADOPTED_EVENT } from "./publicationAdoption.js";
|
|
5
|
+
import { PROJECT_DELIVERY_DECIDED_EVENT } from "./projectDeliveryScope.js";
|
|
5
6
|
export function projectTaskRemoteDeliveryFromStore(store, task, currentCandidate = null) {
|
|
6
7
|
return createTaskRemoteDeliveryProof(store, task, currentCandidate).delivery;
|
|
7
8
|
}
|
|
@@ -10,7 +11,7 @@ export function createTaskRemoteDeliveryProof(store, task, currentCandidate = nu
|
|
|
10
11
|
const delivery = projectTaskRemoteDelivery({
|
|
11
12
|
task,
|
|
12
13
|
events: store.listEventsByType(task.id, [
|
|
13
|
-
"task.completed", "task.archived", PUBLICATION_ADOPTED_EVENT
|
|
14
|
+
"task.completed", "task.archived", PUBLICATION_ADOPTED_EVENT, PROJECT_DELIVERY_DECIDED_EVENT
|
|
14
15
|
]),
|
|
15
16
|
publications,
|
|
16
17
|
managedWorkspaces: store.listManagedWorkspaces(task.id),
|
|
@@ -49,13 +50,13 @@ export function assertTaskRemoteDeliveryProof(store, task, proof) {
|
|
|
49
50
|
}
|
|
50
51
|
export function assertTaskRemoteDeliveryIntegrated(delivery) {
|
|
51
52
|
if (!delivery.allMerged) {
|
|
52
|
-
const headUnavailable = delivery.projects.filter((project) => (project.
|
|
53
|
+
const headUnavailable = delivery.projects.filter((project) => (project.deliveryRequired
|
|
53
54
|
&& project.coverage === "head-unavailable"));
|
|
54
55
|
if (headUnavailable.length > 0 && delivery.source === "task-completed") {
|
|
55
56
|
throw usageError(`Task ${delivery.taskId} has no frozen completion heads for: ${headUnavailable.map(({ projectId }) => projectId).join(", ")}. Historical acceptance cannot be inferred from a merge or archive.`);
|
|
56
57
|
}
|
|
57
58
|
const uncovered = delivery.projects
|
|
58
|
-
.filter((project) => project.
|
|
59
|
+
.filter((project) => project.deliveryRequired && !project.merged)
|
|
59
60
|
.map((project) => (`${project.projectId}/${project.coverage}`
|
|
60
61
|
+ (project.publication === null ? "" : `/${project.publication.id}`)))
|
|
61
62
|
.join(", ");
|
|
@@ -65,7 +66,7 @@ export function assertTaskRemoteDeliveryIntegrated(delivery) {
|
|
|
65
66
|
if (delivery.allVerified)
|
|
66
67
|
return;
|
|
67
68
|
const unverified = delivery.projects
|
|
68
|
-
.filter((project) => (project.
|
|
69
|
+
.filter((project) => (project.deliveryRequired
|
|
69
70
|
&& project.merged
|
|
70
71
|
&& !project.verified))
|
|
71
72
|
.map((project) => (`${project.projectId}/${project.coverage}`
|
|
@@ -1052,7 +1052,7 @@ export const EN = {
|
|
|
1052
1052
|
"messageKind.operator": "Operator",
|
|
1053
1053
|
"messageKind.role-result": "Role result",
|
|
1054
1054
|
"messageKind.system": "System",
|
|
1055
|
-
"remoteDelivery.none": "No
|
|
1055
|
+
"remoteDelivery.none": "No delivery obligation",
|
|
1056
1056
|
"remoteDelivery.unavailable": "Exact heads unavailable",
|
|
1057
1057
|
"remoteDelivery.pending": "Not delivered",
|
|
1058
1058
|
"remoteDelivery.uncovered": "Merged, acceptance not covered",
|
|
@@ -1084,7 +1084,9 @@ export const EN = {
|
|
|
1084
1084
|
"delivery.title": "Integration and delivery",
|
|
1085
1085
|
"delivery.hint": "Integration checks, PR/MR and remote merge are separate facts; none of them is acceptance.",
|
|
1086
1086
|
"delivery.readAt": "Evidence read at",
|
|
1087
|
-
"delivery.counts": "{merged}/{total} code projects merged · {verified}/{total} verified",
|
|
1087
|
+
"delivery.counts": "{merged}/{total} required code projects merged · {verified}/{total} verified",
|
|
1088
|
+
"delivery.excluded": "Excluded from delivery",
|
|
1089
|
+
"delivery.scopeDecision": "Scope decision",
|
|
1088
1090
|
"delivery.noProjects": "No project is bound to this Task.",
|
|
1089
1091
|
"evidence.loading": "Reading original evidence…",
|
|
1090
1092
|
"evidence.report": "Original review report",
|
|
@@ -1048,7 +1048,7 @@ export const ZH_CN = {
|
|
|
1048
1048
|
"messageKind.operator": "Operator",
|
|
1049
1049
|
"messageKind.role-result": "Role 结果",
|
|
1050
1050
|
"messageKind.system": "系统",
|
|
1051
|
-
"remoteDelivery.none": "
|
|
1051
|
+
"remoteDelivery.none": "无交付义务",
|
|
1052
1052
|
"remoteDelivery.unavailable": "精确 head 不可用",
|
|
1053
1053
|
"remoteDelivery.pending": "尚未交付",
|
|
1054
1054
|
"remoteDelivery.uncovered": "已合并,未覆盖验收",
|
|
@@ -1080,7 +1080,9 @@ export const ZH_CN = {
|
|
|
1080
1080
|
"delivery.title": "集成与交付",
|
|
1081
1081
|
"delivery.hint": "集成检查、PR/MR 与远程合并是不同事实,都不等于验收。",
|
|
1082
1082
|
"delivery.readAt": "证据读取于",
|
|
1083
|
-
"delivery.counts": "已合入 {merged}/{total}
|
|
1083
|
+
"delivery.counts": "已合入 {merged}/{total} 个应交付代码项目 · 已验证 {verified}/{total}",
|
|
1084
|
+
"delivery.excluded": "已排除交付",
|
|
1085
|
+
"delivery.scopeDecision": "交付范围决定",
|
|
1084
1086
|
"delivery.noProjects": "此任务未绑定项目。",
|
|
1085
1087
|
"evidence.loading": "正在读取原始证据…",
|
|
1086
1088
|
"evidence.report": "原始评审报告",
|
|
@@ -53,9 +53,9 @@ function drawResults(body, scope) {
|
|
|
53
53
|
function drawRemote(body, remote, t) {
|
|
54
54
|
body.append(h("h4.sub-head", null, icon("package", "icon-sm"), h("span", null, t("evidence.remote")),
|
|
55
55
|
badge(t("remoteDelivery." + remote.status, remote.status), DELIVERY_TONES[remote.status] || "idle", { dot: true })));
|
|
56
|
-
if (remote.
|
|
56
|
+
if (remote.requiredCodeProjectCount) {
|
|
57
57
|
body.append(h("p.faint.small", null, fill(t("delivery.counts"), {
|
|
58
|
-
merged: remote.mergedProjectCount, verified: remote.verifiedProjectCount, total: remote.
|
|
58
|
+
merged: remote.mergedProjectCount, verified: remote.verifiedProjectCount, total: remote.requiredCodeProjectCount
|
|
59
59
|
})));
|
|
60
60
|
}
|
|
61
61
|
(remote.projects || []).forEach(function (project) { body.append(projectRow(project, t)); });
|
|
@@ -85,6 +85,7 @@ function projectRow(project, t) {
|
|
|
85
85
|
const href = publication && publication.externalUrl && safeUrl(publication.externalUrl);
|
|
86
86
|
const element = h("article.record.delivery-project");
|
|
87
87
|
element.append(h("header.record-head", null, icon("folder", "icon-sm"), h("strong", null, project.directory || project.projectId),
|
|
88
|
+
project.deliveryRequirement === "excluded" ? chip(t("delivery.excluded")) : null,
|
|
88
89
|
project.coverage ? chip(label(t, "coverage", project.coverage)) : null,
|
|
89
90
|
h("span.spacer"),
|
|
90
91
|
publication ? (href
|
|
@@ -97,6 +98,9 @@ function projectRow(project, t) {
|
|
|
97
98
|
project.state ? chip(label(t, "publicationState", project.state)) : null,
|
|
98
99
|
project.verification ? chip(t("evidence.verification") + " · " + label(t, "verification", project.verification)) : null,
|
|
99
100
|
project.adoption ? h("span", null, t("evidence.adoption") + " ", mono(project.adoption.id)) : null));
|
|
101
|
+
if (project.scopeDecision) element.append(h("p.faint.small", null,
|
|
102
|
+
t("delivery.scopeDecision") + " " + project.scopeDecision.id + " · "
|
|
103
|
+
+ project.scopeDecision.payload.sourceMessageId + " · " + project.scopeDecision.payload.reason));
|
|
100
104
|
return element;
|
|
101
105
|
}
|
|
102
106
|
|