@capybara-hq/kernel 0.1.0-rc.25 → 0.1.0-rc.27
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/admin/outbox-dead.service.d.ts +7 -3
- package/dist/admin/outbox-dead.service.js +17 -6
- package/dist/admin/overview.service.d.ts +6 -6
- package/dist/admin/overview.service.js +7 -7
- package/dist/background/wake-rules.d.ts +5 -0
- package/dist/background/wake-rules.js +16 -5
- package/dist/catalog/catalog-content.d.ts +6 -1
- package/dist/catalog/catalog-content.js +2 -1
- package/dist/catalog/marketplaces.service.d.ts +1 -1
- package/dist/catalog/org-catalog.service.js +5 -2
- package/dist/catalog/plugin-install.d.ts +5 -0
- package/dist/catalog/plugin-install.js +37 -1
- package/dist/channels/channels.module.d.ts +9 -8
- package/dist/channels/channels.module.js +11 -10
- package/dist/channels/connections.service.d.ts +3 -3
- package/dist/channels/connections.service.js +6 -6
- package/dist/channels/deliver-job.d.ts +10 -3
- package/dist/channels/deliver-job.js +7 -3
- package/dist/channels/jobs.d.ts +11 -4
- package/dist/channels/jobs.js +18 -3
- package/dist/channels/outbound.d.ts +5 -2
- package/dist/channels/outbound.js +9 -2
- package/dist/conversations/conversations.controller.d.ts +4 -2
- package/dist/conversations/conversations.controller.js +4 -2
- package/dist/conversations/conversations.service.d.ts +37 -4
- package/dist/conversations/conversations.service.js +77 -20
- package/dist/conversations/events.d.ts +6 -0
- package/dist/conversations/events.js +6 -0
- package/dist/db/kernel-schema.d.ts +10 -1
- package/dist/devices/devices.service.d.ts +14 -6
- package/dist/devices/devices.service.js +20 -7
- package/dist/evals/evals.module.js +5 -1
- package/dist/evals/jobs.d.ts +14 -6
- package/dist/evals/jobs.js +21 -19
- package/dist/evals/runs.service.d.ts +5 -6
- package/dist/evals/runs.service.js +11 -33
- package/dist/infra/outbox/outbox.service.d.ts +6 -0
- package/dist/infra/outbox/outbox.service.js +51 -17
- package/dist/infra/outbox/registry.d.ts +52 -0
- package/dist/infra/outbox/registry.js +57 -0
- package/dist/local-devices/device-policy.service.d.ts +131 -26
- package/dist/local-devices/device-policy.service.js +341 -145
- package/dist/local-devices/machines.d.ts +17 -2
- package/dist/local-devices/machines.js +66 -5
- package/dist/local-devices/policy-op-jobs.d.ts +15 -0
- package/dist/local-devices/policy-op-jobs.js +12 -0
- package/dist/local-devices/policy-summary.d.ts +36 -3
- package/dist/local-devices/policy-summary.js +47 -1
- package/dist/orgs/orgs.service.js +4 -1
- package/dist/overrides.d.ts +4 -0
- package/dist/overrides.js +2 -0
- package/dist/platform/ops/conversations.ops.js +1 -1
- package/dist/runtime/maintenance.d.ts +6 -3
- package/dist/runtime/maintenance.js +21 -7
- package/dist/runtime/plugin-context.service.d.ts +9 -3
- package/dist/runtime/plugin-context.service.js +17 -5
- package/dist/runtime/run-context.service.d.ts +7 -3
- package/dist/runtime/run-context.service.js +12 -2
- package/dist/runtime/run-finish.d.ts +2 -2
- package/dist/runtime/run-finish.js +11 -6
- package/dist/runtime/run-jobs.d.ts +21 -4
- package/dist/runtime/run-jobs.js +29 -6
- package/dist/runtime/run-orphan-check.job.d.ts +14 -11
- package/dist/runtime/run-orphan-check.job.js +16 -11
- package/dist/runtime/run-queue.d.ts +23 -31
- package/dist/runtime/run-queue.js +62 -64
- package/dist/runtime/run-status.d.ts +12 -0
- package/dist/runtime/run-status.js +21 -0
- package/dist/runtime/run-worker.d.ts +10 -2
- package/dist/runtime/run-worker.js +44 -19
- package/dist/runtime/step-log.d.ts +6 -5
- package/dist/runtime/step-log.js +6 -5
- package/dist/runtime/usage-report.d.ts +3 -1
- package/dist/runtime/usage-report.js +4 -2
- package/dist/runtime/waits.d.ts +16 -6
- package/dist/runtime/waits.js +37 -15
- package/dist/schedules/scan.job.d.ts +6 -6
- package/dist/schedules/scan.job.js +6 -6
- package/dist/skills/catalog.d.ts +7 -3
- package/dist/skills/catalog.js +12 -2
- package/dist/tools/approvals.service.d.ts +96 -19
- package/dist/tools/approvals.service.js +300 -67
- package/dist/tools/builtins/sandbox/task-output.js +21 -6
- package/dist/tools/connectors.service.js +0 -4
- package/dist/tools/device-op-resolver.d.ts +40 -0
- package/dist/tools/device-op-resolver.js +121 -0
- package/dist/tools/network-asks.d.ts +1 -1
- package/dist/tools/network-asks.js +4 -3
- package/dist/tools/tools.module.js +4 -0
- package/package.json +2 -2
- package/src/migrations/0125_conversation_tree_stopped_at.sql +5 -0
|
@@ -8,6 +8,7 @@ var __param = (this && this.__param) || function (paramIndex, decorator) {
|
|
|
8
8
|
return function (target, key) { decorator(target, key, paramIndex); }
|
|
9
9
|
};
|
|
10
10
|
import { randomUUID } from "node:crypto";
|
|
11
|
+
import { isDeepStrictEqual } from "node:util";
|
|
11
12
|
import { connectorToolKey, parseToolKey, validateAllowHost, } from "@capybara-hq/plugin-sdk";
|
|
12
13
|
import { Inject, Injectable } from "@nestjs/common";
|
|
13
14
|
import { sql } from "kysely";
|
|
@@ -17,17 +18,19 @@ import { conversationSourceOf } from "../conversations/source.js";
|
|
|
17
18
|
import { ApiError } from "../errors.js";
|
|
18
19
|
import { audit } from "../identity/audit.js";
|
|
19
20
|
import { KernelConfigService } from "../infra/config/kernel-config.service.js";
|
|
21
|
+
import { afterCommit } from "../infra/database/after-commit.js";
|
|
20
22
|
import { KernelDb } from "../infra/database/kernel-db.js";
|
|
21
23
|
import { TransactionHost } from "../infra/database/transaction-host.js";
|
|
22
24
|
import { Transactional } from "../infra/database/transactional.decorator.js";
|
|
23
25
|
import { KernelEvents } from "../infra/events/kernel-events.js";
|
|
24
26
|
import { LockService } from "../infra/lock/lock.service.js";
|
|
25
27
|
import { WithLock } from "../infra/lock/with-lock.decorator.js";
|
|
26
|
-
import { DevicePolicyService } from "../local-devices/device-policy.service.js";
|
|
28
|
+
import { DevicePolicyService, } from "../local-devices/device-policy.service.js";
|
|
29
|
+
import { pendingPolicyOpsOf } from "../local-devices/policy-summary.js";
|
|
27
30
|
import { ORG_ROLES, roleAtLeast } from "../orgs/roles.js";
|
|
28
31
|
import { RunResume } from "../runtime/run-jobs.js";
|
|
29
32
|
import { rootConversationOf } from "../runtime/subagents/child.js";
|
|
30
|
-
import { SandboxService } from "../sandbox/sandbox.service.js";
|
|
33
|
+
import { deviceNotFound, SandboxService } from "../sandbox/sandbox.service.js";
|
|
31
34
|
import { readScheduleSettings } from "../schedules/effective-settings.js";
|
|
32
35
|
import { grantRefusalOf, NETWORK_GRANT_PREFIX, NETWORK_GRANT_SPEC_HASH, networkGrantRefusal, } from "../schedules/grant-rules.js";
|
|
33
36
|
import { ScheduledTaskStore } from "../schedules/store.js";
|
|
@@ -55,6 +58,18 @@ export const NETWORK_ASK_TOOL_KEY = "Bash";
|
|
|
55
58
|
/** The spec hash of a network ask (no tool definition is approved). */
|
|
56
59
|
const NETWORK_ASK_SPEC_HASH = "network";
|
|
57
60
|
const notFound = () => new ApiError("approval.not_found", "Approval not found");
|
|
61
|
+
/**
|
|
62
|
+
* A decision refused before it wrote anything (the ask closed, expired or is not the account's to
|
|
63
|
+
* decide): the device op's outcome is still recorded in the same transaction.
|
|
64
|
+
*/
|
|
65
|
+
const refusedBeforeWrite = (e) => e instanceof ApiError &&
|
|
66
|
+
(e.code === "approval.already_decided" || e.code === "approval.not_found");
|
|
67
|
+
/** A pending network ask's reason when its device change got no answer (`detail.attempt`). */
|
|
68
|
+
const UNCONFIRMED_ATTEMPT = "The device did not confirm the change to its network rules; its next connection tells whether it took effect.";
|
|
69
|
+
/** A pending network ask's reason when the device applied it but its chooser cannot decide. */
|
|
70
|
+
const APPLIED_UNDECIDED_ATTEMPT = "The device allowed it, but the person who chose this can no longer decide this request.";
|
|
71
|
+
/** A pending network ask's reason when its device change failed without a message. */
|
|
72
|
+
const NOT_APPLIED_ATTEMPT = "The device did not apply the change to its network rules.";
|
|
58
73
|
const iso = (v) => new Date(v).toISOString();
|
|
59
74
|
const isRole = (v) => v !== null && ORG_ROLES.includes(v);
|
|
60
75
|
/** The scopes of an approval without stored ones: a role escalation is approved only once. */
|
|
@@ -126,7 +141,10 @@ let ApprovalsService = class ApprovalsService {
|
|
|
126
141
|
constructor(db, config, eventLog, events, txHost, schedules,
|
|
127
142
|
/** A trial approval is kept only for a tool the task may hold as a grant (its agent's tools). */
|
|
128
143
|
registry,
|
|
129
|
-
/**
|
|
144
|
+
/**
|
|
145
|
+
* 「这台设备一直允许」 records the device change's intent, sends the `policy` op, then records
|
|
146
|
+
* its outcome with the decision.
|
|
147
|
+
*/
|
|
130
148
|
policies,
|
|
131
149
|
/** One decision of a network ask at a time, across replicas (the op is sent before the transaction). */
|
|
132
150
|
lockService,
|
|
@@ -391,12 +409,13 @@ let ApprovalsService = class ApprovalsService {
|
|
|
391
409
|
* A network ask's decision (network ask-to-allow spec §5.2), one at a time per ask across
|
|
392
410
|
* replicas: only the device owner (`requested_for`) decides, in a signed-in session (a personal
|
|
393
411
|
* access token is refused; the route already refuses service and embed tokens); the allowed
|
|
394
|
-
* hosts are a subset of those asked. 「这台设备一直允许」
|
|
395
|
-
*
|
|
396
|
-
* as a run grant with the reason, and when the device refuses (its owner takes no
|
|
397
|
-
* as `always` with outcome `refused` and nothing granted
|
|
398
|
-
*
|
|
399
|
-
*
|
|
412
|
+
* hosts are a subset of those asked. 「这台设备一直允许」 goes through the device change's intent
|
|
413
|
+
* ({@link decideAlways}): when the device cannot be reached or lacks the feature the decision is
|
|
414
|
+
* recorded as a run grant with the reason, and when the device refuses (its owner takes no
|
|
415
|
+
* remote grants) as `always` with outcome `refused` and nothing granted; the decision and the
|
|
416
|
+
* op's outcome are one transaction. Every decision resumes the run in its transaction and is
|
|
417
|
+
* announced after the commit; a change the device applied after the ask closed is audited
|
|
418
|
+
* (`late`) and answered with a 409 that says so.
|
|
400
419
|
*/
|
|
401
420
|
async decideNetwork(orgId, id, accountId, body, actorTokenId, via) {
|
|
402
421
|
const row = await this.db
|
|
@@ -458,18 +477,11 @@ let ApprovalsService = class ApprovalsService {
|
|
|
458
477
|
}
|
|
459
478
|
network = { wanted: granted, granted, outcome: "once", via };
|
|
460
479
|
if (scope === "always") {
|
|
461
|
-
const
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
network =
|
|
467
|
-
applied === null
|
|
468
|
-
? { wanted: granted, granted, outcome: "always", via }
|
|
469
|
-
: applied === "refused"
|
|
470
|
-
? // the device's owner takes no remote grants: nothing is allowed, not even this run
|
|
471
|
-
{ wanted: granted, granted: [], outcome: "refused", fallback: applied, via }
|
|
472
|
-
: { wanted: granted, granted, outcome: "once_fallback", fallback: applied, via };
|
|
480
|
+
const decided = await this.decideAlways(orgId, ask, accountId, body, granted, via);
|
|
481
|
+
// null: the resolver decided it as this owner meanwhile (announced by its commit)
|
|
482
|
+
if (decided.toSeq !== null)
|
|
483
|
+
await this.announceDecided(decided.record, decided.toSeq);
|
|
484
|
+
return decided.record;
|
|
473
485
|
}
|
|
474
486
|
}
|
|
475
487
|
else {
|
|
@@ -480,46 +492,267 @@ let ApprovalsService = class ApprovalsService {
|
|
|
480
492
|
}
|
|
481
493
|
network = { wanted: [], granted: [], outcome: null, via };
|
|
482
494
|
}
|
|
483
|
-
|
|
495
|
+
const decided = await this.decideIn(orgId, id, accountId, body, actorTokenId, network);
|
|
496
|
+
await this.announceDecided(decided.record, decided.toSeq);
|
|
497
|
+
return decided.record;
|
|
498
|
+
}
|
|
499
|
+
/**
|
|
500
|
+
* 「这台设备一直允许」 (transactional outbox spec §7): the intent of the device change (naming the
|
|
501
|
+
* ask) is recorded, the `policy` op sent (no transaction open), then {@link settleNetworkOp}
|
|
502
|
+
* decides the ask and records the op's outcome in one transaction. When nothing could be sent
|
|
503
|
+
* (the device is offline, deleted, or its program lacks the feature) the decision is a run
|
|
504
|
+
* grant with the reason. The device's answer decides: applied → `always`; refused → `always`
|
|
505
|
+
* with outcome `refused`, nothing granted; no answer → a run grant (`once_fallback`, `offline`)
|
|
506
|
+
* while the op stays `unknown` for the machine's next hello or the resolver. A device that
|
|
507
|
+
* reconnects during the call and reports the op applied lets the resolver approve the ask as
|
|
508
|
+
* this owner first: that decision is returned (`toSeq` null: already announced), never a 409;
|
|
509
|
+
* when it granted other hosts than this call's (another of the owner's changes) and this call's
|
|
510
|
+
* change was not confirmed, `approval.already_decided` says so. A device deleted during the
|
|
511
|
+
* call is `device.not_found` (the op recorded unknown).
|
|
512
|
+
*/
|
|
513
|
+
async decideAlways(orgId, ask, accountId, body, granted, via) {
|
|
514
|
+
const detail = ask.detail;
|
|
515
|
+
const accountName = (await this.displayNameOf(accountId)) ?? accountId;
|
|
516
|
+
let begun;
|
|
484
517
|
try {
|
|
485
|
-
|
|
518
|
+
begun = await this.policies.begin({
|
|
519
|
+
orgId,
|
|
520
|
+
deviceId: detail.deviceId,
|
|
521
|
+
change: { add: granted },
|
|
522
|
+
source: { approvalId: ask.id, accountId, accountName, via },
|
|
523
|
+
});
|
|
486
524
|
}
|
|
487
525
|
catch (e) {
|
|
488
|
-
// the device
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
526
|
+
// the device was deleted meanwhile: nothing to write to, the run cannot go on there either
|
|
527
|
+
if (!(e instanceof ApiError && e.code === "device.not_found"))
|
|
528
|
+
throw e;
|
|
529
|
+
begun = { outcome: { applied: false, reason: "offline", message: e.message } };
|
|
530
|
+
}
|
|
531
|
+
if ("outcome" in begun) {
|
|
532
|
+
// nothing was sent: a run grant with the reason
|
|
533
|
+
const fallback = begun.outcome.applied ? "offline" : begun.outcome.reason;
|
|
534
|
+
return this.decideIn(orgId, ask.id, accountId, body, undefined, {
|
|
535
|
+
wanted: granted,
|
|
536
|
+
granted,
|
|
537
|
+
outcome: "once_fallback",
|
|
538
|
+
fallback,
|
|
539
|
+
via,
|
|
540
|
+
});
|
|
541
|
+
}
|
|
542
|
+
const { intent } = begun;
|
|
543
|
+
const sent = await this.policies.send(intent);
|
|
544
|
+
if (sent.notFound) {
|
|
545
|
+
// deleted during the call: nothing to grant on, the run cannot go on there either
|
|
546
|
+
await this.settleOpOnly({ machineId: intent.machineId, opId: intent.opId }, sent.result);
|
|
547
|
+
throw deviceNotFound();
|
|
548
|
+
}
|
|
549
|
+
const network = sent.result.outcome === "applied"
|
|
550
|
+
? { wanted: granted, granted, outcome: "always", via }
|
|
551
|
+
: sent.result.outcome === "refused"
|
|
552
|
+
? // the device's owner takes no remote grants: nothing is allowed, not even this run
|
|
553
|
+
{ wanted: granted, granted: [], outcome: "refused", fallback: "refused", via }
|
|
554
|
+
: { wanted: granted, granted, outcome: "once_fallback", fallback: "offline", via };
|
|
555
|
+
const settled = await this.settleNetworkOp(orgId, ask.id, accountId, body, network, {
|
|
556
|
+
machineId: intent.machineId,
|
|
557
|
+
opId: intent.opId,
|
|
558
|
+
result: sent.result,
|
|
559
|
+
});
|
|
560
|
+
if ("decided" in settled)
|
|
561
|
+
return settled.decided;
|
|
562
|
+
if (settled.closed.code === "approval.already_decided" && !settled.late) {
|
|
563
|
+
// the machine reconnected mid-call and reported the op: the resolver approved it as this
|
|
564
|
+
// owner, with this change; it is their decision, not a conflict
|
|
565
|
+
const now = await this.db
|
|
566
|
+
.selectFrom("approvals")
|
|
567
|
+
.selectAll()
|
|
568
|
+
.where("id", "=", ask.id)
|
|
569
|
+
.executeTakeFirst();
|
|
570
|
+
const record = now === undefined ? null : toRecord(now);
|
|
571
|
+
if (record?.status === "approved" &&
|
|
572
|
+
record.detail?.outcome === "always" &&
|
|
573
|
+
record.decidedBy === accountId) {
|
|
574
|
+
const stored = [...(record.detail.grantedHosts ?? [])].sort();
|
|
575
|
+
if (isDeepStrictEqual(stored, [...granted].sort()))
|
|
576
|
+
return { record, toSeq: null };
|
|
577
|
+
// approved by another of this owner's changes (other hosts), and this call's change was
|
|
578
|
+
// not confirmed by the device: never report it as done
|
|
579
|
+
throw new ApiError("approval.already_decided", `The request was already approved to always allow ${stored.join(", ") || "no hosts"}; ` +
|
|
580
|
+
`the device did not confirm allowing ${granted.join(", ")}.`);
|
|
495
581
|
}
|
|
496
|
-
throw e;
|
|
497
582
|
}
|
|
498
|
-
|
|
499
|
-
|
|
583
|
+
// the device took the hosts, but a reply, the expiry or a stop closed the ask meanwhile: the
|
|
584
|
+
// change stands (the owner chose it), audited late in the settle transaction, and said
|
|
585
|
+
if (settled.late && settled.closed.code === "approval.already_decided") {
|
|
586
|
+
throw new ApiError("approval.already_decided", `The device now allows ${granted.join(", ")}; the request had already closed.`);
|
|
587
|
+
}
|
|
588
|
+
throw settled.closed;
|
|
589
|
+
}
|
|
590
|
+
/** Records only the op's outcome, in its own transaction (the ask is left as it is). */
|
|
591
|
+
async settleOpOnly(op, result) {
|
|
592
|
+
await this.policies.settleIn(op, result);
|
|
593
|
+
}
|
|
594
|
+
/**
|
|
595
|
+
* The resolve transaction of a network ask's device change: the ask decided as `network` says
|
|
596
|
+
* ({@link decideTx}: lock order conversation → approval → run), then the op's outcome recorded
|
|
597
|
+
* (`DevicePolicyService.settleIn`: the machine's row). When the ask closed meanwhile (a reply,
|
|
598
|
+
* the expiry, a stop) only the op's outcome is recorded, and a change the device applied is
|
|
599
|
+
* audited `device.network_allowed` with `late: true` in the same transaction.
|
|
600
|
+
*/
|
|
601
|
+
async settleNetworkOp(orgId, id, accountId, body, network, op) {
|
|
602
|
+
let decided;
|
|
603
|
+
try {
|
|
604
|
+
decided = await this.decideTx(orgId, id, accountId, body, undefined, network);
|
|
605
|
+
}
|
|
606
|
+
catch (e) {
|
|
607
|
+
// refused before any write (closed, expired, gone): the op's outcome is recorded anyway
|
|
608
|
+
if (!refusedBeforeWrite(e))
|
|
609
|
+
throw e;
|
|
610
|
+
const late = await this.settleClosedIn(id, op, network.via);
|
|
611
|
+
return { closed: e, late };
|
|
612
|
+
}
|
|
613
|
+
await this.policies.settleIn(op, op.result);
|
|
614
|
+
return { decided };
|
|
500
615
|
}
|
|
501
|
-
/**
|
|
502
|
-
|
|
616
|
+
/**
|
|
617
|
+
* The op's outcome for an ask that is no longer pending (in the caller's transaction): recorded,
|
|
618
|
+
* and a change the device applied audited `device.network_allowed` `late` (the change stands:
|
|
619
|
+
* the owner chose it; no inverse change is sent). True when it was audited late. `undecided`:
|
|
620
|
+
* the ask is still pending but the op's account can no longer decide it; the allow is audited
|
|
621
|
+
* without `late` (it is not after the ask) and the caller gives the ask the reason.
|
|
622
|
+
*/
|
|
623
|
+
async settleClosedIn(approvalId, op, via, undecided = false) {
|
|
624
|
+
const pendingOp = await this.pendingOpOf(op.machineId, op.opId);
|
|
625
|
+
const out = await this.policies.settleIn(op, op.result);
|
|
626
|
+
if (out === null || pendingOp === null || op.result.outcome !== "applied")
|
|
627
|
+
return false;
|
|
628
|
+
const row = await this.db
|
|
629
|
+
.selectFrom("approvals")
|
|
630
|
+
.selectAll()
|
|
631
|
+
.where("id", "=", approvalId)
|
|
632
|
+
.executeTakeFirst();
|
|
633
|
+
if (!row)
|
|
634
|
+
return false;
|
|
635
|
+
const ask = toRecord(row);
|
|
503
636
|
if (ask.detail === null)
|
|
504
|
-
return;
|
|
637
|
+
return false;
|
|
505
638
|
await audit(this.db.db, {
|
|
506
|
-
actorAccountId:
|
|
507
|
-
orgId,
|
|
639
|
+
actorAccountId: pendingOp.source.accountId,
|
|
640
|
+
orgId: ask.orgId,
|
|
508
641
|
action: "device.network_allowed",
|
|
509
642
|
target: ask.detail.deviceId,
|
|
510
643
|
detail: {
|
|
511
644
|
approvalId: ask.id,
|
|
512
645
|
deviceId: ask.detail.deviceId,
|
|
513
|
-
hosts:
|
|
646
|
+
hosts: pendingOp.add,
|
|
514
647
|
scope: "always",
|
|
515
648
|
outcome: "always",
|
|
516
|
-
late: true,
|
|
517
|
-
by:
|
|
518
|
-
via
|
|
649
|
+
...(undecided ? { undecided: true } : { late: true }),
|
|
650
|
+
by: pendingOp.source.accountId,
|
|
651
|
+
via,
|
|
519
652
|
conversationId: ask.conversationId,
|
|
520
653
|
runId: ask.runId,
|
|
521
654
|
},
|
|
522
655
|
});
|
|
656
|
+
return true;
|
|
657
|
+
}
|
|
658
|
+
/** The live intent `opId` on machine `machineId` (null: settled already, or expired). */
|
|
659
|
+
async pendingOpOf(machineId, opId) {
|
|
660
|
+
const r = await this.db
|
|
661
|
+
.selectFrom("device_machines")
|
|
662
|
+
.select("pending_policy_ops")
|
|
663
|
+
.where("id", "=", machineId)
|
|
664
|
+
.executeTakeFirst();
|
|
665
|
+
const op = pendingPolicyOpsOf(r?.pending_policy_ops).find((o) => o.id === opId);
|
|
666
|
+
return op === undefined || op.until <= Date.now() ? null : op;
|
|
667
|
+
}
|
|
668
|
+
/**
|
|
669
|
+
* The `device-op-resolve` job's outcome of an op that answers network ask `op.source.approvalId`
|
|
670
|
+
* (transactional outbox spec §7.4), in the resolver's transaction: under the ask's locks
|
|
671
|
+
* (conversation, then the approval) the op is read again (settled meanwhile: nothing to do).
|
|
672
|
+
* Applied, and the ask still pending → approved `always` by the op's account (`via` as it
|
|
673
|
+
* asked), the run resumed in this transaction as every decision does; the ask closed → audited
|
|
674
|
+
* `late`; still pending but the op's account can no longer decide it → the allow audited (not
|
|
675
|
+
* late) and the ask left pending with `applied_undecided`. Not applied (refused, never received,
|
|
676
|
+
* interrupted) or still unknown → the op's outcome recorded and the ask left pending with the
|
|
677
|
+
* reason (`detail.attempt`) and an `approval_updated` event so the card shows it: no person is
|
|
678
|
+
* there to fall back to a run grant (D2).
|
|
679
|
+
*/
|
|
680
|
+
async settleFromResolver(ref, result) {
|
|
681
|
+
const t = this.db.db;
|
|
682
|
+
const op = { machineId: ref.machineId, opId: ref.opId, result };
|
|
683
|
+
if (!(await this.lockConversationOf(ref.approvalId))) {
|
|
684
|
+
await this.policies.settleIn(op, result);
|
|
685
|
+
return;
|
|
686
|
+
}
|
|
687
|
+
const row = await t
|
|
688
|
+
.selectFrom("approvals")
|
|
689
|
+
.selectAll()
|
|
690
|
+
.select(sql `expires_at <= now()`.as("expired"))
|
|
691
|
+
.where("id", "=", ref.approvalId)
|
|
692
|
+
.forUpdate()
|
|
693
|
+
.executeTakeFirst();
|
|
694
|
+
const pendingOp = await this.pendingOpOf(ref.machineId, ref.opId);
|
|
695
|
+
if (row === undefined || pendingOp === null) {
|
|
696
|
+
await this.policies.settleIn(op, result);
|
|
697
|
+
return;
|
|
698
|
+
}
|
|
699
|
+
const ask = toRecord(row);
|
|
700
|
+
const open = ask.status === "pending" && !row.expired && ask.kind === "network";
|
|
701
|
+
const via = pendingOp.source.via;
|
|
702
|
+
if (result.outcome === "applied") {
|
|
703
|
+
if (!open) {
|
|
704
|
+
await this.settleClosedIn(ask.id, op, via);
|
|
705
|
+
return;
|
|
706
|
+
}
|
|
707
|
+
let decided;
|
|
708
|
+
try {
|
|
709
|
+
decided = await this.decideTx(ask.orgId, ask.id, pendingOp.source.accountId, { decision: "approve", scope: "always" }, undefined, { wanted: pendingOp.add, granted: pendingOp.add, outcome: "always", via });
|
|
710
|
+
}
|
|
711
|
+
catch (e) {
|
|
712
|
+
// the op's account may no longer decide it: the change stands, the ask waits with why
|
|
713
|
+
if (!refusedBeforeWrite(e))
|
|
714
|
+
throw e;
|
|
715
|
+
if (await this.settleClosedIn(ask.id, op, via, true)) {
|
|
716
|
+
await this.attemptIn(ask, ref.opId, {
|
|
717
|
+
outcome: "applied_undecided",
|
|
718
|
+
message: APPLIED_UNDECIDED_ATTEMPT,
|
|
719
|
+
});
|
|
720
|
+
}
|
|
721
|
+
return;
|
|
722
|
+
}
|
|
723
|
+
await this.policies.settleIn(op, result);
|
|
724
|
+
afterCommit(() => this.announceDecided(decided.record, decided.toSeq));
|
|
725
|
+
return;
|
|
726
|
+
}
|
|
727
|
+
const out = await this.policies.settleIn(op, result);
|
|
728
|
+
if (out === null || !open || ask.detail === null)
|
|
729
|
+
return;
|
|
730
|
+
await this.attemptIn(ask, ref.opId, result.outcome === "unknown"
|
|
731
|
+
? { outcome: "unknown", message: UNCONFIRMED_ATTEMPT }
|
|
732
|
+
: { outcome: "failed", message: out.applied ? NOT_APPLIED_ATTEMPT : out.message });
|
|
733
|
+
}
|
|
734
|
+
/**
|
|
735
|
+
* A pending network ask's reason (`detail.attempt`) and its `approval_updated` event (one per
|
|
736
|
+
* op and outcome), in the caller's transaction with the ask locked; notified after the commit.
|
|
737
|
+
*/
|
|
738
|
+
async attemptIn(ask, opId, attempt) {
|
|
739
|
+
if (ask.detail === null)
|
|
740
|
+
return;
|
|
741
|
+
await this.db.db
|
|
742
|
+
.updateTable("approvals")
|
|
743
|
+
.set({ detail: JSON.stringify({ ...ask.detail, attempt }) })
|
|
744
|
+
.where("id", "=", ask.id)
|
|
745
|
+
.execute();
|
|
746
|
+
const written = await this.eventLog.appendIn(ask.conversationId, [
|
|
747
|
+
{
|
|
748
|
+
type: "approval_updated",
|
|
749
|
+
runId: ask.runId,
|
|
750
|
+
idemKey: `approval_updated:${ask.id}:${opId}:${attempt.outcome}`,
|
|
751
|
+
payload: { approvalId: ask.id, callId: ask.callId },
|
|
752
|
+
},
|
|
753
|
+
]);
|
|
754
|
+
const toSeq = Math.max(...written.map((e) => e.seq));
|
|
755
|
+
afterCommit(() => this.eventLog.notify(ask.conversationId, toSeq));
|
|
523
756
|
}
|
|
524
757
|
/** The owner of a live device of the org (null: none, deleted, or of another org). */
|
|
525
758
|
async deviceOwnerOf(orgId, deviceId) {
|
|
@@ -532,28 +765,6 @@ let ApprovalsService = class ApprovalsService {
|
|
|
532
765
|
.executeTakeFirst();
|
|
533
766
|
return d?.owner_account_id ?? null;
|
|
534
767
|
}
|
|
535
|
-
/**
|
|
536
|
-
* Adds the hosts to the device's local policy (the `policy` op; never inside a transaction).
|
|
537
|
-
* Null when it was applied, else why not: the decision falls back to a run grant.
|
|
538
|
-
*/
|
|
539
|
-
async applyAlways(orgId, deviceId, hosts, source) {
|
|
540
|
-
const accountName = (await this.displayNameOf(source.accountId)) ?? source.accountId;
|
|
541
|
-
try {
|
|
542
|
-
const out = await this.policies.apply({
|
|
543
|
-
orgId,
|
|
544
|
-
deviceId,
|
|
545
|
-
change: { add: hosts },
|
|
546
|
-
source: { ...source, accountName },
|
|
547
|
-
});
|
|
548
|
-
return out.applied ? null : out.reason;
|
|
549
|
-
}
|
|
550
|
-
catch (e) {
|
|
551
|
-
// the device was deleted meanwhile: nothing to write to, the run cannot go on there either
|
|
552
|
-
if (e instanceof ApiError && e.code === "device.not_found")
|
|
553
|
-
return "offline";
|
|
554
|
-
throw e;
|
|
555
|
-
}
|
|
556
|
-
}
|
|
557
768
|
/** After the commit: the decision's events reach the page, the run's waits and IM delivery. */
|
|
558
769
|
async announceDecided(record, toSeq) {
|
|
559
770
|
await this.eventLog.notify(record.conversationId, toSeq);
|
|
@@ -577,7 +788,14 @@ let ApprovalsService = class ApprovalsService {
|
|
|
577
788
|
}
|
|
578
789
|
// its own transaction (as the legacy one was): `approval.decided` is emitted only after the
|
|
579
790
|
// decision committed, even when a caller has a transaction open
|
|
580
|
-
|
|
791
|
+
decideIn(orgId, id, accountId, body, actorTokenId, network) {
|
|
792
|
+
return this.decideTx(orgId, id, accountId, body, actorTokenId, network);
|
|
793
|
+
}
|
|
794
|
+
/**
|
|
795
|
+
* A decision in the caller's transaction: lock order conversation → approval → run (the resume).
|
|
796
|
+
* Refuses (`approval.already_decided`, `approval.not_found`, …) before any write.
|
|
797
|
+
*/
|
|
798
|
+
async decideTx(orgId, id, accountId, body, actorTokenId, network) {
|
|
581
799
|
const t = this.db.db;
|
|
582
800
|
// lock order: the conversation, the approval, then the run (as a stop of the waiting run)
|
|
583
801
|
if (!(await this.lockConversationOf(id, orgId)))
|
|
@@ -612,7 +830,7 @@ let ApprovalsService = class ApprovalsService {
|
|
|
612
830
|
if (locked.expired) {
|
|
613
831
|
throw new ApiError("approval.already_decided", "Approval has expired");
|
|
614
832
|
}
|
|
615
|
-
// a network ask is decided only
|
|
833
|
+
// a network ask is decided only with what took effect (decideNetwork, the op's resolver)
|
|
616
834
|
if ((before.kind === "network") !== (network !== undefined)) {
|
|
617
835
|
throw new Error(`approval ${id} is of kind ${before.kind}`);
|
|
618
836
|
}
|
|
@@ -1309,10 +1527,25 @@ __decorate([
|
|
|
1309
1527
|
], ApprovalsService.prototype, "decideNetwork", null);
|
|
1310
1528
|
__decorate([
|
|
1311
1529
|
Transactional()
|
|
1312
|
-
], ApprovalsService.prototype, "
|
|
1530
|
+
], ApprovalsService.prototype, "settleOpOnly", null);
|
|
1531
|
+
__decorate([
|
|
1532
|
+
Transactional()
|
|
1533
|
+
], ApprovalsService.prototype, "settleNetworkOp", null);
|
|
1534
|
+
__decorate([
|
|
1535
|
+
Transactional({ propagation: "mandatory" })
|
|
1536
|
+
], ApprovalsService.prototype, "settleClosedIn", null);
|
|
1537
|
+
__decorate([
|
|
1538
|
+
Transactional({ propagation: "mandatory" })
|
|
1539
|
+
], ApprovalsService.prototype, "settleFromResolver", null);
|
|
1540
|
+
__decorate([
|
|
1541
|
+
Transactional({ propagation: "mandatory" })
|
|
1542
|
+
], ApprovalsService.prototype, "attemptIn", null);
|
|
1313
1543
|
__decorate([
|
|
1314
1544
|
Transactional({ propagation: "requires_new" })
|
|
1315
1545
|
], ApprovalsService.prototype, "decideIn", null);
|
|
1546
|
+
__decorate([
|
|
1547
|
+
Transactional({ propagation: "mandatory" })
|
|
1548
|
+
], ApprovalsService.prototype, "decideTx", null);
|
|
1316
1549
|
__decorate([
|
|
1317
1550
|
Transactional({ propagation: "mandatory" })
|
|
1318
1551
|
], ApprovalsService.prototype, "keepTrialGrant", null);
|
|
@@ -77,14 +77,18 @@ const notFound = (id) => new ToolError(`No background task ${clip(id, 40)} in th
|
|
|
77
77
|
*/
|
|
78
78
|
export const subagentNotTaskText = (id) => `${clip(id, 40)} is a sub-agent, not a background task. Its report arrives in this conversation as a notification when it hands back or finishes. Don't poll or sleep for it: keep working or end your turn. Use SendMessage to give it more instructions.`;
|
|
79
79
|
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
80
|
-
/**
|
|
81
|
-
async function
|
|
80
|
+
/** Whether `id` is the agentId of a sub-agent in the caller's tree (not a task id). */
|
|
81
|
+
async function isSubagentId(env, ctx, id) {
|
|
82
82
|
const trimmed = id.trim();
|
|
83
|
-
|
|
83
|
+
return (!TASK_ID_RE.test(id) &&
|
|
84
|
+
env.subagents !== undefined &&
|
|
84
85
|
UUID_RE.test(trimmed) &&
|
|
85
|
-
(await env.subagents.inTree(sandboxConversationOf(ctx), trimmed.toLowerCase())))
|
|
86
|
-
|
|
87
|
-
|
|
86
|
+
(await env.subagents.inTree(sandboxConversationOf(ctx), trimmed.toLowerCase())));
|
|
87
|
+
}
|
|
88
|
+
/** {@link notFound}, or {@link subagentNotTaskText} when `id` is a sub-agent of the caller's tree. */
|
|
89
|
+
async function missingTask(env, ctx, id) {
|
|
90
|
+
if (await isSubagentId(env, ctx, id))
|
|
91
|
+
return new ToolError(subagentNotTaskText(id.trim()));
|
|
88
92
|
return notFound(id);
|
|
89
93
|
}
|
|
90
94
|
/**
|
|
@@ -286,6 +290,17 @@ export function taskOutputTool(deps) {
|
|
|
286
290
|
async run(input, ctx) {
|
|
287
291
|
const { task_id, block = true, timeout } = input;
|
|
288
292
|
const env = await taskEnv(deps, ctx);
|
|
293
|
+
// A sub-agent's agentId is answered, not refused: where its report arrives is the answer
|
|
294
|
+
// (acceptance 2026-11-13: guidance the model is expected to follow is not a failed call).
|
|
295
|
+
// TaskStop keeps the error: it cannot stop a sub-agent.
|
|
296
|
+
if (await isSubagentId(env, ctx, task_id)) {
|
|
297
|
+
const id = clip(task_id.trim(), 40);
|
|
298
|
+
return {
|
|
299
|
+
content: [{ type: "text", text: subagentNotTaskText(task_id.trim()) }],
|
|
300
|
+
isError: false,
|
|
301
|
+
display: display(`查看子智能体:${id}`, `Checked sub-agent: ${id}`),
|
|
302
|
+
};
|
|
303
|
+
}
|
|
289
304
|
let task = await ownedTask(env, ctx, task_id);
|
|
290
305
|
await recheckDevice(env, ctx);
|
|
291
306
|
if (block && task.status === "running") {
|
|
@@ -287,10 +287,6 @@ let ConnectorsService = class ConnectorsService {
|
|
|
287
287
|
checkCreateInput(actorAccountId, input) {
|
|
288
288
|
const slug = this.resolveSlug(input);
|
|
289
289
|
const endpoint = this.parseEndpoint(input.connector, input.endpoint);
|
|
290
|
-
// a defaulted slug skips plugin names itself (`freeName`)
|
|
291
|
-
if (!input.slugDefault && this.extensions.manifests().some((m) => m.name === slug)) {
|
|
292
|
-
throw fail("slug", `Slug "${slug}" is the name of an installed plugin; choose another`);
|
|
293
|
-
}
|
|
294
290
|
const auth = input.auth;
|
|
295
291
|
const credential = input.credential ?? undefined;
|
|
296
292
|
if (!isOAuth(auth)) {
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { KernelDb } from "../infra/database/kernel-db.js";
|
|
2
|
+
import { TransactionHost } from "../infra/database/transaction-host.js";
|
|
3
|
+
import { DevicePolicyService } from "../local-devices/device-policy.service.js";
|
|
4
|
+
import type { DeviceOpResolveData } from "../local-devices/policy-op-jobs.js";
|
|
5
|
+
import { ApprovalsService } from "./approvals.service.js";
|
|
6
|
+
/**
|
|
7
|
+
* The `device-op-resolve` job (transactional outbox spec §7): records what became of a device
|
|
8
|
+
* policy op whose outcome its caller did not record (a crash between the call and the resolve
|
|
9
|
+
* transaction, an answer that never came). Owed by the op's intent for when the op can no longer
|
|
10
|
+
* reach the machine, and by a hello that reported the op (`device-op-reported-<opId>`).
|
|
11
|
+
*
|
|
12
|
+
* An op without an entry any more is resolved already: a no-op. Otherwise the outcome is the one
|
|
13
|
+
* the hello reported, else the machine's answer to a read-only `policyStatus`: `applied` →
|
|
14
|
+
* recorded applied; `refused`, never received (`unknown` to the machine, or `absent` from its
|
|
15
|
+
* hello, once the op can no longer reach it) → failed; `started` (the machine stopped between
|
|
16
|
+
* its log and its answer), once the op can no longer reach it → applied when the policy it
|
|
17
|
+
* reports holds the change, else failed (`interrupted`); no answer (`offline`) → `unknown`, left
|
|
18
|
+
* for the machine's next hello. It never sends the change
|
|
19
|
+
* again (a widening after its ask closed is not allowed). An op that answers a network ask
|
|
20
|
+
* decides it when applied and the ask is still pending, else leaves it pending with the reason
|
|
21
|
+
* (`ApprovalsService.settleFromResolver`).
|
|
22
|
+
*/
|
|
23
|
+
export declare class DeviceOpResolver {
|
|
24
|
+
private readonly db;
|
|
25
|
+
readonly txHost: TransactionHost;
|
|
26
|
+
private readonly policies;
|
|
27
|
+
private readonly approvals;
|
|
28
|
+
constructor(db: KernelDb, txHost: TransactionHost, policies: Pick<DevicePolicyService, "outcomeOf" | "settleIn">, approvals: Pick<ApprovalsService, "settleFromResolver">);
|
|
29
|
+
resolve(data: DeviceOpResolveData): Promise<void>;
|
|
30
|
+
/**
|
|
31
|
+
* The outcome a hello reported; a refusal's message (a hello carries none) is the machine's
|
|
32
|
+
* answer to `policyStatus` when it is connected.
|
|
33
|
+
*/
|
|
34
|
+
private reportedResult;
|
|
35
|
+
/**
|
|
36
|
+
* The resolve transaction: the op's outcome, and the network ask it answers decided or given
|
|
37
|
+
* the reason in the same transaction (`ApprovalsService.settleFromResolver`).
|
|
38
|
+
*/
|
|
39
|
+
private settle;
|
|
40
|
+
}
|