@zq-silk/yui 0.15.5 → 0.15.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/commandCatalog.js +11 -1
- package/dist/cli.js +10 -0
- package/dist/commands/capabilityCommands.js +54 -0
- package/dist/commands/taskCommands.js +28 -20
- package/dist/controller/capabilityBridge.js +66 -0
- package/dist/controller/controller.js +13 -0
- package/dist/controller/runtime.js +5 -1
- package/dist/controller/runtimeLaunchCoordinator.js +24 -4
- package/dist/kernel/builtinCapabilities.js +188 -0
- package/dist/kernel/capabilityRegistry.js +268 -0
- package/dist/kernel/capabilitySchema.js +91 -0
- package/dist/kernel/instanceHost.js +6 -0
- package/dist/kernel/kernelPorts.js +4 -1
- package/package.json +4 -4
|
@@ -985,7 +985,7 @@ export const ROOT_COMMAND = buildNode({
|
|
|
985
985
|
] },
|
|
986
986
|
{ id: "workflow", title: "Workflow", entries: ["operator", "project", "task"] },
|
|
987
987
|
{ id: "configuration", title: "Configuration", entries: ["config"] },
|
|
988
|
-
{ id: "operations", title: "Operations", entries: ["web", "controller", "session", "execution", "job", "jobs", "telemetry", "release"] },
|
|
988
|
+
{ id: "operations", title: "Operations", entries: ["web", "controller", "session", "execution", "capability", "job", "jobs", "telemetry", "release"] },
|
|
989
989
|
{ id: "resources", title: "Resources", entries: ["resources"] },
|
|
990
990
|
{ id: "internal", title: "Internal", entries: ["internal"] }
|
|
991
991
|
],
|
|
@@ -1386,6 +1386,16 @@ export const ROOT_COMMAND = buildNode({
|
|
|
1386
1386
|
],
|
|
1387
1387
|
children: taskChildren
|
|
1388
1388
|
},
|
|
1389
|
+
{
|
|
1390
|
+
name: "capability",
|
|
1391
|
+
summary: "Discover and call authorized capabilities through the Controller.",
|
|
1392
|
+
sections: [{ id: "entry", title: "Commands", entries: ["search", "describe", "call"] }],
|
|
1393
|
+
children: [
|
|
1394
|
+
{ name: "search", summary: "Search the current authorized directory.", usage: "yui capability search [query] --task <id>" },
|
|
1395
|
+
{ name: "describe", summary: "Describe one contract or report Provider ambiguity.", usage: "yui capability describe <name> --task <id> [--provider <id>] [--version <version>]" },
|
|
1396
|
+
{ name: "call", summary: "Invoke one implementation under the current managed identity.", usage: "yui capability call <name> --task <id> --input <json> [--provider <id>] [--version <version>] [--request-id <id>]" }
|
|
1397
|
+
]
|
|
1398
|
+
},
|
|
1389
1399
|
{
|
|
1390
1400
|
name: "job",
|
|
1391
1401
|
summary: "Start, inspect, cancel, or acknowledge a Controller-managed DurableJob.",
|
package/dist/cli.js
CHANGED
|
@@ -24,6 +24,7 @@ import { nativeAgentEnvironmentNames } from "./agent/launchEnvironment.js";
|
|
|
24
24
|
import { runAgentCommand } from "./commands/agentCommands.js";
|
|
25
25
|
import { runGlobalRoleCommand } from "./commands/globalRoleCommands.js";
|
|
26
26
|
import { runConfigCommand } from "./commands/configCommands.js";
|
|
27
|
+
import { runCapabilityCommand } from "./commands/capabilityCommands.js";
|
|
27
28
|
import { CONFIG_DOMAINS } from "./config/configCatalog.js";
|
|
28
29
|
import { runConfigOverview } from "./commands/configOverview.js";
|
|
29
30
|
import { parseControllerCleanupOptions, parseControllerStatusOptions, parseControllerRuntimeSnapshot, renderControllerResourceStatus, renderRuntimeIdentitySection, summarizeDurablePhysicalMismatch, runInteractiveControllerCleanup } from "./commands/controllerCommands.js";
|
|
@@ -249,6 +250,15 @@ export async function main() {
|
|
|
249
250
|
emit(renderCommandHelp(target, VERSION), false, describeCommandTree(target));
|
|
250
251
|
return;
|
|
251
252
|
}
|
|
253
|
+
if (args[0] === "capability") {
|
|
254
|
+
const result = await runCapabilityCommand(args.slice(1), home, process.env);
|
|
255
|
+
emit(JSON.stringify(result, null, 2), false, result);
|
|
256
|
+
const kind = result?.kind;
|
|
257
|
+
if (typeof result === "object" && result !== null && !Array.isArray(result)
|
|
258
|
+
&& typeof kind === "string" && !["value", "operation"].includes(kind))
|
|
259
|
+
process.exitCode = 5;
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
252
262
|
if (args[0] === "setup") {
|
|
253
263
|
if (jsonOutput)
|
|
254
264
|
throw usageError("Setup does not support --json.");
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { usageError } from "../errors/cliError.js";
|
|
2
|
+
import { resolveJobCaller } from "./taskActor.js";
|
|
3
|
+
import { callFileTaskController } from "../controller/clientRuntime.js";
|
|
4
|
+
export async function runCapabilityCommand(args, home, environment) {
|
|
5
|
+
const [action, ...rest] = args;
|
|
6
|
+
const usage = "yui capability search [query] --task <id> | describe <name> --task <id> [--provider <id>] [--version <version>] | call <name> --task <id> --input <json> [--provider <id>] [--version <version>] [--request-id <id>]";
|
|
7
|
+
if (!["search", "describe", "call"].includes(action))
|
|
8
|
+
throw usageError(usage);
|
|
9
|
+
const flags = new Map();
|
|
10
|
+
const positionals = [];
|
|
11
|
+
const allowed = new Set(["--task", "--provider", "--version", "--input", "--request-id"]);
|
|
12
|
+
for (let index = 0; index < rest.length; index += 1) {
|
|
13
|
+
const arg = rest[index];
|
|
14
|
+
if (!arg.startsWith("--")) {
|
|
15
|
+
positionals.push(arg);
|
|
16
|
+
continue;
|
|
17
|
+
}
|
|
18
|
+
const next = rest[++index];
|
|
19
|
+
if (!allowed.has(arg) || flags.has(arg) || next === undefined || next.startsWith("--")) {
|
|
20
|
+
throw usageError(`Invalid capability option: ${arg}.`, usage);
|
|
21
|
+
}
|
|
22
|
+
flags.set(arg, next);
|
|
23
|
+
}
|
|
24
|
+
const taskId = flags.get("--task") ?? environment.YUI_TASK_ID;
|
|
25
|
+
if (!taskId || positionals.length > 1 || (action !== "search" && !positionals.length))
|
|
26
|
+
throw usageError(usage);
|
|
27
|
+
if (action === "search" && [...flags.keys()].some((key) => key !== "--task"))
|
|
28
|
+
throw usageError(usage);
|
|
29
|
+
if (action === "describe" && (flags.has("--input") || flags.has("--request-id")))
|
|
30
|
+
throw usageError(usage);
|
|
31
|
+
let input;
|
|
32
|
+
if (action === "call") {
|
|
33
|
+
if (!flags.has("--input"))
|
|
34
|
+
throw usageError("Capability call requires --input <json>.", usage);
|
|
35
|
+
try {
|
|
36
|
+
input = JSON.parse(flags.get("--input"));
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
throw usageError("Invalid capability JSON input.");
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return callFileTaskController(home, `capability.${action}`, {
|
|
43
|
+
taskId, caller: resolveJobCaller(environment, taskId),
|
|
44
|
+
...(action === "search" ? { query: positionals[0] ?? "" } : {
|
|
45
|
+
request: {
|
|
46
|
+
name: positionals[0],
|
|
47
|
+
...(input === undefined ? {} : { input }),
|
|
48
|
+
...(flags.has("--provider") ? { providerId: flags.get("--provider") } : {}),
|
|
49
|
+
...(flags.has("--version") ? { contractVersion: flags.get("--version") } : {}),
|
|
50
|
+
...(flags.has("--request-id") ? { requestId: flags.get("--request-id") } : {})
|
|
51
|
+
}
|
|
52
|
+
})
|
|
53
|
+
}, { environment });
|
|
54
|
+
}
|
|
@@ -471,30 +471,38 @@ function updateTaskCommand(args, store, options) {
|
|
|
471
471
|
const tags = parsed.options.has("--tags")
|
|
472
472
|
? parseTaskTags(requiredOption(parsed.options, "--tags"))
|
|
473
473
|
: undefined;
|
|
474
|
+
const result = updateTaskMetadataCommand(store, parsed.positionals[0], {
|
|
475
|
+
...(parsed.options.has("--title") ? { title: requiredOption(parsed.options, "--title") } : {}),
|
|
476
|
+
...(parsed.options.has("--type")
|
|
477
|
+
? { type: requiredOption(parsed.options, "--type") }
|
|
478
|
+
: parsed.options.has("--clear-type") ? { type: null } : {}),
|
|
479
|
+
...(parsed.options.has("--description")
|
|
480
|
+
? { description: requiredOption(parsed.options, "--description") }
|
|
481
|
+
: parsed.options.has("--clear-description") ? { description: null } : {}),
|
|
482
|
+
...(priority === undefined
|
|
483
|
+
? parsed.options.has("--clear-priority") ? { priority: null } : {}
|
|
484
|
+
: { priority }),
|
|
485
|
+
...(tags === undefined
|
|
486
|
+
? parsed.options.has("--clear-tags") ? { tags: null } : {}
|
|
487
|
+
: { tags }),
|
|
488
|
+
...(dueAt === undefined
|
|
489
|
+
? parsed.options.has("--clear-due-at") ? { dueAt: null } : {}
|
|
490
|
+
: { dueAt })
|
|
491
|
+
}, options);
|
|
492
|
+
return `Updated task ${result.id}\n`;
|
|
493
|
+
}
|
|
494
|
+
/** Shared domain transaction for structured capabilities and the legacy CLI.
|
|
495
|
+
* Parsing text flags must not become an alternate business write path. */
|
|
496
|
+
export function updateTaskMetadataCommand(store, taskId, patch, options = {}) {
|
|
497
|
+
if (Object.keys(patch).length === 0)
|
|
498
|
+
throw usageError("At least one Task metadata field is required.");
|
|
474
499
|
const now = clock(options);
|
|
475
500
|
const result = store.transaction((tx) => {
|
|
476
|
-
const current = requireTask(tx,
|
|
501
|
+
const current = requireTask(tx, taskId);
|
|
477
502
|
if (current.status === "archived")
|
|
478
503
|
throw usageError(`Task is archived: ${current.id}.`);
|
|
479
504
|
taskActor(tx, options, current.id);
|
|
480
|
-
const updated = updateTaskMetadata(current,
|
|
481
|
-
...(parsed.options.has("--title") ? { title: requiredOption(parsed.options, "--title") } : {}),
|
|
482
|
-
...(parsed.options.has("--type")
|
|
483
|
-
? { type: requiredOption(parsed.options, "--type") }
|
|
484
|
-
: parsed.options.has("--clear-type") ? { type: null } : {}),
|
|
485
|
-
...(parsed.options.has("--description")
|
|
486
|
-
? { description: requiredOption(parsed.options, "--description") }
|
|
487
|
-
: parsed.options.has("--clear-description") ? { description: null } : {}),
|
|
488
|
-
...(priority === undefined
|
|
489
|
-
? parsed.options.has("--clear-priority") ? { priority: null } : {}
|
|
490
|
-
: { priority }),
|
|
491
|
-
...(tags === undefined
|
|
492
|
-
? parsed.options.has("--clear-tags") ? { tags: null } : {}
|
|
493
|
-
: { tags }),
|
|
494
|
-
...(dueAt === undefined
|
|
495
|
-
? parsed.options.has("--clear-due-at") ? { dueAt: null } : {}
|
|
496
|
-
: { dueAt })
|
|
497
|
-
}, now);
|
|
505
|
+
const updated = updateTaskMetadata(current, patch, now);
|
|
498
506
|
tx.saveTask(updated);
|
|
499
507
|
recordTaskEvent(tx, updated.id, "task.updated", {
|
|
500
508
|
status: updated.status,
|
|
@@ -504,7 +512,7 @@ function updateTaskCommand(args, store, options) {
|
|
|
504
512
|
return updated;
|
|
505
513
|
});
|
|
506
514
|
notifyMailbox(options.runtime, taskMailbox(result.id), result.id);
|
|
507
|
-
return
|
|
515
|
+
return result;
|
|
508
516
|
}
|
|
509
517
|
export function submitOperatorMessage(body, taskId, store, options = {}) {
|
|
510
518
|
const now = clock(options);
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { capabilitySchemaError } from "../kernel/capabilitySchema.js";
|
|
2
|
+
/** The same transport method is usable by CLI, Agent and an authenticated
|
|
3
|
+
* Surface adapter. Credentials are an ingress envelope, never capability input. */
|
|
4
|
+
export function createCapabilityDispatcher(capabilities) {
|
|
5
|
+
return async (method, value) => {
|
|
6
|
+
const error = capabilitySchemaError({
|
|
7
|
+
type: "object", required: ["taskId", "caller"],
|
|
8
|
+
additionalProperties: false,
|
|
9
|
+
properties: {
|
|
10
|
+
taskId: { type: "string", minLength: 1 },
|
|
11
|
+
caller: {
|
|
12
|
+
type: "object", required: ["scope"], additionalProperties: false,
|
|
13
|
+
properties: {
|
|
14
|
+
scope: { enum: ["user", "global", "task"] }, taskId: { type: "string" },
|
|
15
|
+
role: { type: "string" }, agentId: { type: "string" }, adapterId: { type: "string" },
|
|
16
|
+
runtimeGenerationId: { type: "string" }, nativeSessionId: { type: "string" },
|
|
17
|
+
turnId: { type: "string" }, callerKey: { type: "string" }
|
|
18
|
+
}
|
|
19
|
+
},
|
|
20
|
+
query: { type: "string" },
|
|
21
|
+
request: {
|
|
22
|
+
type: "object", required: ["name"], additionalProperties: false,
|
|
23
|
+
properties: {
|
|
24
|
+
name: { type: "string", minLength: 1 }, input: {},
|
|
25
|
+
contractVersion: { type: "string", minLength: 1 },
|
|
26
|
+
providerId: { type: "string", minLength: 1 },
|
|
27
|
+
requestId: { type: "string", minLength: 1 }
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}, value);
|
|
32
|
+
if (error)
|
|
33
|
+
throw invalidParams(`Invalid capability envelope: ${error}`);
|
|
34
|
+
const params = value;
|
|
35
|
+
const context = capabilities.authenticate(params.caller, params.taskId);
|
|
36
|
+
let result;
|
|
37
|
+
if (method === "capability.search") {
|
|
38
|
+
if (params.request !== undefined)
|
|
39
|
+
throw invalidParams("search accepts query, not request.");
|
|
40
|
+
result = { capabilities: capabilities.registry.search(context, params.query) };
|
|
41
|
+
}
|
|
42
|
+
else {
|
|
43
|
+
if (params.query !== undefined || params.request === undefined)
|
|
44
|
+
throw invalidParams("describe/call requires request.");
|
|
45
|
+
if (method === "capability.describe")
|
|
46
|
+
result = capabilities.registry.describe(context, params.request);
|
|
47
|
+
else if (method === "capability.call") {
|
|
48
|
+
if (!Object.hasOwn(params.request, "input"))
|
|
49
|
+
throw invalidParams("call requires input.");
|
|
50
|
+
// Target is bound before acquisition, not inferred from plugin actor data.
|
|
51
|
+
const input = params.request.input;
|
|
52
|
+
if (typeof input === "object" && input !== null && "taskId" in input
|
|
53
|
+
&& input.taskId !== params.taskId)
|
|
54
|
+
throw invalidParams("Capability target is outside the authenticated Task.");
|
|
55
|
+
result = await capabilities.registry.call(context, params.request);
|
|
56
|
+
}
|
|
57
|
+
else
|
|
58
|
+
throw invalidParams("Unknown capability method.");
|
|
59
|
+
}
|
|
60
|
+
return JSON.parse(JSON.stringify(result));
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
/** Expected ingress rejections use the Controller's existing safe error shape. */
|
|
64
|
+
function invalidParams(message) {
|
|
65
|
+
return Object.assign(new Error(message), { name: "CoreApplicationError", code: "INVALID_PARAMS" });
|
|
66
|
+
}
|
|
@@ -1667,6 +1667,19 @@ export async function startFileTaskController(home, store, delivery, dispatcher,
|
|
|
1667
1667
|
const intervalMs = runtime.reloadReconciliationInterval();
|
|
1668
1668
|
return { configured: true, reconciliationIntervalMs: intervalMs };
|
|
1669
1669
|
}
|
|
1670
|
+
if (method === "capability.search" || method === "capability.describe" || method === "capability.call") {
|
|
1671
|
+
if (options.capabilityDispatcher === undefined) {
|
|
1672
|
+
throw controllerApplicationError("METHOD_NOT_FOUND", "Capability ingress is unavailable.");
|
|
1673
|
+
}
|
|
1674
|
+
const request = Promise.resolve(options.capabilityDispatcher(method, params));
|
|
1675
|
+
lifecycleRequests.add(request);
|
|
1676
|
+
try {
|
|
1677
|
+
return await request;
|
|
1678
|
+
}
|
|
1679
|
+
finally {
|
|
1680
|
+
lifecycleRequests.delete(request);
|
|
1681
|
+
}
|
|
1682
|
+
}
|
|
1670
1683
|
if (method === "job.start" || method === "job.get" || method === "job.cancel" || method === "job.acknowledge") {
|
|
1671
1684
|
const control = options.jobControl;
|
|
1672
1685
|
if (control === undefined) {
|
|
@@ -23,6 +23,7 @@ import { openSchedulerTelemetry } from "../telemetry/telemetryWiring.js";
|
|
|
23
23
|
import { createFileArtifactPort, createLinuxProcessPort, DurableJobSupervisor } from "./jobSupervisor.js";
|
|
24
24
|
import { authorizeJobStart } from "./jobControl.js";
|
|
25
25
|
import { createKernelPorts } from "../kernel/kernelPorts.js";
|
|
26
|
+
import { createCapabilityDispatcher } from "./capabilityBridge.js";
|
|
26
27
|
import { FileRuntimeEventInbox } from "./runtimeEventInbox.js";
|
|
27
28
|
import { AgentRuntimeObserver } from "./agentRuntimeObserver.js";
|
|
28
29
|
import { AsyncRuntimeEventProcessor, FileRuntimeEventProcessor, createAsyncRuntimeObserver, } from "./runtimeEventProcessor.js";
|
|
@@ -356,7 +357,9 @@ export async function startFileTaskControllerRuntime(home, options = {}) {
|
|
|
356
357
|
// supervisor enqueues a durable-job-terminal event; the processor drains it
|
|
357
358
|
// on the next pass, waking the Controller immediately instead of waiting for
|
|
358
359
|
// the poll interval.
|
|
359
|
-
const kernel = createKernelPorts(store, createLinuxProcessPort())
|
|
360
|
+
const kernel = createKernelPorts(store, createLinuxProcessPort(), (taskId) => {
|
|
361
|
+
runningRuntime?.signal(`task:${taskId}`);
|
|
362
|
+
});
|
|
360
363
|
const jobSupervisor = new DurableJobSupervisor({
|
|
361
364
|
store: schedulerStore,
|
|
362
365
|
process: kernel.runner,
|
|
@@ -419,6 +422,7 @@ export async function startFileTaskControllerRuntime(home, options = {}) {
|
|
|
419
422
|
lifecycleHost,
|
|
420
423
|
jobSupervisor,
|
|
421
424
|
jobControl,
|
|
425
|
+
capabilityDispatcher: createCapabilityDispatcher(kernel.capabilities),
|
|
422
426
|
...(continuationReconciler === undefined ? {} : { continuationReconciler }),
|
|
423
427
|
...(resourceReaper === undefined ? {} : { resourceReaper }),
|
|
424
428
|
resourceAutoGc,
|
|
@@ -76,8 +76,9 @@ export class RuntimeLaunchCoordinator {
|
|
|
76
76
|
if (request.owner.scope === "global" && request.managedWorkspace !== undefined) {
|
|
77
77
|
throw new Error("A global runtime cannot use a Task ManagedWorkspace.");
|
|
78
78
|
}
|
|
79
|
+
let currentRequest = request;
|
|
79
80
|
const assertLaunchCurrent = () => {
|
|
80
|
-
this.#assertCurrent?.(
|
|
81
|
+
this.#assertCurrent?.(currentRequest);
|
|
81
82
|
assertCurrent?.();
|
|
82
83
|
};
|
|
83
84
|
const proposedGenerationId = requireText(this.#createGenerationId(), "Launch generation id");
|
|
@@ -155,8 +156,20 @@ export class RuntimeLaunchCoordinator {
|
|
|
155
156
|
throw new Error("Runtime host reported its pre-start launch fence more than once.");
|
|
156
157
|
}
|
|
157
158
|
validateRuntimeLaunchPreflight(preflight, request, runtimeGenerationId);
|
|
159
|
+
this.reservations.confirmRuntimeLaunchReservation({
|
|
160
|
+
owner: request.owner,
|
|
161
|
+
runtimeGenerationId
|
|
162
|
+
}, assertLaunchCurrent);
|
|
158
163
|
preflightObserved = true;
|
|
159
164
|
beforeHostStart?.(preflight);
|
|
165
|
+
// The pre-start persistence callback hands the fixed native Session to
|
|
166
|
+
// this reserved activation. From here on, fence against that exact new
|
|
167
|
+
// identity, not the historical Host we originally set out to restore.
|
|
168
|
+
// Do not accept both identities: a later change back is stale as well.
|
|
169
|
+
if (request.mode === "resume" && request.hostActivationId !== undefined) {
|
|
170
|
+
currentRequest = { ...request, hostActivationId: runtimeGenerationId };
|
|
171
|
+
}
|
|
172
|
+
assertLaunchCurrent();
|
|
160
173
|
};
|
|
161
174
|
let binding;
|
|
162
175
|
try {
|
|
@@ -257,7 +270,7 @@ export class RuntimeLaunchCoordinator {
|
|
|
257
270
|
throw error;
|
|
258
271
|
}
|
|
259
272
|
if (reusedConfirmedRunningHost && binding.hostCreated === true) {
|
|
260
|
-
await this.#compensateStartedHost(request.owner, binding, runtimeGenerationId, runtimeIsolation, new Error(`Runtime host was recreated while recovering an existing generation: ${request.owner.roleName}.`));
|
|
273
|
+
await this.#compensateStartedHost(request.owner, binding, runtimeGenerationId, runtimeIsolation, reusedConfirmedRunningHost, new Error(`Runtime host was recreated while recovering an existing generation: ${request.owner.roleName}.`));
|
|
261
274
|
}
|
|
262
275
|
try {
|
|
263
276
|
assertLaunchCurrent();
|
|
@@ -282,7 +295,7 @@ export class RuntimeLaunchCoordinator {
|
|
|
282
295
|
}
|
|
283
296
|
}
|
|
284
297
|
catch (error) {
|
|
285
|
-
await this.#compensateStartedHost(request.owner, binding, runtimeGenerationId, runtimeIsolation, error);
|
|
298
|
+
await this.#compensateStartedHost(request.owner, binding, runtimeGenerationId, runtimeIsolation, reusedConfirmedRunningHost, error);
|
|
286
299
|
}
|
|
287
300
|
return binding;
|
|
288
301
|
}
|
|
@@ -317,7 +330,14 @@ export class RuntimeLaunchCoordinator {
|
|
|
317
330
|
return;
|
|
318
331
|
this.#requireCleanup(request.owner);
|
|
319
332
|
}
|
|
320
|
-
async #compensateStartedHost(owner, binding, runtimeGenerationId, runtimeIsolation, cause) {
|
|
333
|
+
async #compensateStartedHost(owner, binding, runtimeGenerationId, runtimeIsolation, reusedConfirmedRunningHost, cause) {
|
|
334
|
+
if (reusedConfirmedRunningHost && binding.hostCreated !== true) {
|
|
335
|
+
// Reattaching an existing activation gives this launch no ownership of
|
|
336
|
+
// it. A fresh activation inside a persistent Host is different: this
|
|
337
|
+
// launch still owns its startup and isolation cleanup even when the
|
|
338
|
+
// physical Host did not need to be created.
|
|
339
|
+
throw new RuntimeLaunchStateChangedError(`Runtime launch state changed while reusing a Host: ${cause instanceof Error ? cause.message : String(cause)}`, { cause });
|
|
340
|
+
}
|
|
321
341
|
try {
|
|
322
342
|
await this.host.stop(binding);
|
|
323
343
|
}
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import { updateTaskMetadataCommand } from "../commands/taskCommands.js";
|
|
2
|
+
import { runConfigCommand } from "../commands/configCommands.js";
|
|
3
|
+
import { CONFIG_DOMAINS } from "../config/configCatalog.js";
|
|
4
|
+
import { createJobCallAuthority, parseDurableJobStartParams } from "../controller/jobControl.js";
|
|
5
|
+
import { inspectJobOperation } from "./kernelPorts.js";
|
|
6
|
+
import { CapabilityRegistry, } from "./capabilityRegistry.js";
|
|
7
|
+
const text = { type: "string", minLength: 1 };
|
|
8
|
+
const strings = { type: "object", additionalProperties: { type: "string" } };
|
|
9
|
+
const object = (properties, required = Object.keys(properties)) => ({
|
|
10
|
+
type: "object", properties, required, additionalProperties: false
|
|
11
|
+
});
|
|
12
|
+
const taskInput = object({ taskId: text });
|
|
13
|
+
const taskOutput = { type: "object", required: ["id", "status", "title"], properties: {
|
|
14
|
+
id: text, status: text, title: text
|
|
15
|
+
} };
|
|
16
|
+
const provider = Object.freeze({ id: "yui:builtin-capabilities", generation: "1" });
|
|
17
|
+
/** Source locators identify the existing semantic owner, not a new Store. */
|
|
18
|
+
const definitions = [
|
|
19
|
+
{
|
|
20
|
+
name: "task.read", summary: "Read the current Task record.", effect: "query",
|
|
21
|
+
inputSchema: taskInput, outputSchema: taskOutput, requiredPermissions: ["task:read"],
|
|
22
|
+
source: "TaskStore.getTask (task show)"
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
name: "task.update", summary: "Update Task metadata through the existing command transaction.",
|
|
26
|
+
effect: "local-mutation", requiredPermissions: ["task:manage"],
|
|
27
|
+
source: "updateTaskMetadataCommand (task update)",
|
|
28
|
+
inputSchema: object({
|
|
29
|
+
taskId: text,
|
|
30
|
+
patch: object({
|
|
31
|
+
title: text, description: { type: "string" },
|
|
32
|
+
priority: { enum: ["low", "medium", "high", "urgent"] },
|
|
33
|
+
tags: { type: "array", items: text }
|
|
34
|
+
}, [])
|
|
35
|
+
}), outputSchema: taskOutput
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
name: "config.read", summary: "Read effective global configuration (Operator only).",
|
|
39
|
+
effect: "query", requiredPermissions: ["config:read"],
|
|
40
|
+
source: "runConfigCommand (config <domain> show)",
|
|
41
|
+
inputSchema: object({ domain: { enum: CONFIG_DOMAINS } }),
|
|
42
|
+
outputSchema: { type: "object" }
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
name: "resource.workspaces", summary: "Read the Task's managed workspace resources.",
|
|
46
|
+
effect: "query", requiredPermissions: ["task:read"],
|
|
47
|
+
source: "TaskStore.listManagedWorkspaces (task workspace list)",
|
|
48
|
+
inputSchema: taskInput, outputSchema: { type: "array", items: { type: "object", required: ["owner", "root", "entries"] } }
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
name: "job.get", summary: "Read the original Job and its operation evidence.",
|
|
52
|
+
effect: "query", requiredPermissions: ["task:read"],
|
|
53
|
+
source: "DurableJobControlPort.getJob (job.get RPC)",
|
|
54
|
+
inputSchema: object({ taskId: text, jobId: text }),
|
|
55
|
+
outputSchema: { type: "object", required: ["job", "operation"] }
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
name: "job.start", summary: "Request an idempotent Job through the existing Controller owner.",
|
|
59
|
+
effect: "external-operation", requiredPermissions: ["job:start"],
|
|
60
|
+
source: "DurableJobControlPort.startJob (job.start RPC)",
|
|
61
|
+
inputSchema: object({
|
|
62
|
+
taskId: text, projectId: text, head: text, workspace: text,
|
|
63
|
+
owner: { anyOf: [
|
|
64
|
+
object({ kind: { const: "task" } }),
|
|
65
|
+
object({ kind: { const: "work-item" }, workItemId: text }),
|
|
66
|
+
object({ kind: { const: "integration-attempt" }, integrationAttemptId: text })
|
|
67
|
+
] },
|
|
68
|
+
env: strings,
|
|
69
|
+
steps: { type: "array", minItems: 1, items: object({
|
|
70
|
+
name: text, command: text, timeoutMs: { type: "integer" }
|
|
71
|
+
}, ["name", "command"]) },
|
|
72
|
+
retryOf: text
|
|
73
|
+
}, ["taskId", "projectId", "head", "workspace", "owner", "env", "steps"]),
|
|
74
|
+
outputSchema: { type: "object", required: ["job", "created", "operation"], properties: { created: { type: "boolean" } } }
|
|
75
|
+
},
|
|
76
|
+
...["create", "validate", "activate", "disable"].map((action) => ({
|
|
77
|
+
name: `plugin.${action}`, summary: "Plugin SDK management is not implemented.",
|
|
78
|
+
effect: "local-mutation", requiredPermissions: ["plugin:manage"],
|
|
79
|
+
source: "T09 (not implemented)", inputSchema: object({}), outputSchema: object({}),
|
|
80
|
+
unavailable: "The executable plugin SDK is not implemented. Existing Agent Drivers and Project Skills retain their typed management interfaces."
|
|
81
|
+
}))
|
|
82
|
+
];
|
|
83
|
+
export const BUILTIN_CAPABILITIES = definitions.map((entry) => ({
|
|
84
|
+
...entry, contractVersion: "1", provider, scope: { kind: "global" }
|
|
85
|
+
}));
|
|
86
|
+
/** Authenticated managed bridge. Socket possession/scope JSON is not a grant.
|
|
87
|
+
* A future user/Web ingress must provide its own verified identity adapter;
|
|
88
|
+
* this one deliberately accepts only the existing managed credentials. */
|
|
89
|
+
export function createBuiltinCapabilities(host, store, jobs, signal = () => undefined) {
|
|
90
|
+
const authority = createJobCallAuthority(store);
|
|
91
|
+
const callers = new WeakMap();
|
|
92
|
+
const current = (context) => {
|
|
93
|
+
authority.authorize(context, context.targetId);
|
|
94
|
+
const caller = callers.get(context);
|
|
95
|
+
if (!caller)
|
|
96
|
+
throw new Error("Untrusted capability ingress.");
|
|
97
|
+
return caller;
|
|
98
|
+
};
|
|
99
|
+
const implementation = {
|
|
100
|
+
invoke(name, input, invocation) {
|
|
101
|
+
const caller = current(invocation.context);
|
|
102
|
+
const params = input;
|
|
103
|
+
if (params.taskId !== undefined && params.taskId !== invocation.context.targetId) {
|
|
104
|
+
throw new Error("Capability target is outside the authenticated Task.");
|
|
105
|
+
}
|
|
106
|
+
const taskId = invocation.context.targetId;
|
|
107
|
+
if (name === "task.read")
|
|
108
|
+
return requireTask(store, taskId);
|
|
109
|
+
if (name === "resource.workspaces")
|
|
110
|
+
return store.listManagedWorkspaces(taskId);
|
|
111
|
+
if (name === "config.read")
|
|
112
|
+
return runConfigCommand(params.domain, ["show"], store).data;
|
|
113
|
+
if (name === "task.update") {
|
|
114
|
+
const patch = params.patch;
|
|
115
|
+
return updateTaskMetadataCommand(store, taskId, {
|
|
116
|
+
...patch,
|
|
117
|
+
...(patch.description === "" ? { description: null } : {}),
|
|
118
|
+
...(patch.tags?.length === 0 ? { tags: null } : {})
|
|
119
|
+
}, {
|
|
120
|
+
environment: callerEnvironment(caller),
|
|
121
|
+
runtime: { notifyStateChanged: signal, reconcileTask: signal }
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
if (name === "job.get") {
|
|
125
|
+
const job = jobs.getJob(taskId, params.jobId);
|
|
126
|
+
if (!job)
|
|
127
|
+
throw new Error(`Job not found: ${taskId}/${params.jobId}.`);
|
|
128
|
+
const operation = inspectJobOperation(job);
|
|
129
|
+
invocation.observe(operation);
|
|
130
|
+
return { job, operation };
|
|
131
|
+
}
|
|
132
|
+
if (name === "job.start") {
|
|
133
|
+
const parsed = parseDurableJobStartParams({
|
|
134
|
+
...params, caller, requestId: invocation.requestId
|
|
135
|
+
});
|
|
136
|
+
const { job, created } = jobs.startJob(parsed, new Date());
|
|
137
|
+
const operation = inspectJobOperation(job);
|
|
138
|
+
invocation.observe(operation);
|
|
139
|
+
if (created)
|
|
140
|
+
signal(taskId);
|
|
141
|
+
return { job, created, operation };
|
|
142
|
+
}
|
|
143
|
+
throw new Error(`Builtin capability unavailable: ${name}.`);
|
|
144
|
+
}
|
|
145
|
+
};
|
|
146
|
+
host.attach(provider, implementation);
|
|
147
|
+
const registry = new CapabilityRegistry(host, (context, descriptor, input) => {
|
|
148
|
+
const caller = current(context);
|
|
149
|
+
const task = requireTask(store, context.targetId);
|
|
150
|
+
if (typeof input === "object" && input !== null && "taskId" in input && input.taskId !== task.id) {
|
|
151
|
+
throw new Error("Capability target is outside the authenticated Task.");
|
|
152
|
+
}
|
|
153
|
+
for (const permission of descriptor?.requiredPermissions ?? []) {
|
|
154
|
+
if (permission === "task:read" || permission === "job:start")
|
|
155
|
+
continue;
|
|
156
|
+
if (permission === "task:manage" && ((caller.scope === "task" && caller.role === "leader")
|
|
157
|
+
|| (caller.scope === "global" && caller.role === "operator")))
|
|
158
|
+
continue;
|
|
159
|
+
if ((permission === "config:read" || permission === "plugin:manage")
|
|
160
|
+
&& caller.scope === "global" && caller.role === "operator")
|
|
161
|
+
continue;
|
|
162
|
+
throw new Error(`Permission unavailable: ${permission}.`);
|
|
163
|
+
}
|
|
164
|
+
return { taskIds: [task.id], projectIds: task.projectBindings.map((binding) => binding.projectId) };
|
|
165
|
+
}, BUILTIN_CAPABILITIES);
|
|
166
|
+
return {
|
|
167
|
+
registry,
|
|
168
|
+
authenticate(caller, taskId) {
|
|
169
|
+
const credential = Object.freeze({ ...caller });
|
|
170
|
+
const context = authority.authenticate(credential, taskId);
|
|
171
|
+
callers.set(context, credential);
|
|
172
|
+
return context;
|
|
173
|
+
}
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
function requireTask(store, taskId) {
|
|
177
|
+
const task = store.getTask(taskId);
|
|
178
|
+
if (!task)
|
|
179
|
+
throw new Error(`Task not found: ${taskId}.`);
|
|
180
|
+
return task;
|
|
181
|
+
}
|
|
182
|
+
function callerEnvironment(caller) {
|
|
183
|
+
return {
|
|
184
|
+
YUI_SESSION_SCOPE: caller.scope, YUI_TASK_ID: caller.taskId,
|
|
185
|
+
YUI_ROLE: caller.role, YUI_AGENT_ID: caller.agentId,
|
|
186
|
+
YUI_ADAPTER_ID: caller.adapterId, YUI_JOB_CALLER_KEY: caller.callerKey
|
|
187
|
+
};
|
|
188
|
+
}
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
import { capabilitySchemaError, checkCapabilitySchema } from "./capabilitySchema.js";
|
|
2
|
+
const effectRank = { query: 0, "local-mutation": 1, "external-operation": 2 };
|
|
3
|
+
const reserved = new Set(["yui", "task", "config", "job", "resource", "plugin", "runtime", "grant", "capability"]);
|
|
4
|
+
/** One rebuildable descriptor view over the composition root's existing Host.
|
|
5
|
+
* Only the trusted root owns this object. Extensions receive a bound invocation
|
|
6
|
+
* port, never the registry/Host or an actor-selecting call interface. */
|
|
7
|
+
export class CapabilityRegistry {
|
|
8
|
+
host;
|
|
9
|
+
authorize;
|
|
10
|
+
#descriptors = [];
|
|
11
|
+
#coreProviders = new Set();
|
|
12
|
+
constructor(host, authorize, builtins = []) {
|
|
13
|
+
this.host = host;
|
|
14
|
+
this.authorize = authorize;
|
|
15
|
+
this.#publish(builtins, true);
|
|
16
|
+
builtins.forEach((entry) => this.#coreProviders.add(entry.provider.id));
|
|
17
|
+
}
|
|
18
|
+
/** Publish a complete provider generation atomically after owner initialization.
|
|
19
|
+
* Failure leaves all existing descriptors intact. Host lifecycle remains owned
|
|
20
|
+
* by the root: publish replacement, then detach old generation when appropriate. */
|
|
21
|
+
register(descriptors) {
|
|
22
|
+
this.#publish(descriptors, false);
|
|
23
|
+
}
|
|
24
|
+
disable(providerId) {
|
|
25
|
+
this.#descriptors = this.#descriptors.filter((entry) => entry.provider.id !== providerId);
|
|
26
|
+
}
|
|
27
|
+
search(context, query = "") {
|
|
28
|
+
const visibility = this.authorize(context);
|
|
29
|
+
const authorized = this.#descriptors.filter((entry) => visible(entry.scope, visibility))
|
|
30
|
+
.filter((entry) => {
|
|
31
|
+
try {
|
|
32
|
+
this.authorize(context, entry);
|
|
33
|
+
return true;
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
return authorized.filter((entry) => `${entry.name} ${entry.summary}`.toLowerCase().includes(query.toLowerCase()))
|
|
40
|
+
.map((entry) => {
|
|
41
|
+
const unavailable = this.#unavailable(entry, authorized, new Set());
|
|
42
|
+
return unavailable === undefined ? entry : Object.freeze({ ...entry, unavailable });
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
describe(context, request) {
|
|
46
|
+
try {
|
|
47
|
+
const candidates = this.search(context).filter((entry) => entry.name === request.name
|
|
48
|
+
&& (request.contractVersion === undefined || request.contractVersion === entry.contractVersion)
|
|
49
|
+
&& (request.providerId === undefined || request.providerId === entry.provider.id));
|
|
50
|
+
if (!candidates.length)
|
|
51
|
+
return result("unavailable", "No authorized compatible Provider is available.");
|
|
52
|
+
const available = candidates.filter((entry) => entry.unavailable === undefined);
|
|
53
|
+
if (!available.length)
|
|
54
|
+
return {
|
|
55
|
+
...result("unavailable", candidates.map((entry) => `${entry.provider.id}: ${entry.unavailable}`).join("; ")),
|
|
56
|
+
candidates
|
|
57
|
+
};
|
|
58
|
+
if (available.length > 1)
|
|
59
|
+
return { ...result("ambiguous", "Select an explicit Provider and contract version."), candidates };
|
|
60
|
+
const selected = available[0];
|
|
61
|
+
return {
|
|
62
|
+
...result("value"), value: selected, candidates, provider: selected.provider,
|
|
63
|
+
selection: request.providerId === undefined ? "unique" : "explicit"
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
return result("denied", "Current call authority is unavailable.");
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
call(context, request) {
|
|
71
|
+
return this.#call(context, request, "external-operation");
|
|
72
|
+
}
|
|
73
|
+
async #call(context, request, ceiling) {
|
|
74
|
+
// Freeze one input snapshot across validation/acquisition and async nesting.
|
|
75
|
+
try {
|
|
76
|
+
request = deepFreeze(structuredClone(request));
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
return result("invalid", "Capability request must be cloneable data.");
|
|
80
|
+
}
|
|
81
|
+
const resolved = this.describe(context, request);
|
|
82
|
+
if (resolved.kind !== "value")
|
|
83
|
+
return resolved;
|
|
84
|
+
const descriptor = resolved.value;
|
|
85
|
+
const origin = { provider: descriptor.provider, selection: resolved.selection };
|
|
86
|
+
if (effectRank[descriptor.effect] > effectRank[ceiling]) {
|
|
87
|
+
return { ...result("denied", "Nested call exceeds the parent's effect boundary."), ...origin };
|
|
88
|
+
}
|
|
89
|
+
const inputError = capabilitySchemaError(descriptor.inputSchema, request.input);
|
|
90
|
+
if (inputError)
|
|
91
|
+
return { ...result("invalid", inputError), ...origin };
|
|
92
|
+
if (descriptor.effect !== "query" && (typeof request.requestId !== "string" || !request.requestId.trim())) {
|
|
93
|
+
return { ...result("invalid", "Effectful calls require requestId."), ...origin };
|
|
94
|
+
}
|
|
95
|
+
try {
|
|
96
|
+
this.authorize(context, descriptor, request.input);
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
return { ...result("denied", "Current call authority was revoked."), ...origin };
|
|
100
|
+
}
|
|
101
|
+
const operations = [];
|
|
102
|
+
let effect = "none";
|
|
103
|
+
const observe = (operation) => {
|
|
104
|
+
operations.push(structuredClone(operation));
|
|
105
|
+
effect = accumulatedEffect(effect, operation.effect);
|
|
106
|
+
};
|
|
107
|
+
let entered = false;
|
|
108
|
+
try {
|
|
109
|
+
const value = await this.host.use(descriptor.provider, async (implementation) => {
|
|
110
|
+
entered = true;
|
|
111
|
+
let open = true;
|
|
112
|
+
const children = [];
|
|
113
|
+
try {
|
|
114
|
+
return await implementation.invoke(descriptor.name, request.input, Object.freeze({
|
|
115
|
+
context, requestId: request.requestId,
|
|
116
|
+
observe: (operation) => {
|
|
117
|
+
if (!open)
|
|
118
|
+
throw new Error("Capability invocation has ended.");
|
|
119
|
+
observe(operation);
|
|
120
|
+
},
|
|
121
|
+
call: (nested) => {
|
|
122
|
+
if (!open)
|
|
123
|
+
return Promise.resolve(result("denied", "Capability invocation has ended."));
|
|
124
|
+
const child = this.#call(context, nested, descriptor.effect).then((outcome) => {
|
|
125
|
+
outcome.operations.forEach(observe);
|
|
126
|
+
effect = accumulatedEffect(effect, outcome.effect);
|
|
127
|
+
return outcome;
|
|
128
|
+
});
|
|
129
|
+
children.push(child);
|
|
130
|
+
return child;
|
|
131
|
+
}
|
|
132
|
+
}));
|
|
133
|
+
}
|
|
134
|
+
finally {
|
|
135
|
+
open = false;
|
|
136
|
+
// A wrapper throwing early must not detach its already-issued child
|
|
137
|
+
// operations from the returned evidence or release the parent handle.
|
|
138
|
+
await Promise.all(children);
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
if (descriptor.effect === "local-mutation")
|
|
142
|
+
effect = accumulatedEffect(effect, "confirmed");
|
|
143
|
+
// An external implementation without an owner's operation reference is not
|
|
144
|
+
// proof of success. Preserve uncertainty rather than return a value success.
|
|
145
|
+
if (descriptor.effect === "external-operation" && !operations.length) {
|
|
146
|
+
return { kind: "failed", detail: "External implementation returned no operation evidence.", effect: "possible", operations, ...origin };
|
|
147
|
+
}
|
|
148
|
+
let output;
|
|
149
|
+
try {
|
|
150
|
+
output = JSON.parse(JSON.stringify(value, (_key, item) => {
|
|
151
|
+
if ((typeof item === "number" && !Number.isFinite(item))
|
|
152
|
+
|| typeof item === "bigint" || typeof item === "function" || typeof item === "symbol") {
|
|
153
|
+
throw new Error("Output contains a non-JSON value.");
|
|
154
|
+
}
|
|
155
|
+
return item;
|
|
156
|
+
}));
|
|
157
|
+
}
|
|
158
|
+
catch {
|
|
159
|
+
if (descriptor.effect !== "query")
|
|
160
|
+
effect = accumulatedEffect(effect, "possible");
|
|
161
|
+
return { kind: "invalid", detail: "Output is not JSON serializable.", effect, operations, ...origin };
|
|
162
|
+
}
|
|
163
|
+
const outputError = capabilitySchemaError(descriptor.outputSchema, output);
|
|
164
|
+
if (outputError) {
|
|
165
|
+
if (descriptor.effect !== "query")
|
|
166
|
+
effect = accumulatedEffect(effect, "possible");
|
|
167
|
+
return { kind: "invalid", detail: `Output ${outputError}`, effect, operations, ...origin };
|
|
168
|
+
}
|
|
169
|
+
return { kind: operations.length ? "operation" : "value", value: output, effect, operations, ...origin };
|
|
170
|
+
}
|
|
171
|
+
catch (error) {
|
|
172
|
+
// An unrelated observation (including a historical queued Job with none)
|
|
173
|
+
// cannot prove that a failing effectful wrapper performed no other action.
|
|
174
|
+
// Preserve the owner's precise facts while reporting wrapper uncertainty.
|
|
175
|
+
if (entered && descriptor.effect !== "query")
|
|
176
|
+
effect = accumulatedEffect(effect, "possible");
|
|
177
|
+
return {
|
|
178
|
+
kind: entered ? "failed" : "unavailable",
|
|
179
|
+
detail: error instanceof Error ? error.message : "Capability invocation failed.",
|
|
180
|
+
effect, operations, ...origin
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
#unavailable(entry, authorized, visiting) {
|
|
185
|
+
if (entry.unavailable !== undefined)
|
|
186
|
+
return entry.unavailable;
|
|
187
|
+
if (!this.host.isAvailable(entry.provider))
|
|
188
|
+
return "Provider implementation is not available.";
|
|
189
|
+
if (visiting.has(entry))
|
|
190
|
+
return "Required capability dependency cycle.";
|
|
191
|
+
const path = new Set([...visiting, entry]);
|
|
192
|
+
// Only declared exact name/version edges are checked. No installation,
|
|
193
|
+
// priority policy, version solver or persisted dependency state is involved.
|
|
194
|
+
const missing = entry.required?.filter((dependency) => !authorized.some((candidate) => (candidate.name === dependency.name && candidate.contractVersion === dependency.contractVersion
|
|
195
|
+
&& this.#unavailable(candidate, authorized, path) === undefined)));
|
|
196
|
+
if (missing?.length)
|
|
197
|
+
return `Missing available required capabilities: ${missing.map((dependency) => `${dependency.name}@${dependency.contractVersion}`).join(", ")}.`;
|
|
198
|
+
return undefined;
|
|
199
|
+
}
|
|
200
|
+
#publish(descriptors, core) {
|
|
201
|
+
if (!descriptors.length)
|
|
202
|
+
return;
|
|
203
|
+
const prepared = structuredClone(descriptors);
|
|
204
|
+
const provider = prepared[0].provider;
|
|
205
|
+
if (!core && this.#coreProviders.has(provider.id))
|
|
206
|
+
throw new Error("Core Provider identity is reserved.");
|
|
207
|
+
const names = new Set();
|
|
208
|
+
for (const entry of prepared) {
|
|
209
|
+
if (!/^[a-z][a-z0-9-]*(?:\.[a-z][a-z0-9-]*)+$/u.test(entry.name)
|
|
210
|
+
|| !entry.contractVersion?.trim() || !entry.source?.trim()
|
|
211
|
+
|| !Object.hasOwn(effectRank, entry.effect)
|
|
212
|
+
|| !Array.isArray(entry.requiredPermissions)
|
|
213
|
+
|| entry.requiredPermissions.some((permission) => typeof permission !== "string" || !permission.trim())) {
|
|
214
|
+
throw new Error("Invalid capability descriptor.");
|
|
215
|
+
}
|
|
216
|
+
if (!core && reserved.has(entry.name.split(".")[0]))
|
|
217
|
+
throw new Error("Core capability namespace is reserved.");
|
|
218
|
+
if (!["global", "project", "task"].includes(entry.scope.kind)
|
|
219
|
+
|| (entry.scope.kind !== "global" && !entry.scope.id?.trim()))
|
|
220
|
+
throw new Error("Invalid capability scope.");
|
|
221
|
+
if (entry.provider.id !== provider.id || entry.provider.generation !== provider.generation) {
|
|
222
|
+
throw new Error("Publish one complete Provider generation at a time.");
|
|
223
|
+
}
|
|
224
|
+
const key = `${entry.name}@${entry.contractVersion}`;
|
|
225
|
+
if (names.has(key))
|
|
226
|
+
throw new Error(`Duplicate capability: ${key}.`);
|
|
227
|
+
names.add(key);
|
|
228
|
+
checkCapabilitySchema(entry.inputSchema);
|
|
229
|
+
checkCapabilitySchema(entry.outputSchema);
|
|
230
|
+
for (const dependency of entry.required ?? []) {
|
|
231
|
+
if (!dependency.name?.trim() || !dependency.contractVersion?.trim())
|
|
232
|
+
throw new Error("Invalid required capability.");
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
// Validate acquisition before publishing; no implementation is invoked.
|
|
236
|
+
if (prepared.some((entry) => !entry.unavailable)) {
|
|
237
|
+
const handle = this.host.acquire(provider);
|
|
238
|
+
try {
|
|
239
|
+
if (typeof handle.value?.invoke !== "function")
|
|
240
|
+
throw new Error("Provider has no capability implementation.");
|
|
241
|
+
}
|
|
242
|
+
finally {
|
|
243
|
+
void handle.release();
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
this.#descriptors = [
|
|
247
|
+
...this.#descriptors.filter((entry) => entry.provider.id !== provider.id),
|
|
248
|
+
...prepared.map((entry) => deepFreeze(entry))
|
|
249
|
+
];
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
function visible(scope, visibility) {
|
|
253
|
+
return scope.kind === "global" || (scope.kind === "task"
|
|
254
|
+
? visibility.taskIds.includes(scope.id) : visibility.projectIds.includes(scope.id));
|
|
255
|
+
}
|
|
256
|
+
function result(kind, detail) {
|
|
257
|
+
return { kind, effect: "none", operations: [], ...(detail === undefined ? {} : { detail }) };
|
|
258
|
+
}
|
|
259
|
+
function accumulatedEffect(a, b) {
|
|
260
|
+
return a === "confirmed" || b === "confirmed" ? "confirmed" : a === "possible" || b === "possible" ? "possible" : "none";
|
|
261
|
+
}
|
|
262
|
+
function deepFreeze(value) {
|
|
263
|
+
if (value !== null && typeof value === "object") {
|
|
264
|
+
Object.values(value).forEach(deepFreeze);
|
|
265
|
+
Object.freeze(value);
|
|
266
|
+
}
|
|
267
|
+
return value;
|
|
268
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { isDeepStrictEqual } from "node:util";
|
|
2
|
+
export function checkCapabilitySchema(schema) {
|
|
3
|
+
if (typeof schema !== "object" || schema === null || Array.isArray(schema)) {
|
|
4
|
+
throw new Error("Capability schema must be an object.");
|
|
5
|
+
}
|
|
6
|
+
const keywords = new Set([
|
|
7
|
+
"type", "properties", "required", "additionalProperties", "items",
|
|
8
|
+
"enum", "const", "anyOf", "minLength", "minItems"
|
|
9
|
+
]);
|
|
10
|
+
for (const key of Object.keys(schema)) {
|
|
11
|
+
if (!keywords.has(key))
|
|
12
|
+
throw new Error(`Unsupported schema keyword: ${key}.`);
|
|
13
|
+
}
|
|
14
|
+
if (schema.type !== undefined
|
|
15
|
+
&& !["object", "array", "string", "number", "integer", "boolean", "null"].includes(schema.type)) {
|
|
16
|
+
throw new Error("Unsupported schema type.");
|
|
17
|
+
}
|
|
18
|
+
if (schema.required !== undefined && (!Array.isArray(schema.required)
|
|
19
|
+
|| schema.required.some((key) => typeof key !== "string")))
|
|
20
|
+
throw new Error("Invalid schema required.");
|
|
21
|
+
for (const bound of [schema.minLength, schema.minItems]) {
|
|
22
|
+
if (bound !== undefined && (!Number.isSafeInteger(bound) || bound < 0))
|
|
23
|
+
throw new Error("Invalid schema bound.");
|
|
24
|
+
}
|
|
25
|
+
if (schema.enum !== undefined && (!Array.isArray(schema.enum) || schema.enum.length === 0)) {
|
|
26
|
+
throw new Error("Invalid schema enum.");
|
|
27
|
+
}
|
|
28
|
+
if (schema.anyOf !== undefined) {
|
|
29
|
+
if (!Array.isArray(schema.anyOf) || !schema.anyOf.length)
|
|
30
|
+
throw new Error("Invalid schema anyOf.");
|
|
31
|
+
schema.anyOf.forEach(checkCapabilitySchema);
|
|
32
|
+
}
|
|
33
|
+
if (schema.properties !== undefined) {
|
|
34
|
+
if (typeof schema.properties !== "object" || schema.properties === null || Array.isArray(schema.properties)) {
|
|
35
|
+
throw new Error("Invalid schema properties.");
|
|
36
|
+
}
|
|
37
|
+
Object.values(schema.properties).forEach(checkCapabilitySchema);
|
|
38
|
+
}
|
|
39
|
+
if (schema.items !== undefined)
|
|
40
|
+
checkCapabilitySchema(schema.items);
|
|
41
|
+
if (schema.additionalProperties !== undefined && typeof schema.additionalProperties !== "boolean") {
|
|
42
|
+
checkCapabilitySchema(schema.additionalProperties);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
export function capabilitySchemaError(schema, value, path = "$") {
|
|
46
|
+
if ("const" in schema && !isDeepStrictEqual(value, schema.const))
|
|
47
|
+
return `${path}: unexpected value.`;
|
|
48
|
+
if (schema.enum && !schema.enum.some((item) => isDeepStrictEqual(item, value)))
|
|
49
|
+
return `${path}: not in enum.`;
|
|
50
|
+
if (schema.anyOf && !schema.anyOf.some((branch) => capabilitySchemaError(branch, value) === undefined)) {
|
|
51
|
+
return `${path}: no matching schema branch.`;
|
|
52
|
+
}
|
|
53
|
+
const type = value === null ? "null" : Array.isArray(value) ? "array" : typeof value;
|
|
54
|
+
if (schema.type !== undefined && (schema.type === "integer"
|
|
55
|
+
? typeof value !== "number" || !Number.isSafeInteger(value)
|
|
56
|
+
: type !== schema.type))
|
|
57
|
+
return `${path}: expected ${schema.type}.`;
|
|
58
|
+
if (typeof value === "number" && !Number.isFinite(value))
|
|
59
|
+
return `${path}: non-finite number.`;
|
|
60
|
+
if (typeof value === "string" && schema.minLength !== undefined && value.length < schema.minLength) {
|
|
61
|
+
return `${path}: string too short.`;
|
|
62
|
+
}
|
|
63
|
+
if (Array.isArray(value)) {
|
|
64
|
+
if (schema.minItems !== undefined && value.length < schema.minItems)
|
|
65
|
+
return `${path}: array too short.`;
|
|
66
|
+
if (schema.items) {
|
|
67
|
+
for (const [index, item] of value.entries()) {
|
|
68
|
+
const error = capabilitySchemaError(schema.items, item, `${path}[${index}]`);
|
|
69
|
+
if (error)
|
|
70
|
+
return error;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
else if (type === "object") {
|
|
75
|
+
const record = value;
|
|
76
|
+
for (const key of schema.required ?? []) {
|
|
77
|
+
if (!Object.hasOwn(record, key))
|
|
78
|
+
return `${path}.${key}: required.`;
|
|
79
|
+
}
|
|
80
|
+
for (const [key, item] of Object.entries(record)) {
|
|
81
|
+
const property = Object.hasOwn(schema.properties ?? {}, key) ? schema.properties[key] : undefined;
|
|
82
|
+
if (!property && schema.additionalProperties === false)
|
|
83
|
+
return `${path}.${key}: unexpected property.`;
|
|
84
|
+
const child = property ?? (typeof schema.additionalProperties === "object" ? schema.additionalProperties : undefined);
|
|
85
|
+
const error = child && capabilitySchemaError(child, item, `${path}.${key}`);
|
|
86
|
+
if (error)
|
|
87
|
+
return error;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return undefined;
|
|
91
|
+
}
|
|
@@ -19,6 +19,12 @@ export class InstanceHost {
|
|
|
19
19
|
});
|
|
20
20
|
return ref;
|
|
21
21
|
}
|
|
22
|
+
/** Read-only availability for the rebuildable capability directory. This is
|
|
23
|
+
* not an acquisition or a grant; acquire still checks immediately before use. */
|
|
24
|
+
isAvailable(implementation) {
|
|
25
|
+
const instance = this.#instances.get(implementationKey(implementation));
|
|
26
|
+
return !this.#closed && instance !== undefined && !instance.detached;
|
|
27
|
+
}
|
|
22
28
|
acquire(implementation) {
|
|
23
29
|
const instance = this.#instances.get(implementationKey(implementation));
|
|
24
30
|
if (this.#closed || instance === undefined || instance.detached) {
|
|
@@ -1,19 +1,22 @@
|
|
|
1
1
|
import { createDurableJobControl } from "../controller/jobControl.js";
|
|
2
2
|
import { JOB_RUNNER_IMPLEMENTATION } from "../job/durableJob.js";
|
|
3
3
|
import { InstanceHost } from "./instanceHost.js";
|
|
4
|
+
import { createBuiltinCapabilities } from "./builtinCapabilities.js";
|
|
4
5
|
/** Called once by the existing Controller root. Does not open a Store, start
|
|
5
6
|
* another Controller, or provide arbitrary persistence to plugin code.
|
|
6
7
|
* T02 registers contributions on this Host and wraps this same Job control.
|
|
7
8
|
*/
|
|
8
|
-
export function createKernelPorts(store, runner) {
|
|
9
|
+
export function createKernelPorts(store, runner, signal = () => undefined) {
|
|
9
10
|
const host = new InstanceHost();
|
|
10
11
|
const runnerImplementation = host.attach(JOB_RUNNER_IMPLEMENTATION, runner);
|
|
11
12
|
const runnerHandle = host.acquire(runnerImplementation);
|
|
12
13
|
const jobImplementation = host.attach({ id: "yui:job-control", generation: "1" }, createDurableJobControl(store));
|
|
13
14
|
// The Controller is a long-lived consumer of this exact implementation.
|
|
14
15
|
const jobHandle = host.acquire(jobImplementation);
|
|
16
|
+
const capabilities = createBuiltinCapabilities(host, store, jobHandle.value, signal);
|
|
15
17
|
return {
|
|
16
18
|
host,
|
|
19
|
+
capabilities,
|
|
17
20
|
jobImplementation,
|
|
18
21
|
runnerImplementation,
|
|
19
22
|
runner: runnerHandle.value,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zq-silk/yui",
|
|
3
|
-
"version": "0.15.
|
|
3
|
+
"version": "0.15.7",
|
|
4
4
|
"description": "Local control plane for long-running native agent CLI sessions backed by tmux.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"private": false,
|
|
@@ -39,12 +39,12 @@
|
|
|
39
39
|
],
|
|
40
40
|
"repository": {
|
|
41
41
|
"type": "git",
|
|
42
|
-
"url": "git+https://github.com/zhangqian-silk/
|
|
42
|
+
"url": "git+https://github.com/zhangqian-silk/yui.git"
|
|
43
43
|
},
|
|
44
44
|
"bugs": {
|
|
45
|
-
"url": "https://github.com/zhangqian-silk/
|
|
45
|
+
"url": "https://github.com/zhangqian-silk/yui/issues"
|
|
46
46
|
},
|
|
47
|
-
"homepage": "https://github.com/zhangqian-silk/
|
|
47
|
+
"homepage": "https://github.com/zhangqian-silk/yui#readme",
|
|
48
48
|
"dependencies": {
|
|
49
49
|
"@xterm/addon-fit": "^0.11.0",
|
|
50
50
|
"@xterm/xterm": "^6.0.0",
|