zigmini 1.0.0-beta.3 → 1.1.0-beta.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.ko.md +29 -2
- package/README.md +29 -2
- package/assets/agent/gateway-request-rules.md +9 -2
- package/assets/agent/gateway-rules.md +13 -6
- package/assets/android/zigmini.apk +0 -0
- package/assets/android/zigmini.apk.json +3 -3
- package/assets/build.json +1 -1
- package/assets/document-reader/document-reader +0 -0
- package/assets/document-reader/host-selector +0 -0
- package/assets/document-reader/manifest.json +27 -0
- package/docs/manual.ko.md +114 -10
- package/docs/manual.md +117 -11
- package/host/document-reader/DocumentRead.swift +370 -0
- package/host/document-reader/HostSelector.swift +192 -0
- package/host/document-reader/Protocol.swift +647 -0
- package/host/document-reader/main.swift +22 -0
- package/host/omo-bridge/agent-aside-tool.ts +119 -0
- package/host/omo-bridge/agent-aside.ts +522 -0
- package/host/omo-bridge/agent-collaboration-files.ts +282 -0
- package/host/omo-bridge/agent-collaboration.ts +385 -0
- package/host/omo-bridge/agent-control.ts +400 -28
- package/host/omo-bridge/agent-execution.sb +42 -0
- package/host/omo-bridge/agent-execution.ts +158 -0
- package/host/omo-bridge/agent-tool-adapters.ts +388 -0
- package/host/omo-bridge/agent-tool-permissions.ts +96 -0
- package/host/omo-bridge/aside-tabs.ts +41 -0
- package/host/omo-bridge/attachment-read.ts +100 -0
- package/host/omo-bridge/bridge.ts +10 -0
- package/host/omo-bridge/managed-call.ts +46 -0
- package/host/omo-bridge/task-control.ts +39 -5
- package/host/omo-check/patches/attachments-293/README.md +138 -0
- package/host/omo-check/patches/attachments-293/document-content.js +234 -0
- package/host/omo-check/patches/attachments-293/document-content.types.txt +91 -0
- package/host/omo-check/patches/attachments-293/omo-5.1.51-documents.patch +831 -0
- package/host/omo-check/patches/attachments-293/omo-5.1.51-gateway-port.manifest.json +471 -0
- package/host/omo-check/patches/omo-5.1.51-agent-tool-authority.patch +471 -0
- package/host/omo-check/patches/omo-5.1.51-gateway-port.manifest.json +28 -6
- package/host/omo-check/patches/omo-5.1.51-memory-receipts.patch +5 -0
- package/package.json +5 -3
- package/src/agent/api.ts +345 -20
- package/src/agent/artifacts.ts +196 -0
- package/src/agent/aside-target-routes.ts +369 -0
- package/src/agent/aside-targets.ts +215 -0
- package/src/agent/collaboration-admission.ts +47 -0
- package/src/agent/collaboration-read.ts +117 -0
- package/src/agent/collaboration-session.ts +196 -0
- package/src/agent/collaboration-types.ts +258 -0
- package/src/agent/collaboration.ts +399 -0
- package/src/agent/definition.ts +65 -3
- package/src/agent/fields.ts +9 -3
- package/src/agent/file-core.ts +325 -0
- package/src/agent/files.ts +87 -8
- package/src/agent/list.ts +3 -3
- package/src/agent/managed-call.ts +144 -0
- package/src/agent/tool-catalog.ts +184 -0
- package/src/agent/tool-delivery.ts +359 -0
- package/src/agent/tool-permission-types.ts +81 -0
- package/src/agent/tool-permissions.ts +1366 -0
- package/src/attachments/api.ts +161 -0
- package/src/attachments/document-reader.ts +325 -0
- package/src/attachments/host-selector.ts +410 -0
- package/src/attachments/managed-binding.ts +83 -0
- package/src/attachments/prepare.ts +214 -0
- package/src/attachments/selector-port.ts +28 -0
- package/src/attachments/service.ts +729 -0
- package/src/attachments/store.ts +698 -0
- package/src/attachments/types.ts +123 -0
- package/src/cli/doctor.ts +30 -10
- package/src/decisions.ts +109 -10
- package/src/engine/apply-patch.ts +21 -0
- package/src/engine/compat.json +2 -1
- package/src/engine/install.ts +45 -4
- package/src/engine/sandbox-cell.ts +2 -0
- package/src/gc.ts +33 -225
- package/src/housekeeping/api.ts +163 -0
- package/src/housekeeping/audit.ts +61 -0
- package/src/housekeeping/collectors/agent-collaboration.ts +33 -0
- package/src/housekeeping/collectors/agent-drafts.ts +43 -0
- package/src/housekeeping/collectors/agent-file-trash.ts +36 -0
- package/src/housekeeping/collectors/agent-trash.ts +28 -0
- package/src/housekeeping/collectors/attachment-cleanup.ts +35 -0
- package/src/housekeeping/collectors/db-backup.ts +25 -0
- package/src/housekeeping/collectors/docker-build-cache.ts +29 -0
- package/src/housekeeping/collectors/docker-image.ts +135 -0
- package/src/housekeeping/collectors/omo-version.ts +32 -0
- package/src/housekeeping/collectors/orphan-test-process.ts +205 -0
- package/src/housekeeping/collectors/reconcile-work.ts +55 -0
- package/src/housekeeping/collectors/rpc-daemon.ts +39 -0
- package/src/housekeeping/collectors/shared.ts +149 -0
- package/src/housekeeping/collectors/temp-dir.ts +28 -0
- package/src/housekeeping/collectors/update-file.ts +25 -0
- package/src/housekeeping/collectors/worktree.ts +193 -0
- package/src/housekeeping/engine.ts +125 -0
- package/src/housekeeping/reconcile/work.ts +130 -0
- package/src/housekeeping/registry.ts +63 -0
- package/src/housekeeping/trash.ts +59 -0
- package/src/housekeeping/types.ts +31 -0
- package/src/hub.ts +1095 -41
- package/src/main.ts +228 -30
- package/src/message-references.ts +14 -5
- package/src/profile-prompt.ts +13 -1
- package/src/protocol.ts +41 -6
- package/src/security-allow.ts +79 -2
- package/src/security-check.ts +1 -1
- package/src/store.ts +1946 -23
- package/src/test-crash.ts +20 -0
- package/src/work-watch.ts +55 -7
- package/src/work.ts +192 -3
- package/web/DESIGN.md +33 -0
- package/web/agents.d.ts +189 -12
- package/web/agents.js +898 -39
- package/web/app.js +2634 -210
- package/web/attachments.d.ts +91 -0
- package/web/attachments.js +422 -0
- package/web/collaboration.d.ts +87 -0
- package/web/collaboration.js +290 -0
- package/web/decisions.d.ts +39 -2
- package/web/decisions.js +354 -2
- package/web/gc.d.ts +26 -5
- package/web/gc.js +113 -13
- package/web/i18n/agents.d.ts +4 -0
- package/web/i18n/agents.js +200 -8
- package/web/i18n/attachments.js +78 -0
- package/web/i18n/decisions.d.ts +4 -0
- package/web/i18n/decisions.js +68 -0
- package/web/i18n/gc.d.ts +4 -0
- package/web/i18n/gc.js +184 -2
- package/web/i18n/main.js +80 -8
- package/web/i18n/tour.d.ts +4 -0
- package/web/i18n/tour.js +37 -0
- package/web/index.html +20 -3
- package/web/profile.d.ts +2 -2
- package/web/profile.js +11 -6
- package/web/style.css +342 -21
- package/web/tooltip.d.ts +2 -0
- package/web/tooltip.js +12 -0
- package/web/tour.d.ts +57 -0
- package/web/tour.js +450 -0
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { isDeepStrictEqual } from "node:util";
|
|
3
|
+
import { MEDIA_MAX_BYTES } from "../media.ts";
|
|
4
|
+
import type { Store } from "../store.ts";
|
|
5
|
+
import { AgentContentError, AgentContentFiles, sourceName } from "./file-core.ts";
|
|
6
|
+
import type { ArtifactSecurityHold } from "../security-allow.ts";
|
|
7
|
+
import { CollaborationStoreError, collaborationNever, type AgentArtifact, type AgentArtifactStage, type CollaborationBinding, type CollaborationGate } from "./collaboration-types.ts";
|
|
8
|
+
|
|
9
|
+
export interface ArtifactBytes {
|
|
10
|
+
readonly bytes: Uint8Array;
|
|
11
|
+
readonly name: string;
|
|
12
|
+
readonly mediaType: string;
|
|
13
|
+
}
|
|
14
|
+
export interface MaterializedArtifact {
|
|
15
|
+
readonly artifactId: string;
|
|
16
|
+
readonly requestId: string;
|
|
17
|
+
readonly path: string;
|
|
18
|
+
readonly name: string;
|
|
19
|
+
readonly sha256: string;
|
|
20
|
+
readonly bytes: number;
|
|
21
|
+
readonly origin: "collaboration-copy";
|
|
22
|
+
}
|
|
23
|
+
type ArtifactRead = { readonly requestId: string; readonly artifactId: string; readonly readOperationId: string };
|
|
24
|
+
|
|
25
|
+
function digest(bytes: Uint8Array): string { return createHash("sha256").update(bytes).digest("hex"); }
|
|
26
|
+
|
|
27
|
+
/** A byte provider consumes a claimed server stage, never a model-supplied grant or host path. */
|
|
28
|
+
export type StageBytes = (stage: AgentArtifactStage) => Promise<ArtifactBytes>;
|
|
29
|
+
|
|
30
|
+
/** Store owns authority and lineage. This service owns immutable bytes, not a second grant ledger. */
|
|
31
|
+
export class AgentArtifacts {
|
|
32
|
+
constructor(private readonly files: AgentContentFiles, private readonly store: Store, private readonly gate: CollaborationGate) {}
|
|
33
|
+
|
|
34
|
+
private artifact(id: string): AgentArtifact {
|
|
35
|
+
const artifact = this.store.agentArtifact(id);
|
|
36
|
+
if (!artifact) throw new CollaborationStoreError("artifact_not_found", 404);
|
|
37
|
+
if (artifact.state === "trashed") throw new CollaborationStoreError("artifact_revoked", 410);
|
|
38
|
+
return artifact;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
private checkBytes(artifact: Pick<AgentArtifact, "bytes" | "sha256">, bytes: Uint8Array): void {
|
|
42
|
+
if (bytes.byteLength !== artifact.bytes || digest(bytes) !== artifact.sha256) throw new CollaborationStoreError("artifact_digest_conflict");
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
private pathStage(stage: AgentArtifactStage): void {
|
|
46
|
+
switch (stage.source.kind) {
|
|
47
|
+
case "attachment": case "artifact": return;
|
|
48
|
+
case "agent_path": break;
|
|
49
|
+
default: return collaborationNever(stage.source);
|
|
50
|
+
}
|
|
51
|
+
const parts = stage.source.path.split(/[\\/]/);
|
|
52
|
+
if (parts.some(part => part === ".." || part === ".collaboration" || part === ".file-trash" || part === ".trash") ||
|
|
53
|
+
stage.source.path.includes("\0")) throw new CollaborationStoreError("managed_artifact_path", 403);
|
|
54
|
+
if (stage.sourceBinding.agentId !== null && (parts[0] !== "source" && parts[0] !== "outputs")) throw new CollaborationStoreError("agent_path_forbidden", 403);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The caller supplies the real trusted transport/export port. Absent providers are errors in the caller,
|
|
59
|
+
* not a successful placeholder here. Claim precedes all provider IO; seal rechecks the same grant and epochs.
|
|
60
|
+
*/
|
|
61
|
+
async stage(transferId: string, provide: StageBytes): Promise<AgentArtifact> {
|
|
62
|
+
const saved = this.store.agentStage(transferId);
|
|
63
|
+
if (!saved) throw new CollaborationStoreError("stage_not_found", 404);
|
|
64
|
+
this.pathStage(saved);
|
|
65
|
+
if (saved.state === "staged" && saved.artifactId !== null) return this.artifact(saved.artifactId);
|
|
66
|
+
const stage = this.store.claimAgentStage(transferId, saved.revision, this.gate);
|
|
67
|
+
try {
|
|
68
|
+
let supplied: ArtifactBytes;
|
|
69
|
+
let expected: string;
|
|
70
|
+
let expectedBytes: number | undefined;
|
|
71
|
+
switch (stage.source.kind) {
|
|
72
|
+
case "agent_path":
|
|
73
|
+
supplied = await provide(stage);
|
|
74
|
+
expected = stage.source.sha256;
|
|
75
|
+
break;
|
|
76
|
+
case "attachment":
|
|
77
|
+
supplied = await provide(stage);
|
|
78
|
+
expected = stage.source.origin.originalSha256;
|
|
79
|
+
expectedBytes = stage.source.origin.bytes;
|
|
80
|
+
break;
|
|
81
|
+
case "artifact": {
|
|
82
|
+
const original = this.artifact(stage.source.artifactId);
|
|
83
|
+
expected = stage.source.sha256;
|
|
84
|
+
supplied = { bytes: this.readGranted({ requestId: stage.source.requestId, artifactId: original.id, readOperationId: randomUUID() }, stage.sourceBinding),
|
|
85
|
+
name: original.originalName, mediaType: original.mediaType };
|
|
86
|
+
break;
|
|
87
|
+
}
|
|
88
|
+
default: return collaborationNever(stage.source);
|
|
89
|
+
}
|
|
90
|
+
const bytes = Buffer.from(supplied.bytes);
|
|
91
|
+
if (digest(bytes) !== expected || (expectedBytes !== undefined && bytes.byteLength !== expectedBytes)) throw new CollaborationStoreError("artifact_digest_conflict");
|
|
92
|
+
const sealed = this.seal([{ ...supplied, bytes }])[0];
|
|
93
|
+
if (!sealed) throw new CollaborationStoreError("artifact_missing");
|
|
94
|
+
return this.store.sealAgentStage({ transferId, expectedRevision: stage.revision, ...sealed }, this.gate);
|
|
95
|
+
} catch (error) {
|
|
96
|
+
// A provider or publication may have crossed an external IO boundary. Never replay it as ready.
|
|
97
|
+
const current = this.store.agentStage(transferId);
|
|
98
|
+
if (current?.state === "staging") this.store.markAgentStageUnknown(transferId, current.revision);
|
|
99
|
+
throw error;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
private seal(input: readonly ArtifactBytes[]) {
|
|
104
|
+
if (input.length > 10 || input.reduce((sum, file) => sum + file.bytes.byteLength, 0) > 100 * 1024 * 1024) throw new CollaborationStoreError("result_too_large", 413);
|
|
105
|
+
// Validate the complete operation before creating its first file.
|
|
106
|
+
const batch = input.map(file => {
|
|
107
|
+
if (file.bytes.byteLength > MEDIA_MAX_BYTES) throw new CollaborationStoreError("result_too_large", 413);
|
|
108
|
+
return { ...file, name: sourceName(file.name), bytes: Buffer.from(file.bytes) };
|
|
109
|
+
});
|
|
110
|
+
return batch.map(file => {
|
|
111
|
+
const sealed = this.files.publish({ directory: [".collaboration", "artifacts"], name: randomUUID(), bytes: file.bytes, immutable: true, exact: true });
|
|
112
|
+
const readback = this.files.read([".collaboration", "artifacts", sealed.name]);
|
|
113
|
+
this.checkBytes(sealed, readback);
|
|
114
|
+
return { sha256: sealed.sha256, bytes: sealed.bytes, name: file.name, mediaType: file.mediaType, sealedName: sealed.name };
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** T4's coherent snapshot consumer supplies bytes; this method never reads a mutable outputs path. */
|
|
119
|
+
prepareResult(input: { readonly requestId: string; readonly expectedRevision: number; readonly files: readonly ArtifactBytes[] }, binding: CollaborationBinding): readonly AgentArtifact[] {
|
|
120
|
+
if (!isDeepStrictEqual(this.store.agentBinding(binding.convId), binding)) throw new CollaborationStoreError("binding_conflict");
|
|
121
|
+
const files = this.seal(input.files);
|
|
122
|
+
return this.store.prepareAgentResult({ requestId: input.requestId, expectedRevision: input.expectedRevision, files }, binding);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
readGranted(input: ArtifactRead, binding: CollaborationBinding): Buffer {
|
|
126
|
+
const artifact = this.artifact(input.artifactId);
|
|
127
|
+
const reader = this.store.beginAgentArtifactRead(input, binding, this.gate);
|
|
128
|
+
try {
|
|
129
|
+
const bytes = this.files.read([".collaboration", "artifacts", artifact.sealedName]);
|
|
130
|
+
this.checkBytes(artifact, bytes);
|
|
131
|
+
this.store.claimAgentArtifactReadRelease(reader.readId, binding, this.gate);
|
|
132
|
+
return bytes;
|
|
133
|
+
} finally {
|
|
134
|
+
this.store.settleAgentArtifactReader(reader.readId, { helperStopped: true, leaseReleased: true });
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** `decide` sees the verified bytes before any release; a decision that does not release claims nothing. */
|
|
139
|
+
async readReviewed<T>(input: ArtifactRead, binding: CollaborationBinding,
|
|
140
|
+
decide: (artifact: AgentArtifact, bytes: Buffer, readId: string) => Promise<{ readonly release: boolean; readonly value: T }>): Promise<T> {
|
|
141
|
+
const artifact = this.artifact(input.artifactId);
|
|
142
|
+
const reader = this.store.beginAgentArtifactRead(input, binding, this.gate);
|
|
143
|
+
try {
|
|
144
|
+
const bytes = this.files.read([".collaboration", "artifacts", artifact.sealedName]);
|
|
145
|
+
this.checkBytes(artifact, bytes);
|
|
146
|
+
const decision = await decide(artifact, bytes, reader.readId);
|
|
147
|
+
if (decision.release) this.store.claimAgentArtifactReadRelease(reader.readId, binding, this.gate);
|
|
148
|
+
return decision.value;
|
|
149
|
+
} finally {
|
|
150
|
+
this.store.settleAgentArtifactReader(reader.readId, { helperStopped: true, leaseReleased: true });
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** The owner-allowed continuation releases exactly the reviewed representation, once. */
|
|
155
|
+
async releaseHeld<T>(hold: ArtifactSecurityHold, binding: CollaborationBinding,
|
|
156
|
+
present: (artifact: AgentArtifact, bytes: Buffer) => Promise<{ readonly sha256: string; readonly value: T }>): Promise<T> {
|
|
157
|
+
const artifact = this.artifact(hold.resourceRef.artifactId);
|
|
158
|
+
const bytes = this.files.read([".collaboration", "artifacts", artifact.sealedName]);
|
|
159
|
+
this.checkBytes(artifact, bytes);
|
|
160
|
+
const shown = await present(artifact, bytes);
|
|
161
|
+
if (artifact.sha256 !== hold.resourceRef.originalSha256 || shown.sha256 !== hold.representation.sha256) throw new CollaborationStoreError("artifact_review_stale");
|
|
162
|
+
this.store.claimArtifactRelease({ holdId: hold.id, expectedRevision: hold.revision, representation: hold.representation }, binding, this.gate);
|
|
163
|
+
const claimed = this.store.artifactSecurityHold(hold.id);
|
|
164
|
+
if (!claimed?.continuation.targetBinding) throw new CollaborationStoreError("artifact_delivery_conflict");
|
|
165
|
+
this.store.recordArtifactDelivery({ holdId: hold.id, dispatchReceiptId: claimed.continuation.dispatchReceiptId,
|
|
166
|
+
releaseReceiptId: claimed.continuation.releaseReceiptId, state: "released" }, claimed.continuation.targetBinding);
|
|
167
|
+
return shown.value;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Only committed ordinary copies enter knowledge. Scoped files remain outside lists and clone inputs. */
|
|
171
|
+
importSource(input: ArtifactRead, binding: CollaborationBinding): MaterializedArtifact | null {
|
|
172
|
+
const artifact = this.artifact(input.artifactId);
|
|
173
|
+
if (artifact.requestId !== input.requestId || artifact.state !== "committed") throw new CollaborationStoreError("artifact_unbound");
|
|
174
|
+
if (artifact.storageClass === "scoped_document") return null;
|
|
175
|
+
if (binding.agentId === null) throw new CollaborationStoreError("source_recipient_required", 403);
|
|
176
|
+
const bytes = this.readGranted(input, binding);
|
|
177
|
+
const saved = this.files.publish({ directory: [binding.agentId, "knowledge", "source"],
|
|
178
|
+
name: `${input.requestId.slice(0, 8)}--${artifact.originalName}`, bytes, immutable: false });
|
|
179
|
+
const manifest: MaterializedArtifact = { artifactId: artifact.id, requestId: input.requestId, path: `source/${saved.name}`,
|
|
180
|
+
name: saved.name, sha256: saved.sha256, bytes: saved.bytes, origin: "collaboration-copy" };
|
|
181
|
+
this.store.appendEvent(binding.convId, "agent.collaboration.materialized", { ...manifest, fileIdentity: saved.fileIdentity });
|
|
182
|
+
return manifest;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
trash(artifactId: string): AgentArtifact {
|
|
186
|
+
const tombstone = this.store.trashAgentArtifact(artifactId);
|
|
187
|
+
try {
|
|
188
|
+
this.files.trash(tombstone.sealedName);
|
|
189
|
+
} catch (error) {
|
|
190
|
+
// A retry after the physical rename must match the original immutable bytes in Trash.
|
|
191
|
+
if (!(error instanceof AgentContentError) || error.errno !== 2) throw error;
|
|
192
|
+
this.checkBytes(tombstone, this.files.read([".collaboration", "trash", tombstone.sealedName]));
|
|
193
|
+
}
|
|
194
|
+
return tombstone;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
import type { Database } from "bun:sqlite";
|
|
2
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
3
|
+
import type { Decision } from "../store.ts";
|
|
4
|
+
import { type AsideTarget, type AsideTargetRegistry, isProtectedUrl, originOf } from "./aside-targets.ts";
|
|
5
|
+
import { access, canonicalJson, lifecycleEpoch, type AgentToolClients, type AgentToolRequestContext } from "./tool-permissions.ts";
|
|
6
|
+
|
|
7
|
+
// 294 C7 (defect D4): the owner routes behind the web tab picker of a pending zgw_aside grant card.
|
|
8
|
+
// GET /api/decisions/:id/aside-targets lists the user's open tabs for the user's screen only (never for the model).
|
|
9
|
+
// POST /api/decisions/:id/aside-target binds the chosen tab in the target registry and re-proposes the card.
|
|
10
|
+
// The tab list comes from an injected port (the host AsideAdapter list operation, official CLI listBrowserTabs). A
|
|
11
|
+
// candidate id is opaque and only valid for the decision and the listing that produced it. The client sees the
|
|
12
|
+
// title and the URL without query or fragment; protected targets (gateway origins, loopback/.local/.internal,
|
|
13
|
+
// credential pages, non-web pages) are never listed or bound.
|
|
14
|
+
|
|
15
|
+
export const ASIDE_TOOL = "zgw_aside";
|
|
16
|
+
const OWNED_OPERATIONS = new Set(["open_owned"]);
|
|
17
|
+
const TITLE_LIMIT = 200;
|
|
18
|
+
const LISTINGS_KEPT = 64;
|
|
19
|
+
const BODY_LIMIT = 16 * 1024;
|
|
20
|
+
|
|
21
|
+
export interface BrowserTab {
|
|
22
|
+
readonly targetId: string;
|
|
23
|
+
readonly url: string;
|
|
24
|
+
readonly title: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Host side of the picker: the AsideAdapter list operation and the confirmed CLI account. */
|
|
28
|
+
export interface AsideTabPort {
|
|
29
|
+
listTabs(): Promise<readonly BrowserTab[]>;
|
|
30
|
+
/** The confirmed Aside CLI account, or null when it is not clear. */
|
|
31
|
+
account(): Promise<string | null>;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** The request behind a pending zgw_aside grant card, as the ledger stored it. */
|
|
35
|
+
export interface AsideGrantRequest {
|
|
36
|
+
readonly requestId: string;
|
|
37
|
+
readonly agentId: string;
|
|
38
|
+
readonly incarnation: number;
|
|
39
|
+
readonly originInputSeq: number;
|
|
40
|
+
readonly operation: string;
|
|
41
|
+
readonly toolName: string;
|
|
42
|
+
readonly scope: unknown;
|
|
43
|
+
readonly proposalDigest: string;
|
|
44
|
+
readonly state: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export type RebindResult =
|
|
48
|
+
| { readonly ok: true; readonly decision: Decision }
|
|
49
|
+
| { readonly ok: false; readonly error: "decision_conflict" | "lifecycle_changed" | "not_aside_grant"; readonly decision: Decision | null };
|
|
50
|
+
|
|
51
|
+
/** Reads and re-proposes the card. SqliteAsideDecisions is the Store-connection implementation. */
|
|
52
|
+
export interface AsideDecisionStore {
|
|
53
|
+
decision(id: string): Decision | null;
|
|
54
|
+
request(decisionId: string): AsideGrantRequest | null;
|
|
55
|
+
rebind(decisionId: string, input: { expectedRevision: number; expectedDigest: string; scope: Record<string, unknown> }): RebindResult;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const record = (value: unknown): value is Record<string, unknown> => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
59
|
+
const sha256 = (text: string) => createHash("sha256").update(text).digest("hex");
|
|
60
|
+
|
|
61
|
+
type RequestRow = {
|
|
62
|
+
id: string; agent_id: string; incarnation: number; origin_input_seq: number; tool_key_json: string; operation: string;
|
|
63
|
+
args_digest: string; scope_json: string; proposal_digest: string; state: string;
|
|
64
|
+
};
|
|
65
|
+
type DecisionRow = { revision: number; status: string; submission_id: string | null; payload: string };
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* The card rebind on the Store connection (the database's only writer). Pass the Store's Database and its getDecision.
|
|
69
|
+
* The new proposal digest uses the same fields as AgentToolPermissionStore.proposal, so the owner's allow of the
|
|
70
|
+
* re-proposed card is checked by the existing answer path without change.
|
|
71
|
+
*/
|
|
72
|
+
export class SqliteAsideDecisions implements AsideDecisionStore {
|
|
73
|
+
constructor(private readonly db: Database, private readonly get: (id: string) => Decision | null) {}
|
|
74
|
+
|
|
75
|
+
decision(id: string): Decision | null {
|
|
76
|
+
return this.get(id);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
private row(decisionId: string): RequestRow | null {
|
|
80
|
+
return this.db.query<RequestRow, [string]>(`SELECT id, agent_id, incarnation, origin_input_seq, tool_key_json, operation, args_digest,
|
|
81
|
+
scope_json, proposal_digest, state FROM agent_tool_requests WHERE decision_id = ?`).get(decisionId);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
request(decisionId: string): AsideGrantRequest | null {
|
|
85
|
+
const row = this.row(decisionId);
|
|
86
|
+
if (!row) return null;
|
|
87
|
+
const toolKey = JSON.parse(row.tool_key_json) as unknown;
|
|
88
|
+
return {
|
|
89
|
+
requestId: row.id, agentId: row.agent_id, incarnation: row.incarnation, originInputSeq: row.origin_input_seq, operation: row.operation,
|
|
90
|
+
toolName: record(toolKey) && typeof toolKey.originalName === "string" ? toolKey.originalName : "", scope: JSON.parse(row.scope_json) as unknown,
|
|
91
|
+
proposalDigest: row.proposal_digest, state: row.state,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** The request behind an invocation, for carrying its bound tab at execution (D4). The stored args are read, never changed. */
|
|
96
|
+
boundCall(invocationId: string): BoundAsideCall | null {
|
|
97
|
+
const row = this.db.query<{ agent_id: string; incarnation: number; tool_key_json: string; operation: string; args_json: string; scope_json: string }, [string]>(
|
|
98
|
+
`SELECT r.agent_id, r.incarnation, r.tool_key_json, r.operation, r.args_json, r.scope_json
|
|
99
|
+
FROM agent_tool_invocations i JOIN agent_tool_requests r ON r.id = i.request_id WHERE i.id = ?`).get(invocationId);
|
|
100
|
+
if (!row) return null;
|
|
101
|
+
const toolKey = JSON.parse(row.tool_key_json) as { originalName?: unknown } | null;
|
|
102
|
+
return { agentId: row.agent_id, incarnation: row.incarnation, operation: row.operation, args: JSON.parse(row.args_json) as unknown,
|
|
103
|
+
scope: JSON.parse(row.scope_json) as unknown, toolName: typeof toolKey?.originalName === "string" ? toolKey.originalName : "" };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
rebind(decisionId: string, input: { expectedRevision: number; expectedDigest: string; scope: Record<string, unknown> }): RebindResult {
|
|
107
|
+
return this.db.transaction((): RebindResult => {
|
|
108
|
+
const current = this.db.query<DecisionRow, [string]>("SELECT revision, status, submission_id, payload FROM decisions WHERE id = ?").get(decisionId);
|
|
109
|
+
const row = this.row(decisionId);
|
|
110
|
+
if (!current || !row) return { ok: false, error: "not_aside_grant", decision: this.get(decisionId) };
|
|
111
|
+
if (current.revision !== input.expectedRevision || current.status !== "pending" || current.submission_id !== null ||
|
|
112
|
+
row.state !== "pending" || row.proposal_digest !== input.expectedDigest) return { ok: false, error: "decision_conflict", decision: this.get(decisionId) };
|
|
113
|
+
if (lifecycleEpoch(this.db, row.agent_id) !== row.incarnation) return { ok: false, error: "lifecycle_changed", decision: this.get(decisionId) };
|
|
114
|
+
const toolKey = JSON.parse(row.tool_key_json) as unknown;
|
|
115
|
+
const proposalDigest = sha256(canonicalJson({
|
|
116
|
+
agentId: row.agent_id, incarnation: row.incarnation, authorityEpoch: access(this.db, row.agent_id).authorityEpoch, toolKey,
|
|
117
|
+
operation: row.operation, scope: input.scope, argsDigest: row.args_digest,
|
|
118
|
+
}));
|
|
119
|
+
const payload = { ...(JSON.parse(current.payload) as Record<string, unknown>), scope: input.scope, proposalDigest };
|
|
120
|
+
const now = new Date().toISOString();
|
|
121
|
+
this.db.query("UPDATE agent_tool_requests SET scope_json = ?, proposal_digest = ?, revision = revision + 1 WHERE id = ?")
|
|
122
|
+
.run(canonicalJson(input.scope), proposalDigest, row.id);
|
|
123
|
+
this.db.query("UPDATE decisions SET payload = ?, revision = revision + 1, updated_at = ? WHERE id = ?").run(JSON.stringify(payload), now, decisionId);
|
|
124
|
+
return { ok: true, decision: this.get(decisionId)! };
|
|
125
|
+
}).immediate();
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** The parts of a granted call the deliverer needs to carry its bound tab to the session (D4). */
|
|
130
|
+
export interface BoundAsideCall {
|
|
131
|
+
readonly agentId: string;
|
|
132
|
+
readonly incarnation: number;
|
|
133
|
+
readonly toolName: string;
|
|
134
|
+
readonly operation: string;
|
|
135
|
+
readonly args: unknown;
|
|
136
|
+
readonly scope: unknown;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** What the session adopts under the same handle; the model never sees anything but the handle. */
|
|
140
|
+
export type SessionAsideTarget = Pick<AsideTarget, "handle" | "kind" | "host" | "account" | "targetId" | "origin" | "agentId" | "incarnation" | "selectionSeq">;
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* D4: the tab the owner bound to this call, resolved in the gateway registry for the call's own agent incarnation. null when
|
|
144
|
+
* the call names no bound tab (owned tabs, other tools); `unavailable` when the bound tab can no longer be used, so the
|
|
145
|
+
* call is not started.
|
|
146
|
+
*/
|
|
147
|
+
export function sessionAsideTarget(call: BoundAsideCall | null, registry: AsideTargetRegistry):
|
|
148
|
+
{ readonly args: unknown; readonly target: SessionAsideTarget } | { readonly unavailable: string } | null {
|
|
149
|
+
if (!call || call.toolName !== ASIDE_TOOL || OWNED_OPERATIONS.has(call.operation)) return null;
|
|
150
|
+
// A work tab the session opened itself stays in the session; only a tab the owner designated is carried.
|
|
151
|
+
if (record(call.scope) && call.scope.kind === "owned_target") return null;
|
|
152
|
+
const handle = record(call.scope) ? call.scope.targetHandle : undefined;
|
|
153
|
+
if (typeof handle !== "string" || handle === "") return null;
|
|
154
|
+
const target = registry.resolve(handle, call.agentId, call.incarnation);
|
|
155
|
+
if ("refused" in target) return { unavailable: target.refused };
|
|
156
|
+
const { kind, host, account, targetId, origin, agentId, incarnation, selectionSeq } = target;
|
|
157
|
+
return { args: call.args, target: { handle: target.handle, kind, host, account, targetId, origin, agentId, incarnation, selectionSeq } };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* D4: the picker's tab port over the gateway<->bridge channel. The bridge answers `zgw_aside_tabs` with the confirmed CLI
|
|
162
|
+
* account and the open tabs from one listing; the routes ask account() then listTabs(), so both read that one answer.
|
|
163
|
+
*/
|
|
164
|
+
export function bridgeTabPort(command: (type: string, params: Record<string, unknown>) => Promise<unknown>): AsideTabPort {
|
|
165
|
+
let last: Promise<{ account: string | null; tabs: readonly BrowserTab[] }> | null = null;
|
|
166
|
+
const fetchTabs = async () => {
|
|
167
|
+
const answer = await command("zgw_aside_tabs", {});
|
|
168
|
+
const value = record(answer) ? answer : {};
|
|
169
|
+
const tabs = Array.isArray(value.tabs) ? value.tabs : [];
|
|
170
|
+
return {
|
|
171
|
+
account: typeof value.account === "string" && value.account !== "" ? value.account : null,
|
|
172
|
+
tabs: tabs.filter((tab): tab is Record<string, unknown> => record(tab) && typeof tab.targetId === "string" && typeof tab.url === "string")
|
|
173
|
+
.map((tab) => ({ targetId: tab.targetId as string, url: tab.url as string, title: typeof tab.title === "string" ? tab.title : "" })),
|
|
174
|
+
};
|
|
175
|
+
};
|
|
176
|
+
return {
|
|
177
|
+
account: async () => {
|
|
178
|
+
last = fetchTabs();
|
|
179
|
+
return (await last).account;
|
|
180
|
+
},
|
|
181
|
+
listTabs: async () => {
|
|
182
|
+
const pending = last ?? fetchTabs();
|
|
183
|
+
last = null;
|
|
184
|
+
return (await pending).tabs;
|
|
185
|
+
},
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
interface Listing {
|
|
190
|
+
readonly account: string;
|
|
191
|
+
readonly entries: Map<string, BrowserTab>;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** The latest listing per decision (in memory). A new GET replaces it; a designation consumes it. */
|
|
195
|
+
export class AsideCandidates {
|
|
196
|
+
private readonly listings = new Map<string, Listing>();
|
|
197
|
+
|
|
198
|
+
put(decisionId: string, listing: Listing): void {
|
|
199
|
+
this.listings.delete(decisionId);
|
|
200
|
+
this.listings.set(decisionId, listing);
|
|
201
|
+
if (this.listings.size > LISTINGS_KEPT) this.listings.delete(this.listings.keys().next().value!);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
get(decisionId: string): Listing | undefined {
|
|
205
|
+
return this.listings.get(decisionId);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
drop(decisionId: string): void {
|
|
209
|
+
this.listings.delete(decisionId);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export interface AsideTargetHttpDeps {
|
|
214
|
+
readonly clients: Pick<AgentToolClients, "principal">;
|
|
215
|
+
readonly decisions: AsideDecisionStore;
|
|
216
|
+
readonly tabs: AsideTabPort;
|
|
217
|
+
readonly registry: AsideTargetRegistry;
|
|
218
|
+
readonly candidates: AsideCandidates;
|
|
219
|
+
/** The local host the registry binds a target to. */
|
|
220
|
+
readonly host: string;
|
|
221
|
+
readonly gatewayOrigins: readonly string[];
|
|
222
|
+
/** The decision as the inbox shows it (DecisionService.info). */
|
|
223
|
+
readonly view: (decision: Decision) => unknown;
|
|
224
|
+
/** Publishes the re-proposed card (DecisionService.refresh). */
|
|
225
|
+
readonly changed: () => void;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const ROUTE = /^\/api\/decisions\/([^/]+)\/(aside-targets|aside-target)$/;
|
|
229
|
+
|
|
230
|
+
export function asideTargetRoute(pathname: string): boolean {
|
|
231
|
+
return ROUTE.test(pathname);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const refuse = (error: string, status: number, extra: Record<string, unknown> = {}) =>
|
|
235
|
+
Response.json({ error, ...extra }, { status, headers: { "Cache-Control": "no-store" } });
|
|
236
|
+
|
|
237
|
+
/** C7: only a borrowed-tab card still waiting for its tab takes a designation (an owned tab the agent opens needs none). */
|
|
238
|
+
export function selectionNeeded(request: AsideGrantRequest): boolean {
|
|
239
|
+
const scope = record(request.scope) ? request.scope : {};
|
|
240
|
+
if (request.toolName !== ASIDE_TOOL && scope.kind !== "browser_target") return false;
|
|
241
|
+
if (OWNED_OPERATIONS.has(request.operation)) return false;
|
|
242
|
+
return scope.selection === "user" || typeof scope.targetHandle !== "string" || scope.targetHandle === "";
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** The URL the user's screen may show: origin and path, never query or fragment. */
|
|
246
|
+
export function displayUrl(url: string): string | null {
|
|
247
|
+
const origin = originOf(url);
|
|
248
|
+
return origin === null ? null : `${origin}${new URL(url).pathname}`;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function title(tab: BrowserTab): string {
|
|
252
|
+
const text = typeof tab.title === "string" ? tab.title.trim() : "";
|
|
253
|
+
return (text || new URL(tab.url).hostname).slice(0, TITLE_LIMIT);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** The scope after designation: the opaque handle, its label and origin; the user-selection marker is gone. */
|
|
257
|
+
export function designatedScope(scope: unknown, handle: string, label: string, origin: string): Record<string, unknown> {
|
|
258
|
+
const { selection: _selection, targetHandle: _handle, targetId: _targetId, targetLabel: _label, targetTitle: _title, ...rest } = record(scope) ? scope : {};
|
|
259
|
+
return { ...rest, kind: "browser_target", targetHandle: handle, targetLabel: label, origin };
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
async function body(req: Request): Promise<Record<string, unknown> | null> {
|
|
263
|
+
if (Number(req.headers.get("content-length") ?? 0) > BODY_LIMIT) return null;
|
|
264
|
+
const text = await req.text();
|
|
265
|
+
if (Buffer.byteLength(text) > BODY_LIMIT) return null;
|
|
266
|
+
try {
|
|
267
|
+
const value = JSON.parse(text) as unknown;
|
|
268
|
+
return record(value) ? value : null;
|
|
269
|
+
} catch {
|
|
270
|
+
return null;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
type Card = { decision: Decision; request: AsideGrantRequest };
|
|
275
|
+
|
|
276
|
+
function card(deps: AsideTargetHttpDeps, id: string): Card | Response {
|
|
277
|
+
const decision = deps.decisions.decision(id);
|
|
278
|
+
if (!decision) return refuse("decision_not_found", 404);
|
|
279
|
+
const request = deps.decisions.request(id);
|
|
280
|
+
if (!request || !selectionNeeded(request)) return refuse("not_aside_target_card", 400);
|
|
281
|
+
if (decision.status !== "pending" || decision.submissionId !== null || request.state !== "pending") {
|
|
282
|
+
return refuse("decision_conflict", 409, { decision: deps.view(decision) });
|
|
283
|
+
}
|
|
284
|
+
return { decision, request };
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
async function listTabs(deps: AsideTargetHttpDeps): Promise<{ account: string; tabs: readonly BrowserTab[] } | Response> {
|
|
288
|
+
let account: string | null;
|
|
289
|
+
let tabs: readonly BrowserTab[];
|
|
290
|
+
try {
|
|
291
|
+
account = await deps.tabs.account();
|
|
292
|
+
tabs = await deps.tabs.listTabs();
|
|
293
|
+
} catch {
|
|
294
|
+
return refuse("aside_unavailable", 503);
|
|
295
|
+
}
|
|
296
|
+
if (!account) return refuse("target_identity_unclear", 400);
|
|
297
|
+
return { account, tabs };
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/** Owner routes of the C7 tab picker. Same caller helper (AgentToolClients.principal) as the other owner routes. */
|
|
301
|
+
export async function asideTargetHttp(req: Request, ctx: AgentToolRequestContext, deps: AsideTargetHttpDeps): Promise<Response> {
|
|
302
|
+
const route = ROUTE.exec(new URL(req.url).pathname);
|
|
303
|
+
if (!route) return refuse("not_found", 404);
|
|
304
|
+
const list = route[2] === "aside-targets";
|
|
305
|
+
if (req.method !== (list ? "GET" : "POST")) return refuse("method_not_allowed", 405);
|
|
306
|
+
const principal = deps.clients.principal(req, ctx);
|
|
307
|
+
if (principal instanceof Response) return principal;
|
|
308
|
+
let id: string;
|
|
309
|
+
try {
|
|
310
|
+
id = decodeURIComponent(route[1]!);
|
|
311
|
+
} catch {
|
|
312
|
+
return refuse("invalid_decision", 400);
|
|
313
|
+
}
|
|
314
|
+
if (list) {
|
|
315
|
+
const found = card(deps, id);
|
|
316
|
+
if (found instanceof Response) return found;
|
|
317
|
+
const listed = await listTabs(deps);
|
|
318
|
+
if (listed instanceof Response) return listed;
|
|
319
|
+
const entries = new Map<string, BrowserTab>();
|
|
320
|
+
const candidates: { id: string; title: string; url: string; origin: string }[] = [];
|
|
321
|
+
for (const tab of listed.tabs) {
|
|
322
|
+
if (typeof tab.targetId !== "string" || tab.targetId === "" || typeof tab.url !== "string") continue;
|
|
323
|
+
if (isProtectedUrl(tab.url, deps.gatewayOrigins)) continue;
|
|
324
|
+
const candidateId = `atc_${randomUUID()}`;
|
|
325
|
+
entries.set(candidateId, tab);
|
|
326
|
+
candidates.push({ id: candidateId, title: title(tab), url: displayUrl(tab.url)!, origin: originOf(tab.url)! });
|
|
327
|
+
}
|
|
328
|
+
deps.candidates.put(id, { account: listed.account, entries });
|
|
329
|
+
return Response.json({ candidates }, { headers: { "Cache-Control": "no-store" } });
|
|
330
|
+
}
|
|
331
|
+
const input = await body(req);
|
|
332
|
+
if (!input || typeof input.candidateId !== "string" || input.candidateId === "" ||
|
|
333
|
+
(input.expectedRevision !== undefined && (typeof input.expectedRevision !== "number" || !Number.isSafeInteger(input.expectedRevision))) ||
|
|
334
|
+
(input.proposalDigest !== undefined && typeof input.proposalDigest !== "string") ||
|
|
335
|
+
(input.expectedRevision === undefined && input.proposalDigest === undefined)) return refuse("invalid_request", 400);
|
|
336
|
+
const found = card(deps, id);
|
|
337
|
+
if (found instanceof Response) return found;
|
|
338
|
+
const { decision, request } = found;
|
|
339
|
+
if ((input.expectedRevision !== undefined && input.expectedRevision !== decision.revision) ||
|
|
340
|
+
(input.proposalDigest !== undefined && input.proposalDigest !== request.proposalDigest)) {
|
|
341
|
+
return refuse("decision_conflict", 409, { decision: deps.view(decision) });
|
|
342
|
+
}
|
|
343
|
+
const listing = deps.candidates.get(id);
|
|
344
|
+
const chosen = listing?.entries.get(input.candidateId);
|
|
345
|
+
if (!listing || !chosen) return refuse("unknown_candidate", 400);
|
|
346
|
+
if (isProtectedUrl(chosen.url, deps.gatewayOrigins)) return refuse("protected_target", 403);
|
|
347
|
+
// The tab must still be open on the same origin under the same CLI account as when it was listed.
|
|
348
|
+
const listed = await listTabs(deps);
|
|
349
|
+
if (listed instanceof Response) return listed;
|
|
350
|
+
if (listed.account !== listing.account) return refuse("account_changed", 410);
|
|
351
|
+
const now = listed.tabs.find((tab) => tab.targetId === chosen.targetId);
|
|
352
|
+
if (!now) return refuse("target_closed", 410);
|
|
353
|
+
if (typeof now.url !== "string" || isProtectedUrl(now.url, deps.gatewayOrigins)) return refuse("protected_target", 403);
|
|
354
|
+
if (originOf(now.url) !== originOf(chosen.url)) return refuse("target_changed", 410);
|
|
355
|
+
const target = deps.registry.designate({ host: deps.host, account: listed.account, targetId: now.targetId, url: now.url,
|
|
356
|
+
agentId: request.agentId, incarnation: request.incarnation, selectionSeq: request.originInputSeq, gatewayOrigins: deps.gatewayOrigins });
|
|
357
|
+
if ("refused" in target) return refuse(target.refused, target.refused === "protected_target" ? 403 : 400);
|
|
358
|
+
const result = deps.decisions.rebind(id, { expectedRevision: decision.revision, expectedDigest: request.proposalDigest,
|
|
359
|
+
scope: designatedScope(request.scope, target.handle, title(now), target.origin) });
|
|
360
|
+
if (!result.ok) {
|
|
361
|
+
deps.registry.suspend(target);
|
|
362
|
+
if (result.error === "lifecycle_changed") return refuse("lifecycle_changed", 410);
|
|
363
|
+
if (result.error === "not_aside_grant") return refuse("not_aside_target_card", 400);
|
|
364
|
+
return refuse("decision_conflict", 409, result.decision ? { decision: deps.view(result.decision) } : {});
|
|
365
|
+
}
|
|
366
|
+
deps.candidates.drop(id);
|
|
367
|
+
deps.changed();
|
|
368
|
+
return Response.json(deps.view(result.decision), { headers: { "Cache-Control": "no-store" } });
|
|
369
|
+
}
|