@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
package/dist/controlPlaneCli.js
CHANGED
|
@@ -168,8 +168,6 @@ export async function main() {
|
|
|
168
168
|
const discovery = discoveryCommandTree(process.env);
|
|
169
169
|
invocationHome = home;
|
|
170
170
|
const routedForFence = args.length === 0 ? undefined : routeInvocation(args);
|
|
171
|
-
const managedInvocation = process.env.YUI_SESSION_SCOPE === "task"
|
|
172
|
-
|| process.env.YUI_SESSION_SCOPE === "global";
|
|
173
171
|
const homeFreeInvocation = args.length === 0
|
|
174
172
|
|| (args[0] === "config" && (args[1] === "describe"
|
|
175
173
|
|| (args[1] === "completion" && args[2] === "candidates")))
|
|
@@ -679,29 +677,46 @@ export async function main() {
|
|
|
679
677
|
});
|
|
680
678
|
const workspaceCoordinator = new TaskWorkspaceCoordinator(store, workspacePreparer, runtime);
|
|
681
679
|
if (resolved[0] === "web") {
|
|
682
|
-
if (
|
|
683
|
-
|
|
684
|
-
|
|
680
|
+
if (hasManagedIdentity(process.env)) {
|
|
681
|
+
const current = requireManagedCaller(store, process.env);
|
|
682
|
+
if (current.scope !== "global" || current.roleName !== "operator") {
|
|
683
|
+
throw usageError("Only a local user or the current global Operator may manage Web listeners.");
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
const caller = resolveJobCaller(process.env, "");
|
|
687
|
+
const describe = (status) => status
|
|
688
|
+
? `Web listener: ${status.state}\n`
|
|
689
|
+
+ (status.alreadyRunning ? "Existing listener reused; credentials were not reloaded.\n" : "")
|
|
690
|
+
+ (status.authentication === "basic" ? "Browser authentication: username yui; use the password from your private credential file.\n" : "")
|
|
691
|
+
+ (status.urls ?? []).map(url => `Yui web control room: ${url}\n`).join("")
|
|
692
|
+
+ (status.warnings ?? []).map(warning => `Warning: ${warning}\n`).join("")
|
|
693
|
+
: "Web is not running. Start it explicitly; Controller recovery does not restore listeners.\n";
|
|
685
694
|
if (resolved.length === 2 && (resolved[1] === "--status" || resolved[1] === "--stop")) {
|
|
686
|
-
const status = await callController(home, "web.status", {});
|
|
695
|
+
const status = await callController(home, "web.status", { caller });
|
|
687
696
|
if (resolved[1] === "--status")
|
|
688
|
-
emit(status
|
|
697
|
+
emit(describe(status), false, status);
|
|
689
698
|
else {
|
|
690
|
-
if (status)
|
|
691
|
-
|
|
692
|
-
|
|
699
|
+
if (status && !status.id)
|
|
700
|
+
throw usageError("Web listener is starting; query status before stopping.");
|
|
701
|
+
const result = status ? await callController(home, "web.stop", { id: status.id, caller }) : { stopped: false };
|
|
702
|
+
emit("Web listener stopped or already absent; Controller and Agents are unchanged.\n", false, result);
|
|
693
703
|
}
|
|
694
704
|
return;
|
|
695
705
|
}
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
const options = parseWebCommandOptions(resolved.slice(1));
|
|
706
|
+
const detached = jsonOutput || caller.scope === "global" || resolved.includes("--detach");
|
|
707
|
+
const options = parseWebCommandOptions(resolved.slice(1).filter(arg => arg !== "--detach"));
|
|
699
708
|
const id = randomUUID();
|
|
700
|
-
await callController(home, "web.start", { ...options, id });
|
|
701
|
-
|
|
702
|
-
|
|
709
|
+
const status = await callController(home, "web.start", { ...options, id, caller });
|
|
710
|
+
emit(describe(status), false, status);
|
|
711
|
+
if (detached || status.alreadyRunning)
|
|
712
|
+
return;
|
|
703
713
|
await new Promise((resolve) => {
|
|
714
|
+
// The Controller owns the listening socket, so this CLI otherwise has
|
|
715
|
+
// no referenced handle: a Promise/signal handler alone does not keep
|
|
716
|
+
// Node alive for the foreground user's Ctrl-C.
|
|
717
|
+
const keepAlive = setInterval(() => { }, 60_000);
|
|
704
718
|
const stop = () => {
|
|
719
|
+
clearInterval(keepAlive);
|
|
705
720
|
process.off("SIGINT", stop);
|
|
706
721
|
process.off("SIGTERM", stop);
|
|
707
722
|
resolve();
|
|
@@ -709,7 +724,7 @@ export async function main() {
|
|
|
709
724
|
process.once("SIGINT", stop);
|
|
710
725
|
process.once("SIGTERM", stop);
|
|
711
726
|
});
|
|
712
|
-
await callController(home, "web.stop", { id });
|
|
727
|
+
await callController(home, "web.stop", { id, caller });
|
|
713
728
|
return;
|
|
714
729
|
}
|
|
715
730
|
if (resolved[0] === "config") {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { createHash, randomUUID } from "node:crypto";
|
|
2
2
|
import { isDeepStrictEqual } from "node:util";
|
|
3
|
-
import {
|
|
3
|
+
import { nonReplayableMessageWake, pinMessageRevisions, taskMessageInboxReceipt, unfinishedLeaderInbox } from "../message/taskInboxOwnership.js";
|
|
4
4
|
import { contextSnapshotRef } from "../context/contextSnapshot.js";
|
|
5
5
|
import { freezeRunContextSnapshot } from "../context/runContextPack.js";
|
|
6
6
|
import { taskNotificationPresentation } from "../interaction/notificationPresentation.js";
|
|
@@ -30,6 +30,7 @@ import { SYSTEM_OPERATOR_ROLE } from "../role/systemRoles.js";
|
|
|
30
30
|
import { standardAgentError } from "../runtime/agentError.js";
|
|
31
31
|
import { recordTaskAgentError } from "./taskAgentError.js";
|
|
32
32
|
import { recordGlobalRuntimeAttention, recordGlobalRuntimeFailure, recordGlobalRuntimeRecovery } from "./globalRuntimeAttention.js";
|
|
33
|
+
import { checkInboxTurn } from "./inboxTurnCheck.js";
|
|
33
34
|
import { queueOperatorNotification } from "./operatorNotification.js";
|
|
34
35
|
import { claimOperatorAttention } from "../scheduler/operatorEvent.js";
|
|
35
36
|
import { builtinAgentDriverRegistry } from "../runtime/builtinAgentDrivers.js";
|
|
@@ -466,7 +467,7 @@ export class FileSchedulerStoreAdapter {
|
|
|
466
467
|
const outcome = input.kind === "input.accepted" ? "accepted"
|
|
467
468
|
: input.kind === "input.rejected" ? "rejected" : "delivery-unknown";
|
|
468
469
|
if (message.control?.outcome !== "accepted" && message.control?.outcome !== "rejected") {
|
|
469
|
-
const updated = { ...message, control: { requestId: message.inputControl.requestId,
|
|
470
|
+
const updated = { ...message, control: { ...message.control, requestId: message.inputControl.requestId,
|
|
470
471
|
receiptId: receipt, outcome, observedAt: at } };
|
|
471
472
|
store.updateGlobalRoleMessage(outcome === "accepted"
|
|
472
473
|
? markGlobalRoleMessageDelivered(updated, new Date(at)) : updated);
|
|
@@ -490,6 +491,10 @@ export class FileSchedulerStoreAdapter {
|
|
|
490
491
|
}
|
|
491
492
|
store.saveGlobalRoleSessionSet(updateGlobalRoleProviderRuntime(sessions, binding, now));
|
|
492
493
|
settleGlobalRetryInput(store, fence.roleName, binding, now);
|
|
494
|
+
if (["turn.completed", "turn.failed", "turn.cancelled"].includes(input.kind)) {
|
|
495
|
+
checkInboxTurn(store, { scope: "global", roleName: fence.roleName, agentId: fence.agentId,
|
|
496
|
+
nativeSessionId: fence.nativeSessionId }, binding.run, now);
|
|
497
|
+
}
|
|
493
498
|
if (input.kind === "turn.failed" && matches && input.payload.failure !== undefined
|
|
494
499
|
&& !providerRetryPending(binding)) {
|
|
495
500
|
recordGlobalRuntimeAttention(store, fence.roleName, input.eventId, {
|
|
@@ -1157,8 +1162,7 @@ export class FileSchedulerStoreAdapter {
|
|
|
1157
1162
|
throw new AgentHostProviderSessionBusyError("The next Global input belongs to an explicit then handoff.");
|
|
1158
1163
|
}
|
|
1159
1164
|
if (message !== undefined) {
|
|
1160
|
-
if (
|
|
1161
|
-
|| message.inbox !== undefined || message.delivery !== undefined || message.notDelivered !== undefined
|
|
1165
|
+
if (message.inbox !== undefined || message.delivery !== undefined || message.notDelivered !== undefined
|
|
1162
1166
|
|| message.control !== undefined && message.control.outcome !== "rejected"
|
|
1163
1167
|
|| message.deliveryTarget?.agentId !== input.agentId
|
|
1164
1168
|
|| message.deliveryTarget?.nativeSessionId !== input.nativeSessionId) {
|
|
@@ -1687,12 +1691,24 @@ export class FileSchedulerStoreAdapter {
|
|
|
1687
1691
|
store.saveTaskRoleSessionSet(selectNewTaskRoleSession(set, set.activeAgentId, now));
|
|
1688
1692
|
const event = createTaskEvent(store.nextEventId(owner.taskId), owner.taskId, "runtime.session-replaced", { roleName: owner.roleName }, now);
|
|
1689
1693
|
store.saveEvent(owner.taskId, event);
|
|
1690
|
-
//
|
|
1691
|
-
//
|
|
1694
|
+
// Release only operational occupancy after exact runtime stop. Neither
|
|
1695
|
+
// an uncertain automatic submission nor an Agent's handling claim may
|
|
1696
|
+
// be replayed as input to the replacement.
|
|
1692
1697
|
const leaderTarget = { kind: "role", taskId: owner.taskId, roleName: "leader" };
|
|
1693
1698
|
const leaderMailbox = store.getWorkMailbox(leaderTarget);
|
|
1694
|
-
if (owner.roleName === "leader" && leaderMailbox?.processing
|
|
1695
|
-
|
|
1699
|
+
if (owner.roleName === "leader" && leaderMailbox?.processing != null) {
|
|
1700
|
+
const processing = leaderMailbox.processing;
|
|
1701
|
+
if (processing.owner.startsWith("leader-notification:")) {
|
|
1702
|
+
store.saveEvent(owner.taskId, createTaskEvent(store.nextEventId(owner.taskId), owner.taskId, "notification.resolved", {
|
|
1703
|
+
wakeId: processing.owner.slice("leader-notification:".length), attemptId: processing.batchId,
|
|
1704
|
+
reason: "Session replacement after verified native/resource stop; inspect original effects.",
|
|
1705
|
+
outcome: "released-without-replay"
|
|
1706
|
+
}, now));
|
|
1707
|
+
store.saveWorkMailbox(completeProcessing(leaderMailbox, processing.batchId));
|
|
1708
|
+
}
|
|
1709
|
+
else if (processing.owner.startsWith("agent-inbox:")) {
|
|
1710
|
+
store.saveWorkMailbox(completeProcessing(leaderMailbox, processing.batchId));
|
|
1711
|
+
}
|
|
1696
1712
|
}
|
|
1697
1713
|
enqueueWork(store, leaderTarget, "session-replaced", now, [{ type: "event", taskId: owner.taskId, id: event.id }]);
|
|
1698
1714
|
}
|
|
@@ -1839,6 +1855,24 @@ export class FileSchedulerStoreAdapter {
|
|
|
1839
1855
|
if (mailbox === null)
|
|
1840
1856
|
return null;
|
|
1841
1857
|
const provider = store.getTaskRoleSessionSet(taskId, "leader")?.providerBinding?.run;
|
|
1858
|
+
// Only a committed lifecycle event opens this initialization path, not a
|
|
1859
|
+
// caller-supplied reason string. Consume that event once, separately from
|
|
1860
|
+
// business input. It exists only after exact-owner cleanup completed.
|
|
1861
|
+
const recoveryRefs = mailbox.pending?.reasons.includes("session-replaced")
|
|
1862
|
+
? mailbox.pending.refs.filter(ref => {
|
|
1863
|
+
if (ref.type !== "event" || ref.taskId !== taskId)
|
|
1864
|
+
return false;
|
|
1865
|
+
const event = store.queryContextRecords(taskId, { family: "task-event", ids: [ref.id],
|
|
1866
|
+
eventTypes: ["runtime.session-replaced"], limit: 1 }).records[0]?.value;
|
|
1867
|
+
return event?.payload.roleName === "leader";
|
|
1868
|
+
}) : [];
|
|
1869
|
+
// Valid earlier storage may retain an actively claimed mailbox even
|
|
1870
|
+
// after replacement completed. Its durable wake/claim remains untouched.
|
|
1871
|
+
if (recoveryRefs.length > 0 && mailbox.processing?.owner.startsWith("agent-inbox:")
|
|
1872
|
+
&& !(provider && ["submitting", "accepted", "delivery-unknown"].includes(provider.status))) {
|
|
1873
|
+
mailbox = completeProcessing(mailbox, mailbox.processing.batchId);
|
|
1874
|
+
store.saveWorkMailbox(mailbox);
|
|
1875
|
+
}
|
|
1842
1876
|
const processing = mailbox.processing;
|
|
1843
1877
|
if (processing !== null) {
|
|
1844
1878
|
if (!processing.owner.startsWith("leader-notification:"))
|
|
@@ -1882,7 +1916,7 @@ export class FileSchedulerStoreAdapter {
|
|
|
1882
1916
|
return { wakeId, attemptId: nextAttempt, disposition: "submit",
|
|
1883
1917
|
presentation: taskNotificationPresentation(wake, store) };
|
|
1884
1918
|
}
|
|
1885
|
-
if (unfinishedLeaderInbox(store, taskId))
|
|
1919
|
+
if (unfinishedLeaderInbox(store, taskId) && recoveryRefs.length === 0)
|
|
1886
1920
|
return null;
|
|
1887
1921
|
if (mailbox.pending === null || (provider !== null && provider !== undefined
|
|
1888
1922
|
&& ["submitting", "accepted", "delivery-unknown"].includes(provider.status)))
|
|
@@ -1895,7 +1929,7 @@ export class FileSchedulerStoreAdapter {
|
|
|
1895
1929
|
const obsoleteIds = new Set();
|
|
1896
1930
|
for (const message of store.listMessages(taskId)) {
|
|
1897
1931
|
if (pendingMessageIds.has(message.id) && (taskMessageInboxReceipt(store, message) !== undefined
|
|
1898
|
-
||
|
|
1932
|
+
|| nonReplayableMessageWake(store, message) !== undefined)) {
|
|
1899
1933
|
obsoleteIds.add(message.id);
|
|
1900
1934
|
}
|
|
1901
1935
|
}
|
|
@@ -1930,21 +1964,23 @@ export class FileSchedulerStoreAdapter {
|
|
|
1930
1964
|
const handoffs = claimedMessages.filter(message => !obsoleteIds.has(message.id));
|
|
1931
1965
|
// A quiet/new Session is not evidence that the claimed old Turn stopped.
|
|
1932
1966
|
// Keep the exact pending intent until the original fence is provable.
|
|
1933
|
-
if (
|
|
1967
|
+
if (recoveryRefs.length === 0
|
|
1968
|
+
&& handoffs.some((message) => interruptThenTerminalState(store, message) !== "ready"))
|
|
1934
1969
|
return null;
|
|
1935
1970
|
const handoffIds = new Set(handoffs.map((message) => message.id));
|
|
1936
1971
|
if (availableRefs.length === 0 && obsoleteIds.size > 0) {
|
|
1937
1972
|
store.saveWorkMailbox(consumePendingBatch(mailbox, pending));
|
|
1938
1973
|
return null;
|
|
1939
1974
|
}
|
|
1940
|
-
const selectedRefs = handoffs.length === 0 ? availableRefs
|
|
1975
|
+
const selectedRefs = recoveryRefs.length > 0 ? recoveryRefs : handoffs.length === 0 ? availableRefs
|
|
1941
1976
|
: availableRefs.filter((ref) => ref.type === "message" && handoffIds.has(ref.id));
|
|
1942
|
-
const remainingRefs =
|
|
1943
|
-
:
|
|
1977
|
+
const remainingRefs = recoveryRefs.length > 0 ? availableRefs.filter(ref => !recoveryRefs.includes(ref))
|
|
1978
|
+
: handoffs.length === 0 ? []
|
|
1979
|
+
: availableRefs.filter((ref) => !(ref.type === "message" && handoffIds.has(ref.id)));
|
|
1944
1980
|
const latest = latestTaskWake(store.listTaskWakes(taskId));
|
|
1945
1981
|
const wakeId = store.nextTaskWakeId(taskId);
|
|
1946
1982
|
const wake = createTaskWake({
|
|
1947
|
-
id: wakeId, taskId, reasons: mailbox.pending.reasons,
|
|
1983
|
+
id: wakeId, taskId, reasons: recoveryRefs.length > 0 ? ["session-replaced"] : mailbox.pending.reasons,
|
|
1948
1984
|
refs: pinMessageRevisions(store, taskId, selectedRefs),
|
|
1949
1985
|
fromCursor: latest?.toCursor ?? task.createdAt,
|
|
1950
1986
|
toCursor: new Date(Math.max(now.getTime(), Date.parse(mailbox.pending.lastQueuedAt))).toISOString(), now
|
|
@@ -2231,6 +2267,8 @@ export class FileSchedulerStoreAdapter {
|
|
|
2231
2267
|
}
|
|
2232
2268
|
sessions = settleStructuredProviderTurn(sessions, canonicalRunId, input.providerStatus, now, attemptId);
|
|
2233
2269
|
store.saveTaskRoleSessionSet(sessions);
|
|
2270
|
+
checkInboxTurn(store, { scope: "task", taskId: input.taskId, roleName: input.roleName,
|
|
2271
|
+
agentId: input.agentId, nativeSessionId: input.nativeSessionId }, sessions.providerBinding?.run, now);
|
|
2234
2272
|
}
|
|
2235
2273
|
if (input.observation !== undefined)
|
|
2236
2274
|
this.persistRuntimeObservation(input.observation, now);
|
|
@@ -2336,6 +2374,8 @@ export class FileSchedulerStoreAdapter {
|
|
|
2336
2374
|
}
|
|
2337
2375
|
if (input.observation !== undefined)
|
|
2338
2376
|
this.persistRuntimeObservation(input.observation, now);
|
|
2377
|
+
checkInboxTurn(store, { scope: "task", taskId: input.taskId, roleName: input.roleName,
|
|
2378
|
+
agentId: input.agentId, nativeSessionId: input.nativeSessionId }, sessions.providerBinding?.run, now);
|
|
2339
2379
|
return {
|
|
2340
2380
|
session: sessions.sessions[input.agentId],
|
|
2341
2381
|
duplicate: false,
|
|
@@ -2822,6 +2862,10 @@ export class FileSchedulerStoreAdapter {
|
|
|
2822
2862
|
// durable native terminal recorded just above remains the proof of record.
|
|
2823
2863
|
current = settleGlobalRoleRuntimeTurn(current, input.nativeTurnId, input.providerStatus, now);
|
|
2824
2864
|
store.saveGlobalRoleSessionSet(current);
|
|
2865
|
+
if (current.providerBinding?.run?.nativeTurnId === input.nativeTurnId) {
|
|
2866
|
+
checkInboxTurn(store, { scope: "global", roleName: input.roleName,
|
|
2867
|
+
agentId: input.agentId, nativeSessionId }, current.providerBinding.run, now);
|
|
2868
|
+
}
|
|
2825
2869
|
if (input.roleName === SYSTEM_OPERATOR_ROLE
|
|
2826
2870
|
&& input.adapterId === "codex"
|
|
2827
2871
|
&& completedStatus === "active") {
|
|
@@ -6,11 +6,12 @@ import { providerRetryPending } from "../runtime/providerRetry.js";
|
|
|
6
6
|
import { settleGlobalRetryInput } from "../message/globalProviderRetry.js";
|
|
7
7
|
import { globalMessagePresentation } from "../interaction/notificationPresentation.js";
|
|
8
8
|
import { recordGlobalRuntimeAttention, recordGlobalRuntimeRecovery } from "./globalRuntimeAttention.js";
|
|
9
|
+
import { globalInboxRecovery } from "../message/globalInboxRecovery.js";
|
|
9
10
|
/** Messages are the only queue. Host's atomic begin arbitrates native writers. */
|
|
10
11
|
export async function deliverGlobalInputs(home, store, ensure, onError, options = {}) {
|
|
11
12
|
for (const role of store.listGlobalRoles()) {
|
|
12
13
|
try {
|
|
13
|
-
let pending = store.queryGlobalRoleMessages(role.name, { pending: true, limit: 64 }).messages;
|
|
14
|
+
let pending = store.queryGlobalRoleMessages(role.name, { pending: true, unclaimed: true, limit: 64 }).messages;
|
|
14
15
|
if (hasRuntimeCleanupObligation(store.getWorkMailbox(runtimeLifecycleTarget({ scope: "global", roleName: role.name }))))
|
|
15
16
|
continue;
|
|
16
17
|
let sessions = store.getGlobalRoleSessionSet(role.name);
|
|
@@ -20,7 +21,7 @@ export async function deliverGlobalInputs(home, store, ensure, onError, options
|
|
|
20
21
|
continue;
|
|
21
22
|
if (sessions?.providerBinding?.retry !== undefined && !providerRetryPending(sessions.providerBinding)) {
|
|
22
23
|
store.transaction(tx => settleGlobalRetryInput(tx, role.name, tx.getGlobalRoleSessionSet(role.name)?.providerBinding, new Date()));
|
|
23
|
-
pending = store.queryGlobalRoleMessages(role.name, { pending: true, limit: 64 }).messages;
|
|
24
|
+
pending = store.queryGlobalRoleMessages(role.name, { pending: true, unclaimed: true, limit: 64 }).messages;
|
|
24
25
|
if (pending.length === 0)
|
|
25
26
|
continue;
|
|
26
27
|
}
|
|
@@ -84,12 +85,13 @@ export async function deliverGlobalInputs(home, store, ensure, onError, options
|
|
|
84
85
|
|| providerRetryPending(sessions?.providerBinding)
|
|
85
86
|
|| hasRuntimeCleanupObligation(store.getWorkMailbox(runtimeLifecycleTarget({ scope: "global", roleName: role.name }))))
|
|
86
87
|
continue;
|
|
87
|
-
pending = store.queryGlobalRoleMessages(role.name, { pending: true, limit: 64 }).messages;
|
|
88
|
+
pending = store.queryGlobalRoleMessages(role.name, { pending: true, unclaimed: true, limit: 64 }).messages;
|
|
88
89
|
if (pending.length === 0)
|
|
89
90
|
continue;
|
|
90
91
|
}
|
|
91
|
-
|
|
92
|
-
|
|
92
|
+
const recovery = globalInboxRecovery(store, role.name, options.now ?? new Date());
|
|
93
|
+
const recovering = recovery !== undefined && recovery.inbox === undefined && recovery.delivery === undefined
|
|
94
|
+
&& recovery.notDelivered === undefined && recovery.control === undefined;
|
|
93
95
|
// Interrupt-then reserves the next legal slot even behind a large queue.
|
|
94
96
|
const reservedPage = store.queryGlobalRoleMessages(role.name, {
|
|
95
97
|
pending: true, interruptThen: true, limit: 64
|
|
@@ -106,7 +108,7 @@ export async function deliverGlobalInputs(home, store, ensure, onError, options
|
|
|
106
108
|
store.updateGlobalRoleMessage(markGlobalRoleMessageNotDelivered(message, "native-session-or-authority-changed", new Date()));
|
|
107
109
|
const obsoleteIds = new Set(obsolete.map(message => message.id));
|
|
108
110
|
pending = pending.filter(message => !obsoleteIds.has(message.id));
|
|
109
|
-
if (pending.length === 0)
|
|
111
|
+
if (pending.length === 0 && !recovering)
|
|
110
112
|
continue;
|
|
111
113
|
if (reservedPage.count > reserved.length && obsolete.length === reserved.length)
|
|
112
114
|
continue;
|
|
@@ -117,7 +119,15 @@ export async function deliverGlobalInputs(home, store, ensure, onError, options
|
|
|
117
119
|
&& sessions.sessions[message.deliveryTarget.agentId]?.nativeSessionId === message.deliveryTarget.nativeSessionId;
|
|
118
120
|
const claims = reserved.filter(message => !obsolete.includes(message));
|
|
119
121
|
let next;
|
|
120
|
-
if (
|
|
122
|
+
if (recovering) {
|
|
123
|
+
// The notice carries no original business input. An obsolete then
|
|
124
|
+
// target is never inherited; a current explicit reservation still
|
|
125
|
+
// owns its slot. Host admission checks this again atomically.
|
|
126
|
+
if (claims.length > 0)
|
|
127
|
+
continue;
|
|
128
|
+
next = recovery;
|
|
129
|
+
}
|
|
130
|
+
else if (claims.length > 0) {
|
|
121
131
|
next = claims.find(message => {
|
|
122
132
|
const claim = message.interruptThen;
|
|
123
133
|
const binding = sessions?.providerBinding;
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { createGlobalRoleMessage } from "../message/message.js";
|
|
2
|
+
import { INBOX_CHECK_PREFIX, INBOX_CHECK_REASON, missingTurnHandling } from "../message/inboxTurn.js";
|
|
3
|
+
import { readDigest } from "../output/boundedRead.js";
|
|
4
|
+
import { createTaskEvent } from "../event/taskEvent.js";
|
|
5
|
+
import { enqueueWork } from "../coordination/workMailboxQueue.js";
|
|
6
|
+
import { retiredTaskRecordIds } from "../task/taskRecordRetirement.js";
|
|
7
|
+
import { completeProcessing } from "../coordination/workMailbox.js";
|
|
8
|
+
/** Called inside exact-terminal persistence's transaction. A terminal checks
|
|
9
|
+
* receipts; it cannot infer a disposition. One existing notification per exact
|
|
10
|
+
* turn, no timer, retry queue, automatic ACK or reminder-of-reminder loop. */
|
|
11
|
+
export function checkInboxTurn(store, actor, turn, now) {
|
|
12
|
+
if (!turn || !["completed", "failed", "cancelled"].includes(turn.status))
|
|
13
|
+
return;
|
|
14
|
+
const retired = actor.scope === "task" ? retiredTaskRecordIds(store.listEvents(actor.taskId), "message") : new Set();
|
|
15
|
+
const entries = actor.scope === "global" ? store.listGlobalRoleMessages(actor.roleName)
|
|
16
|
+
: [...(actor.roleName === "leader" ? store.listTaskWakes(actor.taskId) : []),
|
|
17
|
+
...store.listMessages(actor.taskId).filter(message => !retired.has(message.id))];
|
|
18
|
+
const missing = missingTurnHandling(entries, actor, turn);
|
|
19
|
+
if (actor.scope === "task" && actor.roleName === "leader") {
|
|
20
|
+
const mailbox = store.getWorkMailbox({ kind: "role", taskId: actor.taskId, roleName: "leader" });
|
|
21
|
+
// End the exact active-consumption notification reservation only. Its
|
|
22
|
+
// original claim remains unfinished and discoverable; no writer is freed
|
|
23
|
+
// here (the caller already persisted an exact native terminal).
|
|
24
|
+
if (mailbox?.processing && entries.some(entry => entry.inbox?.claim.actor.attemptId === turn.attemptId
|
|
25
|
+
&& entry.inbox.claim.actor.nativeSessionId === actor.nativeSessionId
|
|
26
|
+
&& entry.inbox.claim.actor.agentId === actor.agentId
|
|
27
|
+
&& mailbox.processing?.owner === `agent-inbox:${entry.id}`
|
|
28
|
+
&& mailbox.processing.batchId === `inbox:${entry.id}`)) {
|
|
29
|
+
store.saveWorkMailbox(completeProcessing(mailbox, mailbox.processing.batchId));
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
if (!missing.length)
|
|
33
|
+
return;
|
|
34
|
+
const requestId = INBOX_CHECK_PREFIX + readDigest([actor.scope, actor.taskId, actor.roleName,
|
|
35
|
+
actor.agentId, actor.nativeSessionId, turn.attemptId]);
|
|
36
|
+
const facts = {
|
|
37
|
+
roleName: actor.roleName, nativeSessionId: actor.nativeSessionId, attemptId: turn.attemptId,
|
|
38
|
+
status: turn.status, count: String(missing.length), entries: missing.slice(0, 16).map(entry => entry.id).join(","),
|
|
39
|
+
read: actor.scope === "global" || actor.roleName === "leader" ? "yui inbox list --view history --json"
|
|
40
|
+
: `yui task message show ${actor.taskId}/${missing[0].id} --json`,
|
|
41
|
+
disposition: "Missing handling evidence, not unexecuted work. Read originals and prior effects; explicitly ack or retire within authority. Never replay external effects."
|
|
42
|
+
};
|
|
43
|
+
if (actor.scope === "global") {
|
|
44
|
+
if (store.queryGlobalRoleMessages(actor.roleName, { requestId, limit: 1 }).count)
|
|
45
|
+
return;
|
|
46
|
+
const notice = createGlobalRoleMessage(store.nextGlobalRoleMessageId(), actor.roleName, `Yui-Summary: ${missing.length} input(s) lack handling receipts after an exact native terminal.\n`
|
|
47
|
+
+ JSON.stringify(facts), "system", { type: "system" }, now, { inputControl: { action: "queue", requestId } });
|
|
48
|
+
// Retain the original target: replacing a Session is not permission to
|
|
49
|
+
// replay this reminder elsewhere. Successors can inspect history explicitly.
|
|
50
|
+
store.saveGlobalRoleMessage({ ...notice,
|
|
51
|
+
deliveryTarget: { agentId: actor.agentId, nativeSessionId: actor.nativeSessionId } });
|
|
52
|
+
}
|
|
53
|
+
else {
|
|
54
|
+
if (store.listEvents(actor.taskId).some(event => event.type === INBOX_CHECK_REASON
|
|
55
|
+
&& event.payload.requestId === requestId))
|
|
56
|
+
return;
|
|
57
|
+
const event = createTaskEvent(store.nextEventId(actor.taskId), actor.taskId, INBOX_CHECK_REASON, { ...facts, requestId }, now);
|
|
58
|
+
store.saveEvent(actor.taskId, event);
|
|
59
|
+
const task = store.getTask(actor.taskId);
|
|
60
|
+
if (task && ["active", "draft"].includes(task.status) && task.executionGate.state === "enabled") {
|
|
61
|
+
enqueueWork(store, { kind: "role", taskId: actor.taskId, roleName: "leader" }, INBOX_CHECK_REASON, now, [{ type: "event", taskId: actor.taskId, id: event.id }], { source: "inbox-check", dedupeKey: requestId });
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
@@ -2,6 +2,7 @@ import { admitProviderRetry, cancelProviderRetry, providerRetryAttemptId, provid
|
|
|
2
2
|
import { blockingProviderContinuations } from "../runtime/runtimeContinuationProjection.js";
|
|
3
3
|
import { validateExactRunReviewRound } from "../lifecycle/exactRunTerminalization.js";
|
|
4
4
|
import { isDeepStrictEqual } from "node:util";
|
|
5
|
+
import { isHandlingReminder } from "../message/inboxTurn.js";
|
|
5
6
|
export function ownedProviderInput(input) {
|
|
6
7
|
if (input.runId !== undefined && input.taskId !== undefined) {
|
|
7
8
|
return { kind: "run", taskId: input.taskId, runId: input.runId };
|
|
@@ -26,6 +27,7 @@ export function retryIntentBlocker(store, input, retry) {
|
|
|
26
27
|
return "role-configuration-changed";
|
|
27
28
|
if (store.listGlobalRoleMessages(input.roleName).some(message => (message.kind === "user" || message.kind === "operator"
|
|
28
29
|
|| message.inputControl !== undefined || message.interruptThen !== undefined)
|
|
30
|
+
&& !isHandlingReminder(message)
|
|
29
31
|
&& Date.parse(message.createdAt) > Date.parse(retry.intentAt)
|
|
30
32
|
&& message.delivery === undefined && message.notDelivered === undefined))
|
|
31
33
|
return "new-user-input";
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { createExecFileCommandRunner, createPinnedCommandRunner } from "./pinnedCommandRunner.js";
|
|
2
2
|
import { redactLaunchText } from "../runtime/launchDiagnostics.js";
|
|
3
|
+
import { canonicalPublicationUrl } from "../task/publicationToolObservation.js";
|
|
3
4
|
const GIT_COMMIT = /^[0-9a-f]{40}$/iu;
|
|
4
5
|
const GITLAB_REPOSITORY = /^[A-Za-z0-9_.-]+(?:\/[A-Za-z0-9_.-]+)+$/u;
|
|
5
6
|
const MAX_FAILURE_DETAIL_CHARS = 4_000;
|
|
@@ -64,8 +65,13 @@ export function parseGitLabMergeRequest(stdout, repository, externalId, expected
|
|
|
64
65
|
}
|
|
65
66
|
const externalUrl = optionalText(record.web_url);
|
|
66
67
|
assertGitLabReference(record.references, repository, externalId);
|
|
67
|
-
if (externalUrl !== undefined
|
|
68
|
-
|
|
68
|
+
if (externalUrl !== undefined) {
|
|
69
|
+
const url = new URL(canonicalPublicationUrl(externalUrl));
|
|
70
|
+
// references.full and iid establish identity; web_url is a browser link,
|
|
71
|
+
// not a repository/MR path schema. The command also checks the candidate URL.
|
|
72
|
+
if (expectedHost !== undefined && url.host.toLowerCase() !== expectedHost.toLowerCase()) {
|
|
73
|
+
throw new Error(`GitLab MR web URL does not match service ${expectedHost}.`);
|
|
74
|
+
}
|
|
69
75
|
}
|
|
70
76
|
const mergedAt = optionalText(record.merged_at);
|
|
71
77
|
return {
|
|
@@ -103,21 +109,6 @@ function assertGitLabReference(value, repository, externalId) {
|
|
|
103
109
|
+ `${repository}!${externalId}.`);
|
|
104
110
|
}
|
|
105
111
|
}
|
|
106
|
-
function assertGitLabWebUrl(value, expectedHost, repository, externalId) {
|
|
107
|
-
let url;
|
|
108
|
-
try {
|
|
109
|
-
url = new URL(value);
|
|
110
|
-
}
|
|
111
|
-
catch {
|
|
112
|
-
throw new Error("GitLab MR web URL is invalid.");
|
|
113
|
-
}
|
|
114
|
-
const expectedSuffix = `/${repository}/-/merge_requests/${externalId}`;
|
|
115
|
-
if ((url.protocol !== "https:" && url.protocol !== "http:")
|
|
116
|
-
|| url.host.toLowerCase() !== expectedHost.toLowerCase()
|
|
117
|
-
|| !url.pathname.endsWith(expectedSuffix)) {
|
|
118
|
-
throw new Error(`GitLab MR web URL does not match ${expectedHost}/${repository}!${externalId}.`);
|
|
119
|
-
}
|
|
120
|
-
}
|
|
121
112
|
function firstCommit(...values) {
|
|
122
113
|
for (const value of values) {
|
|
123
114
|
if (value === null || value === undefined || value === "")
|
|
@@ -140,17 +131,8 @@ function optionalText(value) {
|
|
|
140
131
|
function gitLabHostname(externalUrl) {
|
|
141
132
|
if (externalUrl === undefined)
|
|
142
133
|
return undefined;
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
url = new URL(externalUrl);
|
|
146
|
-
}
|
|
147
|
-
catch {
|
|
148
|
-
throw new Error(`GitLab external URL is invalid: ${externalUrl}.`);
|
|
149
|
-
}
|
|
150
|
-
if (url.protocol !== "https:" && url.protocol !== "http:") {
|
|
151
|
-
throw new Error(`GitLab external URL protocol is invalid: ${url.protocol}.`);
|
|
152
|
-
}
|
|
153
|
-
return url.hostname === "gitlab.com" ? undefined : url.host;
|
|
134
|
+
const url = new URL(canonicalPublicationUrl(externalUrl));
|
|
135
|
+
return url.host === "gitlab.com" ? undefined : url.host;
|
|
154
136
|
}
|
|
155
137
|
function boundedFailureDetail(value) {
|
|
156
138
|
return value.length <= MAX_FAILURE_DETAIL_CHARS
|
|
@@ -13,6 +13,12 @@ export function taskAuthorizationSource(store, taskId, environment, messageId, p
|
|
|
13
13
|
|| task.executionGate?.state === "stopped") {
|
|
14
14
|
throw usageError("Task authorization is unavailable in this lifecycle or execution gate.");
|
|
15
15
|
}
|
|
16
|
+
return { ...readTaskAuthorizationMessage(store, taskId, messageId, purpose),
|
|
17
|
+
nativeSessionId: caller.nativeSessionId };
|
|
18
|
+
}
|
|
19
|
+
/** Shared source binding; interpreting the quoted authority remains the caller's
|
|
20
|
+
* responsibility. A matching quote proves provenance, not semantic permission. */
|
|
21
|
+
export function readTaskAuthorizationMessage(store, taskId, messageId, purpose) {
|
|
16
22
|
const message = store.listMessages(taskId).find(entry => entry.id === messageId);
|
|
17
23
|
if (!message || !((message.kind === "user" && message.author.type === "user")
|
|
18
24
|
|| (message.kind === "operator" && message.author.type === "operator"))) {
|
|
@@ -29,8 +35,7 @@ export function taskAuthorizationSource(store, taskId, environment, messageId, p
|
|
|
29
35
|
taskId: message.taskId, id: message.id, kind: message.kind,
|
|
30
36
|
author: message.author, body: message.body, createdAt: message.createdAt
|
|
31
37
|
})).digest("hex"),
|
|
32
|
-
purpose: purpose.trim()
|
|
33
|
-
nativeSessionId: caller.nativeSessionId
|
|
38
|
+
purpose: purpose.trim()
|
|
34
39
|
};
|
|
35
40
|
}
|
|
36
41
|
const RELEASE_ACTIONS = new Set([
|
|
@@ -69,6 +69,13 @@ export function taskNotificationPresentation(wake, store) {
|
|
|
69
69
|
...(messages.length > 4 ? [`${messages.length - 4} more messages in the fixed wake.`] : []),
|
|
70
70
|
`Refresh: yui task context ${wake.taskId} --json`,
|
|
71
71
|
`Read inputs/results: yui task wake show ${wake.taskId} ${wake.id} --json`,
|
|
72
|
+
`Handling: claim exact inbox entry ${wake.id}; after disposition, ack it with a concrete reason before ending this turn.`,
|
|
73
|
+
"Use yui inbox list --view current --json for current inputs. Historical missing receipts are not new work; never replay effects to repair an ACK.",
|
|
74
|
+
...(wake.reasons.includes("session-replaced") ? [
|
|
75
|
+
"Session replacement is initialization, not replay or completion of earlier input.",
|
|
76
|
+
"Read yui inbox list --view history --json; inspect retained originals, prior effects and current authority before explicit settlement.",
|
|
77
|
+
"Preserve original claims and delivery targets. Unknown external effects require evidence, not another execution."
|
|
78
|
+
] : []),
|
|
72
79
|
"Check current Skills each turn: yui session skills --json. Apply current yui-leader; read relevant originals in full and advance authorized work."
|
|
73
80
|
].join("\n");
|
|
74
81
|
}
|
|
@@ -80,6 +87,8 @@ export function globalMessagePresentation(roleName, messageId, message) {
|
|
|
80
87
|
...(summary === undefined ? [] : [`Author summary (message-body): ${JSON.stringify(summary)}`]),
|
|
81
88
|
"Refresh Session Context using the current Manifest's loadCommand.",
|
|
82
89
|
`Read original: yui role message show ${roleName} ${messageId} --json`,
|
|
90
|
+
`Handling: claim exact inbox entry ${messageId}; after disposition, ack it with a concrete reason before ending this turn.`,
|
|
91
|
+
"Routing to a Task or asking a necessary user question can settle this input without completing the Task. Missing ACK is not permission to repeat effects.",
|
|
83
92
|
`The Global loadCommand checks current Skill versions each turn. Apply current ${roleName === "operator" ? "yui-operator" : "yui-runtime"} within existing authority.`
|
|
84
93
|
].join("\n");
|
|
85
94
|
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { createGlobalRoleMessage } from "./message.js";
|
|
3
|
+
import { hasRuntimeCleanupObligation, runtimeLifecycleTarget } from "../runtime/lifecycleReservation.js";
|
|
4
|
+
import { currentProviderConversation } from "../runtime/providerRuntimeIdentity.js";
|
|
5
|
+
import { INBOX_CHECK_PREFIX } from "./inboxTurn.js";
|
|
6
|
+
const requestId = (agentId, nativeSessionId) => `${INBOX_CHECK_PREFIX}recovery:${createHash("sha256").update(JSON.stringify([agentId, nativeSessionId])).digest("hex")}`;
|
|
7
|
+
/** Derive a single initialization notice from existing retained claims. New
|
|
8
|
+
* Sessions are created only by the existing authorized lifecycle path. A
|
|
9
|
+
* restart can finish notice creation, but cannot select or replace a Session. */
|
|
10
|
+
export function globalInboxRecovery(store, roleName, now) {
|
|
11
|
+
return store.transaction(tx => {
|
|
12
|
+
const sessions = tx.getGlobalRoleSessionSet(roleName);
|
|
13
|
+
const session = sessions?.sessions[sessions.activeAgentId];
|
|
14
|
+
const binding = sessions?.providerBinding;
|
|
15
|
+
if (!session || session.status !== "active" || !session.nativeSessionId || !binding
|
|
16
|
+
|| binding.authority.owner !== "controller"
|
|
17
|
+
|| currentProviderConversation(binding).conversationId !== session.nativeSessionId
|
|
18
|
+
|| binding.run && ["submitting", "accepted", "delivery-unknown"].includes(binding.run.status)
|
|
19
|
+
|| hasRuntimeCleanupObligation(tx.getWorkMailbox(runtimeLifecycleTarget({ scope: "global", roleName }))))
|
|
20
|
+
return;
|
|
21
|
+
if (tx.queryGlobalRoleMessages(roleName, { handling: true, handlingExceptSession: {
|
|
22
|
+
agentId: session.agentId, nativeSessionId: session.nativeSessionId
|
|
23
|
+
}, limit: 0 }).count === 0)
|
|
24
|
+
return;
|
|
25
|
+
const id = requestId(session.agentId, session.nativeSessionId);
|
|
26
|
+
const existing = tx.queryGlobalRoleMessages(roleName, { requestId: id, limit: 1 }).messages[0];
|
|
27
|
+
if (existing)
|
|
28
|
+
return existing;
|
|
29
|
+
const notice = {
|
|
30
|
+
...createGlobalRoleMessage(tx.nextGlobalRoleMessageId(), roleName, [
|
|
31
|
+
"Yui-Summary: This successor has retained inbox handling obligations; inspect original evidence before settlement.",
|
|
32
|
+
"Read current Session Context and yui inbox list --view history --json. Read the exact original, original claim and prior effects.",
|
|
33
|
+
"This is initialization only: do not replay old input, retarget its delivery, or infer business completion.",
|
|
34
|
+
"After handling under current authority, explicitly acknowledge the original entry without replacing its claim.",
|
|
35
|
+
"Uncertain external effects require verification or a real user decision. New queued input remains separate."
|
|
36
|
+
].join("\n"), "system", { type: "system" }, now, {
|
|
37
|
+
inputControl: { action: "queue", requestId: id, expectedSessionId: session.nativeSessionId }
|
|
38
|
+
}),
|
|
39
|
+
deliveryTarget: { agentId: session.agentId, nativeSessionId: session.nativeSessionId }
|
|
40
|
+
};
|
|
41
|
+
tx.saveGlobalRoleMessage(notice);
|
|
42
|
+
return notice;
|
|
43
|
+
});
|
|
44
|
+
}
|
|
@@ -11,7 +11,7 @@ export function recordGlobalSteerResult(store, roleName, messageId, receipt) {
|
|
|
11
11
|
const now = new Date();
|
|
12
12
|
const outcome = receipt.state === "steered" ? "accepted"
|
|
13
13
|
: receipt.state === "steer-unknown" ? "delivery-unknown" : "rejected";
|
|
14
|
-
const updated = { ...message, control: {
|
|
14
|
+
const updated = { ...message, control: { ...message.control,
|
|
15
15
|
requestId: source.requestId, receiptId: `steer:${roleName}/${messageId}`,
|
|
16
16
|
outcome, observedAt: now.toISOString()
|
|
17
17
|
} };
|