@zq-silk/yui 1.1.0 → 2.0.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/ARCHITECTURE.md +2 -1
- package/ARCHITECTURE.zh-CN.md +2 -1
- package/README.md +3 -1
- package/dist/cli/commandCatalog.js +66 -54
- package/dist/cli/managedDiagnostics.js +2 -1
- package/dist/cli/updatePorts.js +0 -5
- package/dist/cli.js +113 -107
- package/dist/commands/controllerCommands.js +2 -1
- package/dist/commands/globalRoleCommands.js +84 -43
- package/dist/commands/grantCommands.js +48 -8
- package/dist/commands/taskCommands.js +146 -196
- package/dist/commands/taskContextCommand.js +29 -6
- package/dist/commands/taskFactCommands.js +10 -69
- package/dist/commands/taskInputCommands.js +26 -35
- package/dist/commands/taskPublicationCommands.js +2 -35
- package/dist/commands/taskPublicationVerifyCommand.js +1 -2
- package/dist/commands/taskUpstreamCommands.js +2 -1
- package/dist/context/runContextPack.js +8 -29
- package/dist/context/sessionBootstrapManifest.js +5 -101
- package/dist/context/taskContext.js +98 -39
- package/dist/controller/clientRuntime.js +16 -8
- package/dist/controller/fileSchedulerStoreAdapter.js +24 -0
- package/dist/controller/runtime.js +6 -0
- package/dist/core/controllerClient.js +46 -21
- package/dist/core/protocol.js +12 -7
- package/dist/doctor/doctor.js +6 -3
- package/dist/doctor/ptyProbe.js +74 -0
- package/dist/doctor/ptyProbeChild.js +90 -0
- package/dist/errors/cliError.js +4 -4
- package/dist/errors/cliFailure.js +213 -0
- package/dist/executor/fileRoleLaunchPlanner.js +1 -4
- package/dist/grant/capabilityGrant.js +13 -0
- package/dist/grant/taskAuthorization.js +127 -0
- package/dist/kernel/builtinCapabilities.js +60 -8
- package/dist/kernel/kernelPorts.js +2 -2
- package/dist/output/boundedRead.js +118 -0
- package/dist/release/releaseWorkflowEngine.js +8 -1
- package/dist/runtime/managedCaller.js +2 -2
- package/dist/runtime/runtimeCoherence.js +10 -8
- package/dist/storage/sqliteSchema.js +10 -0
- package/dist/storage/storageVersions.js +1 -1
- package/dist/storage/taskStore.js +3 -0
- package/dist/task/leaderArchive.js +111 -0
- package/dist/task/leaderArchiveAuthority.js +24 -0
- package/dist/tmux/commandExecutor.js +7 -5
- package/docs/architecture/README.md +1 -0
- package/docs/architecture/README.zh-CN.md +1 -0
- package/docs/cli-information-contract.md +107 -0
- package/docs/cli-information-contract.zh-CN.md +81 -0
- package/docs/managed-turn-and-session-runtime.md +9 -0
- package/docs/managed-turn-and-session-runtime.zh-CN.md +6 -0
- package/docs/plugin-sdk.md +7 -3
- package/docs/plugin-sdk.zh-CN.md +5 -2
- package/docs/release-workflow.md +19 -9
- package/docs/release-workflow.zh-CN.md +14 -7
- package/docs/storage-baseline.md +5 -0
- package/docs/testing/verification-levels.md +12 -2
- package/docs/testing/verification-levels.zh-CN.md +8 -0
- package/i18n/README.zh-CN.md +3 -1
- package/package.json +3 -3
- package/skills/yui-leader/SKILL.md +8 -1
- package/skills/yui-leader/references/authorization.md +64 -0
- package/skills/yui-leader/references/execution.md +18 -4
- package/skills/yui-leader/references/task-plugins.md +4 -2
- package/skills/yui-operator/SKILL.md +9 -2
- package/skills/yui-runtime/SKILL.md +51 -9
- package/skills/yui-runtime/references/publication.md +3 -1
|
@@ -1,8 +1,10 @@
|
|
|
1
|
+
import { assertTaskLocalResourcePath, taskAuthorizationSource } from "../grant/taskAuthorization.js";
|
|
2
|
+
import { createTaskEvent } from "../event/taskEvent.js";
|
|
1
3
|
import { listArtifactsCapability, readArtifactCapability, saveArtifactCapability } from "../artifacts/artifactCapability.js";
|
|
2
4
|
import { runConfigCommand } from "../commands/configCommands.js";
|
|
3
5
|
import { sendTaskMessageCommand, updateTaskMetadataCommand } from "../commands/taskCommands.js";
|
|
4
6
|
import { CONFIG_DOMAINS } from "../config/configCatalog.js";
|
|
5
|
-
import { inspectTaskContext, readTaskContext, readTaskContextDelta, withContextObservations } from "../context/taskContext.js";
|
|
7
|
+
import { inspectTaskContext, listTaskContext, readTaskContext, readTaskContextDelta, withContextObservations } from "../context/taskContext.js";
|
|
6
8
|
import { createJobCallAuthority, parseDurableJobStartParams } from "../controller/jobControl.js";
|
|
7
9
|
import { inspectJobOperation } from "../job/jobOperation.js";
|
|
8
10
|
import { assertJobAssignmentScope } from "../job/jobAssignmentScope.js";
|
|
@@ -41,11 +43,18 @@ const definitions = [
|
|
|
41
43
|
inputSchema: object({ taskId: text, after: text, continuation: text, limit: { type: "integer" } }, ["taskId", "after"]),
|
|
42
44
|
outputSchema: { type: "object", required: ["events", "throughCursor"] }
|
|
43
45
|
},
|
|
46
|
+
{
|
|
47
|
+
name: "context.list", summary: "Discover one authorized record family with bounded summaries and exact references.",
|
|
48
|
+
effect: "query", requiredPermissions: ["task:read"], source: "listTaskContext",
|
|
49
|
+
inputSchema: object({ taskId: text, store: text, cursor: text, limit: { type: "integer" },
|
|
50
|
+
status: text, after: text, workItemId: text }, ["taskId", "store"]),
|
|
51
|
+
outputSchema: { type: "object", required: ["items", "total", "complete", "nextCursor"] }
|
|
52
|
+
},
|
|
44
53
|
{
|
|
45
54
|
name: "context.inspect", summary: "Expand an authorized current record; optionally require its exact digest.",
|
|
46
55
|
effect: "query", requiredPermissions: ["task:read"], source: "inspectTaskContext",
|
|
47
|
-
inputSchema: object({ taskId: text, store: text, refId: text, digest: text }, ["taskId", "store", "refId"]),
|
|
48
|
-
outputSchema: { type: "object"
|
|
56
|
+
inputSchema: object({ taskId: text, store: text, refId: text, digest: text, cursor: text }, ["taskId", "store", "refId"]),
|
|
57
|
+
outputSchema: { type: "object" }
|
|
49
58
|
},
|
|
50
59
|
{
|
|
51
60
|
name: "artifact.save", summary: "Save a file artifact by relativePath into the Task's local Git repository; returns a commit-pinned reference.",
|
|
@@ -94,9 +103,14 @@ const definitions = [
|
|
|
94
103
|
inputSchema: taskInput, outputSchema: { type: "array", items: recordOutput }
|
|
95
104
|
},
|
|
96
105
|
{
|
|
97
|
-
name: "
|
|
106
|
+
name: "task.archive", summary: "Archive this terminal Task with original user authorization; may end the requesting Leader Session. Inspect durable archive receipts after interrupted delivery.",
|
|
107
|
+
effect: "local-mutation", requiredPermissions: ["task:manage"], source: "LeaderArchive",
|
|
108
|
+
inputSchema: object({ taskId: text, sourceMessage: text, purpose: text }), outputSchema: recordOutput
|
|
109
|
+
},
|
|
110
|
+
{
|
|
111
|
+
name: "resource.local.register", summary: "Register a user-owned local directory by canonical identity (Operator or source-authorized Task Leader); registration does not grant use.",
|
|
98
112
|
effect: "local-mutation", requiredPermissions: ["resource:register"], source: "ProjectResources.registerLocalDirectory",
|
|
99
|
-
inputSchema: object({ displayName: text, path: text }), outputSchema: recordOutput
|
|
113
|
+
inputSchema: object({ displayName: text, path: text, sourceMessage: text, purpose: text }, ["displayName", "path"]), outputSchema: recordOutput
|
|
100
114
|
},
|
|
101
115
|
{
|
|
102
116
|
name: "resource.local.read", summary: "Read an explicitly granted local resource.",
|
|
@@ -217,7 +231,7 @@ export const BUILTIN_CAPABILITIES = definitions.map((entry) => ({
|
|
|
217
231
|
}));
|
|
218
232
|
/** One registry with separate managed credentials and in-process Web query
|
|
219
233
|
* issuance. Socket possession or caller-scope JSON never issues a Web context. */
|
|
220
|
-
export function createBuiltinCapabilities(host, store, jobs, signal = () => undefined, contextProviders = []) {
|
|
234
|
+
export function createBuiltinCapabilities(host, store, jobs, signal = () => undefined, contextProviders = [], archiveTask) {
|
|
221
235
|
const authority = createJobCallAuthority(store);
|
|
222
236
|
const resources = createProjectResources(store);
|
|
223
237
|
const callers = new WeakMap();
|
|
@@ -243,6 +257,16 @@ export function createBuiltinCapabilities(host, store, jobs, signal = () => unde
|
|
|
243
257
|
throw new Error("Capability target is outside the authenticated Task.");
|
|
244
258
|
}
|
|
245
259
|
const taskId = invocation.context.targetId;
|
|
260
|
+
if (name === "task.archive") {
|
|
261
|
+
if (!archiveTask)
|
|
262
|
+
throw new Error("Controller archive execution is unavailable.");
|
|
263
|
+
if (!invocation.requestId)
|
|
264
|
+
throw new Error("Archive requires a stable requestId.");
|
|
265
|
+
return archiveTask(taskId, callerEnvironment(caller), {
|
|
266
|
+
sourceMessage: params.sourceMessage, purpose: params.purpose,
|
|
267
|
+
requestId: invocation.requestId
|
|
268
|
+
});
|
|
269
|
+
}
|
|
246
270
|
if (name === "message.send") {
|
|
247
271
|
// A managed Task Session resolves to Leader/role authority and never
|
|
248
272
|
// gains develop from an intent argument; only a global Operator Session
|
|
@@ -277,9 +301,16 @@ export function createBuiltinCapabilities(host, store, jobs, signal = () => unde
|
|
|
277
301
|
after: params.after, continuation: params.continuation,
|
|
278
302
|
limit: params.limit
|
|
279
303
|
}, callerEnvironment(caller));
|
|
304
|
+
if (name === "context.list")
|
|
305
|
+
return listTaskContext(store, taskId, params.store, {
|
|
306
|
+
cursor: params.cursor, limit: params.limit,
|
|
307
|
+
status: params.status, after: params.after,
|
|
308
|
+
workItemId: params.workItemId
|
|
309
|
+
}, callerEnvironment(caller));
|
|
280
310
|
if (name === "context.inspect")
|
|
281
311
|
return inspectTaskContext(store, taskId, {
|
|
282
|
-
store: params.store, refId: params.refId, digest: params.digest
|
|
312
|
+
store: params.store, refId: params.refId, digest: params.digest,
|
|
313
|
+
cursor: params.cursor
|
|
283
314
|
}, callerEnvironment(caller));
|
|
284
315
|
// File/directory artifacts live in the Task's local Git repository, not
|
|
285
316
|
// the DB. Save commits exactly one path and returns a self-certifying
|
|
@@ -316,7 +347,18 @@ export function createBuiltinCapabilities(host, store, jobs, signal = () => unde
|
|
|
316
347
|
if (name === "environment.list")
|
|
317
348
|
return store.listEnvironmentPreparations(taskId);
|
|
318
349
|
if (name === "resource.local.register")
|
|
319
|
-
return
|
|
350
|
+
return store.transaction(tx => {
|
|
351
|
+
const source = caller.scope === "task"
|
|
352
|
+
? taskAuthorizationSource(tx, taskId, callerEnvironment(caller), params.sourceMessage, params.purpose)
|
|
353
|
+
: undefined;
|
|
354
|
+
if (source)
|
|
355
|
+
assertTaskLocalResourcePath(tx, params.path);
|
|
356
|
+
const resource = resources.registerLocalDirectory(params.displayName, params.path);
|
|
357
|
+
if (source)
|
|
358
|
+
tx.saveEvent(taskId, createTaskEvent(tx.nextEventId(taskId), taskId, "resource.local.registered", { resourceId: resource.id, path: resource.path,
|
|
359
|
+
authorizationSource: JSON.stringify(source) }, new Date()));
|
|
360
|
+
return resource;
|
|
361
|
+
});
|
|
320
362
|
if (name === "resource.local.read")
|
|
321
363
|
return resources.readLocalResource(taskId, params.resourceId);
|
|
322
364
|
if (name === "project.context")
|
|
@@ -375,6 +417,13 @@ export function createBuiltinCapabilities(host, store, jobs, signal = () => unde
|
|
|
375
417
|
throw new Error("Capability target is outside the authenticated Task.");
|
|
376
418
|
}
|
|
377
419
|
for (const permission of descriptor?.requiredPermissions ?? []) {
|
|
420
|
+
if (caller.scope === "task" && descriptor?.effect !== "query"
|
|
421
|
+
&& ["task:manage", "plugin:manage", "resource:register"].includes(permission)) {
|
|
422
|
+
const sessions = store.getTaskRoleSessionSet(task.id, caller.role);
|
|
423
|
+
if (sessions?.sessions[sessions.activeAgentId]?.effective.executionAuthority !== "delivery") {
|
|
424
|
+
throw new Error("Planning Sessions cannot mutate delivery environments or plugins.");
|
|
425
|
+
}
|
|
426
|
+
}
|
|
378
427
|
if (permission === "job:start" && caller.scope === "task") {
|
|
379
428
|
const sessions = store.getTaskRoleSessionSet(task.id, caller.role);
|
|
380
429
|
if (sessions?.sessions[sessions.activeAgentId]?.effective.executionAuthority !== "delivery") {
|
|
@@ -394,6 +443,9 @@ export function createBuiltinCapabilities(host, store, jobs, signal = () => unde
|
|
|
394
443
|
// adopted environments and exact plugin.execute grants remain separate.
|
|
395
444
|
if (permission === "plugin:manage" && caller.scope === "task" && caller.role === "leader")
|
|
396
445
|
continue;
|
|
446
|
+
if (permission === "resource:register" && descriptor?.name === "resource.local.register"
|
|
447
|
+
&& caller.scope === "task" && caller.role === "leader")
|
|
448
|
+
continue;
|
|
397
449
|
if ((permission === "config:read" || permission === "plugin:manage" || permission === "resource:register")
|
|
398
450
|
&& caller.scope === "global" && caller.role === "operator")
|
|
399
451
|
continue;
|
|
@@ -6,14 +6,14 @@ import { InstanceHost } from "./instanceHost.js";
|
|
|
6
6
|
* another Controller, or provide arbitrary persistence to plugin code.
|
|
7
7
|
* T02 registers contributions on this Host and wraps this same Job control.
|
|
8
8
|
*/
|
|
9
|
-
export function createKernelPorts(store, runner, signal = () => undefined, contextProviders = []) {
|
|
9
|
+
export function createKernelPorts(store, runner, signal = () => undefined, contextProviders = [], archiveTask) {
|
|
10
10
|
const host = new InstanceHost();
|
|
11
11
|
const runnerImplementation = host.attach(JOB_RUNNER_IMPLEMENTATION, runner);
|
|
12
12
|
const runnerHandle = host.acquire(runnerImplementation);
|
|
13
13
|
const jobImplementation = host.attach({ id: "yui:job-control", generation: "1" }, createDurableJobControl(store));
|
|
14
14
|
// The Controller is a long-lived consumer of this exact implementation.
|
|
15
15
|
const jobHandle = host.acquire(jobImplementation);
|
|
16
|
-
const capabilities = createBuiltinCapabilities(host, store, jobHandle.value, signal, contextProviders);
|
|
16
|
+
const capabilities = createBuiltinCapabilities(host, store, jobHandle.value, signal, contextProviders, archiveTask);
|
|
17
17
|
return {
|
|
18
18
|
host,
|
|
19
19
|
capabilities,
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { usageError } from "../errors/cliError.js";
|
|
3
|
+
export const READ_PAGE_BYTES = 32 * 1024;
|
|
4
|
+
export const INLINE_DOCUMENT_BYTES = 16 * 1024;
|
|
5
|
+
function assertSource(source) {
|
|
6
|
+
if (Buffer.byteLength(source) > 1024)
|
|
7
|
+
throw usageError("Read source identity exceeds its 1024-byte budget.");
|
|
8
|
+
}
|
|
9
|
+
export function readDigest(value) {
|
|
10
|
+
return createHash("sha256").update(JSON.stringify(value)).digest("hex");
|
|
11
|
+
}
|
|
12
|
+
/** A save receipt is not another read of the submitted body. Keep the exact
|
|
13
|
+
* delivery/control facts and identity; body/history are read on demand.
|
|
14
|
+
*/
|
|
15
|
+
export function messageReceipt(message) {
|
|
16
|
+
const { body: _body, ...facts } = message;
|
|
17
|
+
return { ...facts, digest: readDigest(message), bodyBytes: Buffer.byteLength(message.body) };
|
|
18
|
+
}
|
|
19
|
+
export function encodeReadCursor(cursor) {
|
|
20
|
+
return Buffer.from(JSON.stringify(cursor)).toString("base64url");
|
|
21
|
+
}
|
|
22
|
+
export function decodeReadCursor(raw, source, digest) {
|
|
23
|
+
let cursor;
|
|
24
|
+
try {
|
|
25
|
+
if (raw.length > 4096)
|
|
26
|
+
throw new Error();
|
|
27
|
+
cursor = JSON.parse(Buffer.from(raw, "base64url").toString("utf8"));
|
|
28
|
+
if (cursor.source !== source || !Number.isSafeInteger(cursor.offset) || cursor.offset < 0
|
|
29
|
+
|| typeof cursor.digest !== "string")
|
|
30
|
+
throw new Error();
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
throw usageError("Invalid read cursor for this source/scope.");
|
|
34
|
+
}
|
|
35
|
+
if (cursor.digest !== digest)
|
|
36
|
+
throw usageError("Read source changed; restart the read instead of mixing versions.");
|
|
37
|
+
return cursor.offset;
|
|
38
|
+
}
|
|
39
|
+
export function readLimit(value) {
|
|
40
|
+
const limit = value ?? 20;
|
|
41
|
+
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 100) {
|
|
42
|
+
throw usageError("Read limit must be between 1 and 100.");
|
|
43
|
+
}
|
|
44
|
+
return limit;
|
|
45
|
+
}
|
|
46
|
+
/** For small in-memory authorized collections. Authorize/filter BEFORE calling.
|
|
47
|
+
* Cursors bind the selected collection, never grant access, and store no state.
|
|
48
|
+
*/
|
|
49
|
+
export function recordPage(records, source, options = {}) {
|
|
50
|
+
assertSource(source);
|
|
51
|
+
const limit = readLimit(options.limit);
|
|
52
|
+
const digest = readDigest(records);
|
|
53
|
+
const offset = options.cursor === undefined ? 0 : decodeReadCursor(options.cursor, source, digest);
|
|
54
|
+
if (offset > records.length)
|
|
55
|
+
throw usageError("Read cursor exceeds this collection.");
|
|
56
|
+
const items = [];
|
|
57
|
+
// Reserve room for identities, counts and continuation. Oversized individual
|
|
58
|
+
// records belong in a document read, not silently truncated list metadata.
|
|
59
|
+
let bytes = 0;
|
|
60
|
+
for (const record of records.slice(offset, offset + limit)) {
|
|
61
|
+
const size = Buffer.byteLength(JSON.stringify(record)) + 1;
|
|
62
|
+
if (size > READ_PAGE_BYTES / 2)
|
|
63
|
+
throw usageError("List entry exceeds its summary budget; read the exact record.");
|
|
64
|
+
if (bytes + size > READ_PAGE_BYTES - 4096)
|
|
65
|
+
break;
|
|
66
|
+
items.push(record);
|
|
67
|
+
bytes += size;
|
|
68
|
+
}
|
|
69
|
+
const end = offset + items.length;
|
|
70
|
+
return {
|
|
71
|
+
source, digest, items, total: records.length, complete: end === records.length,
|
|
72
|
+
nextCursor: end < records.length ? encodeReadCursor({ source, digest, offset: end }) : null,
|
|
73
|
+
limits: { items: limit, bytes: READ_PAGE_BYTES }
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
/** Small exact details stay native JSON. Large details explicitly become a
|
|
77
|
+
* contentPage. Concatenate text in order, then JSON.parse once, never each chunk.
|
|
78
|
+
* JSON character offsets (not bytes) preserve Unicode and escaped controls.
|
|
79
|
+
* The source is an exact authorized read identity; reauthorize every page.
|
|
80
|
+
*/
|
|
81
|
+
export function boundedDocument(value, source, cursor) {
|
|
82
|
+
assertSource(source);
|
|
83
|
+
const text = JSON.stringify(value);
|
|
84
|
+
const totalBytes = Buffer.byteLength(text);
|
|
85
|
+
if (cursor === undefined && totalBytes <= INLINE_DOCUMENT_BYTES)
|
|
86
|
+
return value;
|
|
87
|
+
const digest = readDigest(value);
|
|
88
|
+
const offset = cursor === undefined ? 0 : decodeReadCursor(cursor, source, digest);
|
|
89
|
+
if (offset >= text.length && offset !== 0)
|
|
90
|
+
throw usageError("Read cursor exceeds this document.");
|
|
91
|
+
let end = Math.min(offset + 4096, text.length);
|
|
92
|
+
// Never cut between UTF-16 surrogate halves.
|
|
93
|
+
if (end < text.length && /[\uD800-\uDBFF]/u.test(text[end - 1]))
|
|
94
|
+
end--;
|
|
95
|
+
return { contentPage: {
|
|
96
|
+
source, digest, encoding: "json", offset, totalCharacters: text.length,
|
|
97
|
+
totalBytes, text: text.slice(offset, end), complete: end === text.length,
|
|
98
|
+
nextCursor: end < text.length ? encodeReadCursor({ source, digest, offset: end }) : null
|
|
99
|
+
} };
|
|
100
|
+
}
|
|
101
|
+
/** Shared parser for list/detail command tails; no options reach mutations. */
|
|
102
|
+
export function readOptions(args, flags = ["--cursor", "--limit"]) {
|
|
103
|
+
const positionals = [];
|
|
104
|
+
const values = new Map();
|
|
105
|
+
for (let i = 0; i < args.length; i++) {
|
|
106
|
+
const arg = args[i];
|
|
107
|
+
if (!arg.startsWith("--")) {
|
|
108
|
+
positionals.push(arg);
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
if (!flags.includes(arg) || values.has(arg) || args[i + 1] === undefined || args[i + 1].startsWith("--")) {
|
|
112
|
+
throw usageError(`Invalid read option: ${arg}.`);
|
|
113
|
+
}
|
|
114
|
+
values.set(arg, args[++i]);
|
|
115
|
+
}
|
|
116
|
+
return { positionals, values, cursor: values.get("--cursor"),
|
|
117
|
+
...(values.has("--limit") ? { limit: Number(values.get("--limit")) } : {}) };
|
|
118
|
+
}
|
|
@@ -228,7 +228,8 @@ async function runReleaseWorkflowLocked(store, taskId, workflowId, ports, option
|
|
|
228
228
|
if (grant === null) {
|
|
229
229
|
return finish(workflow, "unauthorized", "unauthorized:grant-missing", attempted);
|
|
230
230
|
}
|
|
231
|
-
const params = resolveParams(workflow, plan)
|
|
231
|
+
const params = grant.authorizationSource === undefined ? resolveParams(workflow, plan)
|
|
232
|
+
: Object.freeze({ ...resolveParams(workflow, plan), sourceCommit: workflow.source.commit });
|
|
232
233
|
const effectiveIrreversibility = effectiveStepIrreversibility(plan);
|
|
233
234
|
const decision = checkGrant(grant, {
|
|
234
235
|
action: grantAction(plan),
|
|
@@ -615,6 +616,12 @@ function cliUpdateVersionDenial(plan, params) {
|
|
|
615
616
|
function versionTagCheckoutDenial(grant, plan, params) {
|
|
616
617
|
if (plan.kind !== "version-tag")
|
|
617
618
|
return undefined;
|
|
619
|
+
// Leader authorization names a release version; the adapter pushes tag,
|
|
620
|
+
// not version. Validate that actual effect before consuming a grant use.
|
|
621
|
+
if (grant.authorizationSource !== undefined
|
|
622
|
+
&& (!params.version || ![params.version, `v${params.version}`].includes(params.tag ?? ""))) {
|
|
623
|
+
return "grant-version-tag-mismatch";
|
|
624
|
+
}
|
|
618
625
|
const repositories = grant.scope.repositories;
|
|
619
626
|
if (repositories === undefined || repositories.length === 0)
|
|
620
627
|
return undefined;
|
|
@@ -30,8 +30,8 @@ export function requireManagedGlobalCaller(store, env) {
|
|
|
30
30
|
*/
|
|
31
31
|
export class ManagedRuntimeDriftError extends Error {
|
|
32
32
|
name = "ManagedRuntimeDriftError";
|
|
33
|
-
constructor(message) {
|
|
34
|
-
super(message);
|
|
33
|
+
constructor(message, options) {
|
|
34
|
+
super(message, options);
|
|
35
35
|
}
|
|
36
36
|
}
|
|
37
37
|
/** Reads the process's own immutable managed identity, or undefined when unmanaged. */
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { resolve } from "node:path";
|
|
2
|
+
import { StorageSchemaError } from "../storage/storageSchema.js";
|
|
2
3
|
import { isStorageVersion, compareStorageVersions } from "../storage/storageVersions.js";
|
|
3
4
|
import { callController as defaultCallController, ControllerClientError } from "../core/controllerClient.js";
|
|
4
5
|
import { inspectStorageSchema } from "../storage/storageSchema.js";
|
|
@@ -8,10 +9,12 @@ export async function assertRuntimeCoherence(input, options = {}) {
|
|
|
8
9
|
const identity = validateVersionIdentity(options.identity ?? yuiVersionIdentity());
|
|
9
10
|
const storage = (options.inspectStorage ?? inspectStorageSchema)(home);
|
|
10
11
|
if (storage.status !== "current") {
|
|
11
|
-
throw new
|
|
12
|
+
throw new StorageSchemaError(storage.status === "uninitialized" ? "STORAGE_UNINITIALIZED"
|
|
13
|
+
: storage.status === "unsupported" ? "STORAGE_SCHEMA_UNSUPPORTED" : "STORAGE_SCHEMA_INVALID", `Managed control-plane storage is not current: ${storage.status}.`
|
|
14
|
+
+ ("detail" in storage && typeof storage.detail === "string" ? ` ${storage.detail}` : ""));
|
|
12
15
|
}
|
|
13
16
|
if (storage.currentVersion !== identity.storageVersion) {
|
|
14
|
-
throw new
|
|
17
|
+
throw new StorageSchemaError("STORAGE_SCHEMA_UNSUPPORTED", "Managed control-plane storage version is incompatible "
|
|
15
18
|
+ `(expected ${identity.storageVersion}, found `
|
|
16
19
|
+ `${storage.currentVersion ?? "unknown"}).`);
|
|
17
20
|
}
|
|
@@ -37,7 +40,7 @@ export async function assertRuntimeCoherence(input, options = {}) {
|
|
|
37
40
|
}
|
|
38
41
|
export function assertControllerStatusIdentity(status, expected = yuiVersionIdentity()) {
|
|
39
42
|
if (!isRecord(status) || status.running !== true) {
|
|
40
|
-
throw new
|
|
43
|
+
throw new ControllerClientError("INVALID_RESPONSE", "Controller status does not describe a running Controller.");
|
|
41
44
|
}
|
|
42
45
|
assertControllerField(status.protocolVersion, expected.controllerProtocolVersion, "protocol");
|
|
43
46
|
assertControllerField(status.version, expected.version, "version");
|
|
@@ -66,20 +69,19 @@ function validateVersionIdentity(value) {
|
|
|
66
69
|
}
|
|
67
70
|
function assertControllerContinuityIdentity(status, expected) {
|
|
68
71
|
if (!isRecord(status) || status.running !== true) {
|
|
69
|
-
throw new
|
|
72
|
+
throw new ControllerClientError("INVALID_RESPONSE", "Controller status does not describe a running Controller.");
|
|
70
73
|
}
|
|
71
74
|
if (typeof status.version !== "string" || status.version.trim().length === 0) {
|
|
72
|
-
throw new
|
|
75
|
+
throw new ControllerClientError("CONTROLLER_PROTOCOL_MISMATCH", "Controller version is invalid at the managed continuity gate.");
|
|
73
76
|
}
|
|
74
77
|
assertControllerField(status.protocolVersion, expected.controllerProtocolVersion, "protocol");
|
|
75
78
|
assertControllerField(status.storageVersion, expected.storageVersion, "storage version");
|
|
76
79
|
}
|
|
77
80
|
function assertControllerField(actual, expected, label) {
|
|
78
81
|
if (actual !== expected) {
|
|
79
|
-
throw new
|
|
82
|
+
throw new ControllerClientError("CONTROLLER_PROTOCOL_MISMATCH", `Controller ${label} is incompatible with the exact control plane `
|
|
80
83
|
+ `(expected ${expected}, found ${typeof actual === "string" || typeof actual === "number" ? actual : "unknown"}). `
|
|
81
|
-
+ "
|
|
82
|
-
+ "before writing new Task records.");
|
|
84
|
+
+ "Verify Home, CLI and Controller identity before an authorized restart through the matching invocation.");
|
|
83
85
|
}
|
|
84
86
|
}
|
|
85
87
|
function requireText(value, label) {
|
|
@@ -24,6 +24,16 @@ const MINOR_UPGRADES = Object.freeze([{
|
|
|
24
24
|
targetChecksum: CURRENT_SCHEMA_CHECKSUM,
|
|
25
25
|
sql: "",
|
|
26
26
|
dataMigration: "task-main-workspace"
|
|
27
|
+
}, {
|
|
28
|
+
fromVersion: "1.1",
|
|
29
|
+
toVersion: "1.2",
|
|
30
|
+
name: "task-authorization-source",
|
|
31
|
+
introducedIn: "next",
|
|
32
|
+
sourceChecksum: CURRENT_SCHEMA_CHECKSUM,
|
|
33
|
+
targetChecksum: CURRENT_SCHEMA_CHECKSUM,
|
|
34
|
+
// New grants can carry source evidence. Valid historical Operator grants
|
|
35
|
+
// remain unchanged; migration must not invent authorization provenance.
|
|
36
|
+
sql: ""
|
|
27
37
|
}]);
|
|
28
38
|
export function storageMinorUpgradePlan(from) {
|
|
29
39
|
if (from === CURRENT_STORAGE_VERSION)
|
|
@@ -180,6 +180,8 @@ export function storedCapabilityGrant(value) {
|
|
|
180
180
|
fields.push("revokedAt");
|
|
181
181
|
if (grant.revokedBy !== undefined)
|
|
182
182
|
fields.push("revokedBy");
|
|
183
|
+
if (grant.authorizationSource !== undefined)
|
|
184
|
+
fields.push("authorizationSource");
|
|
183
185
|
exact(grant, fields, "Capability grant");
|
|
184
186
|
requireRecordIdentity(grant.id, "Capability grant id");
|
|
185
187
|
requireRecordIdentity(grant.taskId, "Capability grant Task id");
|
|
@@ -303,6 +305,7 @@ export function isValidCapabilityGrantTransition(existing, candidate) {
|
|
|
303
305
|
const immutable = candidate.id === existing.id
|
|
304
306
|
&& candidate.taskId === existing.taskId
|
|
305
307
|
&& candidate.granter === existing.granter
|
|
308
|
+
&& isDeepStrictEqual(candidate.authorizationSource, existing.authorizationSource)
|
|
306
309
|
&& isDeepStrictEqual(candidate.scope, existing.scope)
|
|
307
310
|
&& isDeepStrictEqual(candidate.actions, existing.actions)
|
|
308
311
|
&& isDeepStrictEqual(candidate.parameterBounds, existing.parameterBounds)
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { createTaskEvent } from "../event/taskEvent.js";
|
|
3
|
+
import { runTaskCommand } from "../commands/taskCommands.js";
|
|
4
|
+
import { archiveExecutionChecks, archiveSettlementChecks } from "./archivePreflight.js";
|
|
5
|
+
import { admitLeaderArchive, assertLeaderArchiveAdmission } from "./leaderArchiveAuthority.js";
|
|
6
|
+
import { assertTaskRemoteDeliveryIntegrated, createTaskRemoteDeliveryProof } from "./remoteDeliveryService.js";
|
|
7
|
+
import { selectNewTaskRoleSession } from "../executor/agentExecutor.js";
|
|
8
|
+
/** A single foreground Controller operation survives stopping its requesting
|
|
9
|
+
* Session. It has no retry worker: uncertain receipts are inspected, not
|
|
10
|
+
* replayed. All existing settlement, delivery and cleanup checks remain. */
|
|
11
|
+
export async function archiveLeaderTask(store, coordinator, taskId, environment, request) {
|
|
12
|
+
const inputDigest = createHash("sha256").update(JSON.stringify(request)).digest("hex");
|
|
13
|
+
const previous = store.listEvents(taskId).find(event => event.type === "task.leader-archive-started" && event.payload.requestId === request.requestId);
|
|
14
|
+
if (previous) {
|
|
15
|
+
if (previous.payload.inputDigest !== inputDigest)
|
|
16
|
+
throw new Error("Archive request id names different input.");
|
|
17
|
+
const result = store.listEvents(taskId).find(event => event.type === "task.leader-archive-result" && event.payload.requestId === request.requestId);
|
|
18
|
+
return { taskId, requestId: request.requestId, status: result?.payload.status ?? "unknown",
|
|
19
|
+
detail: result?.payload.detail, replayed: true };
|
|
20
|
+
}
|
|
21
|
+
const admission = admitLeaderArchive(store, taskId, environment, request.sourceMessage, request.purpose);
|
|
22
|
+
const task = store.getTask(taskId);
|
|
23
|
+
const settlement = archiveSettlementChecks(store, task);
|
|
24
|
+
if (settlement.length)
|
|
25
|
+
throw new Error(`Archive is unsettled: ${JSON.stringify(settlement)}`);
|
|
26
|
+
const session = store.getTaskRoleSessionSet(taskId, "leader");
|
|
27
|
+
const ownInput = session?.providerBinding?.run;
|
|
28
|
+
// Only the requesting no-Run Leader may be stopped as part of this action.
|
|
29
|
+
// Another Run, Job, unknown input, or then handoff is not archive authority.
|
|
30
|
+
const ownResources = `role:${taskId}/leader`;
|
|
31
|
+
const execution = archiveExecutionChecks(store, taskId).filter(check => !(check.resource === ownResources
|
|
32
|
+
&& ownInput?.runId === undefined && ownInput?.status === "accepted"
|
|
33
|
+
&& ["unresolved-provider-input", "unresolved-mailbox"].includes(check.reason)));
|
|
34
|
+
if (execution.length)
|
|
35
|
+
throw new Error(`Archive execution is unresolved: ${JSON.stringify(execution)}`);
|
|
36
|
+
if (store.listMessages(taskId).some(message => message.interruptThen?.targetNativeSessionId === admission.source.nativeSessionId
|
|
37
|
+
&& message.continuation === undefined && message.interruptThen.notDeliveredReason === undefined)) {
|
|
38
|
+
throw new Error("Settle the original input handoff before archive.");
|
|
39
|
+
}
|
|
40
|
+
let candidate = null;
|
|
41
|
+
const workspace = store.getTaskWorkspace(taskId);
|
|
42
|
+
if (task.projectBindings.length && workspace) {
|
|
43
|
+
const snapshot = await coordinator.preparer.snapshotDirectTaskMain(workspace, task.projectBindings.map(binding => binding.projectId));
|
|
44
|
+
candidate = { schemaVersion: 1, projects: snapshot.projects.map(project => ({
|
|
45
|
+
projectId: project.projectId, commit: project.headCommit
|
|
46
|
+
})) };
|
|
47
|
+
}
|
|
48
|
+
const proof = createTaskRemoteDeliveryProof(store, task, candidate);
|
|
49
|
+
assertTaskRemoteDeliveryIntegrated(proof.delivery);
|
|
50
|
+
for (const owned of store.listManagedWorkspaces(taskId)) {
|
|
51
|
+
const disposition = owned.owner.type === "work-item"
|
|
52
|
+
&& store.getWorkItem(taskId, owned.owner.workItemId)?.status === "retired" ? "abandoned" : "integrated";
|
|
53
|
+
const checks = await coordinator.preparer.inspectWorkspaceCleanup(owned, disposition);
|
|
54
|
+
if (checks.some(check => !(owned.owner.type === "task" && check.reason === "git-worktree-registrations"))) {
|
|
55
|
+
throw new Error(`Archive workspace is not clean/removable: ${JSON.stringify(checks)}`);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
// Reauthenticate after asynchronous inspection, before the first effect.
|
|
59
|
+
admitLeaderArchive(store, taskId, environment, request.sourceMessage, request.purpose);
|
|
60
|
+
assertLeaderArchiveAdmission(store, taskId, admission);
|
|
61
|
+
const record = (type, payload) => store.transaction(tx => tx.saveEvent(taskId, createTaskEvent(tx.nextEventId(taskId), taskId, type, { requestId: request.requestId, ...payload }, new Date())));
|
|
62
|
+
const started = store.transaction(tx => {
|
|
63
|
+
assertLeaderArchiveAdmission(tx, taskId, admission);
|
|
64
|
+
const events = tx.listEvents(taskId);
|
|
65
|
+
const prior = events.find(event => event.type === "task.leader-archive-started"
|
|
66
|
+
&& event.payload.requestId === request.requestId);
|
|
67
|
+
if (prior) {
|
|
68
|
+
if (prior.payload.inputDigest !== inputDigest)
|
|
69
|
+
throw new Error("Archive request id names different input.");
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
const unsettled = events.find(event => event.type === "task.leader-archive-started"
|
|
73
|
+
&& !events.some(result => result.type === "task.leader-archive-result"
|
|
74
|
+
&& result.payload.requestId === event.payload.requestId));
|
|
75
|
+
if (unsettled)
|
|
76
|
+
throw new Error(`Inspect unresolved archive ${unsettled.payload.requestId} before another operation.`);
|
|
77
|
+
tx.saveEvent(taskId, createTaskEvent(tx.nextEventId(taskId), taskId, "task.leader-archive-started", { requestId: request.requestId, inputDigest, authorizationSource: JSON.stringify(admission.source) }, new Date()));
|
|
78
|
+
return true;
|
|
79
|
+
});
|
|
80
|
+
if (!started)
|
|
81
|
+
return { taskId, requestId: request.requestId, status: "unknown", replayed: true };
|
|
82
|
+
try {
|
|
83
|
+
await coordinator.runtime.stopTaskRoleSessions(taskId, ["leader"]);
|
|
84
|
+
store.transaction(tx => {
|
|
85
|
+
assertLeaderArchiveAdmission(tx, taskId, admission);
|
|
86
|
+
const stopped = tx.getTaskRoleSessionSet(taskId, "leader");
|
|
87
|
+
const current = stopped?.sessions[stopped.activeAgentId];
|
|
88
|
+
if (!stopped || current?.nativeSessionId !== admission.source.nativeSessionId
|
|
89
|
+
|| current.status !== "ended" || current.endReason !== "stopped") {
|
|
90
|
+
throw new Error("Leader Session changed before archive runtime retirement.");
|
|
91
|
+
}
|
|
92
|
+
// Retain the exact stopped caller in history and clear its resumable
|
|
93
|
+
// binding. Generic workspace cleanup must not stop that caller again:
|
|
94
|
+
// the first verified stop already released its physical owner records.
|
|
95
|
+
tx.saveTaskRoleSessionSet(selectNewTaskRoleSession(stopped, stopped.activeAgentId, new Date()));
|
|
96
|
+
});
|
|
97
|
+
const cleanup = await coordinator.cleanupTaskForArchive(taskId, "integrated");
|
|
98
|
+
if (cleanup.status !== "removed")
|
|
99
|
+
throw new Error(cleanup.error ?? `Archive cleanup ${cleanup.status}.`);
|
|
100
|
+
runTaskCommand(["archive", taskId, "--integrated"], store, {
|
|
101
|
+
environment, archiveLeaderAdmission: admission, archiveRemoteDeliveryProof: proof
|
|
102
|
+
});
|
|
103
|
+
record("task.leader-archive-result", { status: "archived" });
|
|
104
|
+
return { taskId, requestId: request.requestId, status: "archived" };
|
|
105
|
+
}
|
|
106
|
+
catch (error) {
|
|
107
|
+
record("task.leader-archive-result", { status: store.getTask(taskId)?.status === "archived" ? "archived" : "failed",
|
|
108
|
+
detail: error instanceof Error ? error.message : String(error) });
|
|
109
|
+
throw error;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { taskAuthorizationSource } from "../grant/taskAuthorization.js";
|
|
2
|
+
const admissions = new WeakSet();
|
|
3
|
+
const terminalIdentity = (store, taskId) => {
|
|
4
|
+
const task = store.getTask(taskId);
|
|
5
|
+
if (!task || !["completed", "cancelled"].includes(task.status))
|
|
6
|
+
throw new Error("Ordinary Leader archive requires a terminal Task.");
|
|
7
|
+
return JSON.stringify({
|
|
8
|
+
status: task.status, completedAt: task.completedAt, completionSummary: task.completionSummary,
|
|
9
|
+
retiredAt: task.retiredAt, retirementSummary: task.retirementSummary,
|
|
10
|
+
projectBindings: task.projectBindings
|
|
11
|
+
});
|
|
12
|
+
};
|
|
13
|
+
export function admitLeaderArchive(store, taskId, environment, messageId, purpose) {
|
|
14
|
+
const source = taskAuthorizationSource(store, taskId, environment, messageId, purpose);
|
|
15
|
+
const admission = Object.freeze({ taskId, source, terminalIdentity: terminalIdentity(store, taskId) });
|
|
16
|
+
admissions.add(admission);
|
|
17
|
+
return admission;
|
|
18
|
+
}
|
|
19
|
+
export function assertLeaderArchiveAdmission(store, taskId, admission) {
|
|
20
|
+
if (!admissions.has(admission) || admission.taskId !== taskId
|
|
21
|
+
|| terminalIdentity(store, taskId) !== admission.terminalIdentity) {
|
|
22
|
+
throw new Error("Archive admission is untrusted or its terminal Task changed.");
|
|
23
|
+
}
|
|
24
|
+
}
|
|
@@ -4,8 +4,8 @@ export class CommandExecutionError extends Error {
|
|
|
4
4
|
code;
|
|
5
5
|
exitStatus;
|
|
6
6
|
stderr;
|
|
7
|
-
constructor(code, exitStatus, stderr = "") {
|
|
8
|
-
super(commandExecutionMessage(code));
|
|
7
|
+
constructor(code, exitStatus, stderr = "", options) {
|
|
8
|
+
super(commandExecutionMessage(code), options);
|
|
9
9
|
this.code = code;
|
|
10
10
|
this.exitStatus = exitStatus;
|
|
11
11
|
this.stderr = stderr;
|
|
@@ -102,15 +102,17 @@ export class NodeCommandExecutor {
|
|
|
102
102
|
}
|
|
103
103
|
function stableCommandExecutionError(error) {
|
|
104
104
|
const details = errorDetails(error);
|
|
105
|
+
const cause = typeof error === "object" && error !== null && "error" in error
|
|
106
|
+
&& error.error instanceof Error ? { cause: error.error } : undefined;
|
|
105
107
|
if (details.code === "ENOENT") {
|
|
106
|
-
return new CommandExecutionError("COMMAND_NOT_FOUND", details.exitStatus, details.stderr);
|
|
108
|
+
return new CommandExecutionError("COMMAND_NOT_FOUND", details.exitStatus, details.stderr, cause);
|
|
107
109
|
}
|
|
108
110
|
if (details.code === "ETIMEDOUT"
|
|
109
111
|
|| details.signal === "SIGTERM"
|
|
110
112
|
|| details.signal === "SIGKILL") {
|
|
111
|
-
return new CommandExecutionError("COMMAND_TIMED_OUT", details.exitStatus, details.stderr);
|
|
113
|
+
return new CommandExecutionError("COMMAND_TIMED_OUT", details.exitStatus, details.stderr, cause);
|
|
112
114
|
}
|
|
113
|
-
return new CommandExecutionError("COMMAND_FAILED", details.exitStatus, details.stderr);
|
|
115
|
+
return new CommandExecutionError("COMMAND_FAILED", details.exitStatus, details.stderr, cause);
|
|
114
116
|
}
|
|
115
117
|
function errorDetails(error) {
|
|
116
118
|
if (typeof error !== "object" || error === null)
|
|
@@ -24,6 +24,7 @@ not a claim that every real Provider scenario has been validated.
|
|
|
24
24
|
| Who consumes results, synthesis and review? | [Result consumption](../agent-result-consumption.md) |
|
|
25
25
|
| When is a WorkItem dependency satisfied? | [Task dependencies](../task-dag-semantics.md) |
|
|
26
26
|
| How are records referenced inside a Task? | [Task-local identity](../task-local-identity.md) |
|
|
27
|
+
| How do bounded discovery, full originals and mutation receipts work? | [CLI information contract](../cli-information-contract.md) |
|
|
27
28
|
| How do Roles, Profiles and run configuration take effect? | [Roles and configuration](../roles-and-configuration.md) |
|
|
28
29
|
| How do delivery, integration and archive work? | [Task delivery](../task-delivery.md) |
|
|
29
30
|
| What does Project refresh synchronize, and how are partial failures reported? | [Project refresh](../project-refresh.md) |
|
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
| 谁消费结果、综合与审查? | [结果消费](../agent-result-consumption.zh-CN.md) |
|
|
21
21
|
| WorkItem 依赖何时满足? | [Task 依赖](../task-dag-semantics.zh-CN.md) |
|
|
22
22
|
| Task 内记录如何引用? | [局部身份](../task-local-identity.zh-CN.md) |
|
|
23
|
+
| 有界发现、完整原文与变更回执如何配合? | [CLI 信息契约](../cli-information-contract.zh-CN.md) |
|
|
23
24
|
| Role、Profile 与运行配置如何生效? | [角色与配置](../roles-and-configuration.zh-CN.md) |
|
|
24
25
|
| 怎样交付、集成和归档? | [交付生命周期](../task-delivery.zh-CN.md) |
|
|
25
26
|
| Project refresh 同步什么,怎样报告部分失败? | [Project refresh](../project-refresh.zh-CN.md) |
|