zigmini 1.0.0-beta.3 → 1.1.0-beta.1
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 +27 -2
- package/README.md +27 -2
- package/assets/agent/gateway-request-rules.md +1 -1
- package/assets/agent/gateway-rules.md +6 -1
- 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
package/web/decisions.js
CHANGED
|
@@ -343,6 +343,7 @@ export function sourceContext(payload) {
|
|
|
343
343
|
|
|
344
344
|
export function presentation(decision) {
|
|
345
345
|
if (decision.security) return { kind: "security", mode: null, form: false, recheck: false, terminal: false };
|
|
346
|
+
if (isArtifactReview(decision)) return { kind: "artifact-review", mode: null, form: false, recheck: false, terminal: false };
|
|
346
347
|
const actions = decision.allowedActions ?? [];
|
|
347
348
|
const terminal = TERMINAL_WORK.has(decision.work?.status ?? "");
|
|
348
349
|
let kind = "terminal";
|
|
@@ -366,7 +367,7 @@ export function dismissMode(decision) {
|
|
|
366
367
|
}
|
|
367
368
|
|
|
368
369
|
export function canDismiss(decision) {
|
|
369
|
-
if (decision.security) return false;
|
|
370
|
+
if (decision.security || isArtifactReview(decision)) return false;
|
|
370
371
|
return isUnresolved(decision) && decision.status !== "submitted";
|
|
371
372
|
}
|
|
372
373
|
|
|
@@ -397,6 +398,7 @@ export function questionTitle(q) {
|
|
|
397
398
|
* Empty for free-text methods, whose question is the title and detail.
|
|
398
399
|
*/
|
|
399
400
|
export function originalChoices(decision) {
|
|
401
|
+
if (isToolGrant(decision)) return [];
|
|
400
402
|
const payload = record(decision.payload) ? decision.payload : {};
|
|
401
403
|
const options = (list) => (Array.isArray(list) ? list : []).map((o) => (record(o)
|
|
402
404
|
? { label: typeof o.label === "string" ? o.label : "", description: typeof o.description === "string" ? o.description : "" }
|
|
@@ -418,6 +420,8 @@ export function originalChoices(decision) {
|
|
|
418
420
|
|
|
419
421
|
/** Builds the typed response for the decision's method; grouped questions need every required answer. */
|
|
420
422
|
export function composeResponse(decision, form = {}) {
|
|
423
|
+
// A tool grant (294 C3) takes only the structured toolGrant answer; a confirm, value or free text never becomes one.
|
|
424
|
+
if (isToolGrant(decision)) return { ok: false, reason: "unsupported" };
|
|
421
425
|
const payload = record(decision.payload) ? decision.payload : {};
|
|
422
426
|
switch (decision.method) {
|
|
423
427
|
case "select": {
|
|
@@ -455,7 +459,7 @@ export function composeResponse(decision, form = {}) {
|
|
|
455
459
|
|
|
456
460
|
/** An unchanged response replays its pending request verbatim, even after the current presentation mode changes. */
|
|
457
461
|
export function buildRequest(inbox, decision, mode, response, newId = () => crypto.randomUUID()) {
|
|
458
|
-
if (decision.security) return null;
|
|
462
|
+
if (decision.security || isArtifactReview(decision)) return null;
|
|
459
463
|
if (inbox.reviewRequired.has(decision.id)) return null;
|
|
460
464
|
const draft = inbox.drafts.get(decision.id);
|
|
461
465
|
const payload = response === undefined ? null : response;
|
|
@@ -606,3 +610,351 @@ export function waitReason(decision) {
|
|
|
606
610
|
if (work) parts.push(t("decisions.wait.work_status", { status: WORK_STATUS_LABELS[work.status] ?? work.status, reason: work.statusReason ? ` (${work.statusReason})` : "" }));
|
|
607
611
|
return parts.join(" ");
|
|
608
612
|
}
|
|
613
|
+
|
|
614
|
+
// Agent tool grants (plan 294 item 9, C3/C7/C8): the inbox card of a decision whose payload the gateway built with purpose
|
|
615
|
+
// agent_tool_grant. The answer is the server's choice plus the proposal digest; the owner calls carry the page nonce the
|
|
616
|
+
// gateway issued to this screen (POST /api/agent-tool-client) and the CSRF custom header.
|
|
617
|
+
export const TOOL_GRANT_PURPOSE = "agent_tool_grant";
|
|
618
|
+
/** C8: the one-shot confirmation of one click/fill; same structured answer, no lasting grant. */
|
|
619
|
+
const TOOL_ACTION_PURPOSE = "agent_tool_action";
|
|
620
|
+
/** Every 294 tool card the gateway answers only for the verified owner (src/agent/tool-permissions.ts TOOL_PURPOSES). */
|
|
621
|
+
const OWNER_ANSWER_PURPOSES = new Set([TOOL_GRANT_PURPOSE, TOOL_ACTION_PURPOSE, "agent_tool_migration"]);
|
|
622
|
+
export const AGENT_TOOL_FEATURE = "agent-tool-permissions-v1";
|
|
623
|
+
export const AGENT_TOOL_CLIENT_PATH = "/api/agent-tool-client";
|
|
624
|
+
export const AGENT_TOOL_CLIENT_HEADER = "x-zigmini-agent-tool-client";
|
|
625
|
+
export const AGENT_TOOL_NONCE_HEADER = "x-zigmini-page-nonce";
|
|
626
|
+
export const AGENT_TOOL_FEATURE_HEADER = "x-zigmini-features";
|
|
627
|
+
const PROPOSAL_DIGEST = /^[0-9a-f]{64}$/;
|
|
628
|
+
const EXPIRED_CLIENT = new Set(["agent_tool_client_required", "page_nonce_invalid"]);
|
|
629
|
+
const BROWSER_TOOL = "zgw_aside";
|
|
630
|
+
const OWNED_TAB_OPERATIONS = new Set(["open_owned"]);
|
|
631
|
+
const READ_OPERATIONS = new Set(["read", "screenshot", "list", "search", "fetch"]);
|
|
632
|
+
const OPAQUE_SCOPE_KEYS = new Set(["kind", "selection", "targetHandle", "targetId", "targetLabel", "targetTitle", "account", "host"]);
|
|
633
|
+
const TOOL_ERROR_KEYS = {
|
|
634
|
+
tool_grant_response_required: "response", tool_grant_principal_required: "principal", proposal_digest_mismatch: "digest", agent_access_blocked: "blocked",
|
|
635
|
+
grant_conflict: "grant", grant_not_found: "gone", policy_conflict: "policy", cursor_stale: "policy", cursor_invalid: "policy",
|
|
636
|
+
page_nonce_invalid: "expired", agent_tool_client_required: "expired", forbidden: "forbidden", forbidden_origin: "forbidden", forbidden_host: "forbidden",
|
|
637
|
+
custom_header_required: "forbidden", operation_conflict: "operation", tool_request_invalid: "invalid", lifecycle_epoch_mismatch: "epoch",
|
|
638
|
+
authority_epoch_mismatch: "epoch", tool_request_conflict: "duplicate", decision_not_found: "missing", not_found: "missing",
|
|
639
|
+
};
|
|
640
|
+
|
|
641
|
+
export function featureHeaders() {
|
|
642
|
+
return { [AGENT_TOOL_FEATURE_HEADER]: AGENT_TOOL_FEATURE };
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
/** The headers of an owner call (answer, list, revoke, edit): the CSRF header, this page's nonce and the feature mark. */
|
|
646
|
+
export function ownerHeaders(client) {
|
|
647
|
+
if (!record(client) || typeof client.pageNonce !== "string" || client.pageNonce === "") return null;
|
|
648
|
+
return { [AGENT_TOOL_CLIENT_HEADER]: "1", [AGENT_TOOL_NONCE_HEADER]: client.pageNonce, [AGENT_TOOL_FEATURE_HEADER]: AGENT_TOOL_FEATURE };
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
export function interpretClientIssue(status, body) {
|
|
652
|
+
if (status === 200 && record(body) && body.client === "web" && typeof body.pageNonce === "string" && body.pageNonce !== "" && typeof body.generation === "string") {
|
|
653
|
+
return { kind: "ready", client: { pageNonce: body.pageNonce, generation: body.generation } };
|
|
654
|
+
}
|
|
655
|
+
if (status === 401 || status === 403) return { kind: "refused", status };
|
|
656
|
+
return { kind: "unavailable", status };
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
/** A 403 whose session or page nonce is gone: the screen takes a new token, and the refused POST is never replayed by itself. */
|
|
660
|
+
export function clientExpired(error) {
|
|
661
|
+
return typeof error === "string" && EXPIRED_CLIENT.has(error);
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
export function toolGrantPayload(decision) {
|
|
665
|
+
const payload = record(decision?.payload) ? decision.payload : null;
|
|
666
|
+
if (!payload || (payload.purpose !== TOOL_GRANT_PURPOSE && payload.purpose !== TOOL_ACTION_PURPOSE) || typeof payload.proposalDigest !== "string" || !PROPOSAL_DIGEST.test(payload.proposalDigest)) return null;
|
|
667
|
+
return payload;
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
export function isToolGrant(decision) {
|
|
671
|
+
return toolGrantPayload(decision) !== null;
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
/** The answer of this card carries the owner client headers (cookie, page nonce, x-zigmini-agent-tool-client). */
|
|
675
|
+
export function ownerAnswerRequired(decision) {
|
|
676
|
+
return record(decision?.payload) && OWNER_ANSWER_PURPOSES.has(decision.payload.purpose);
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
function toolIdentifier(toolKey) {
|
|
680
|
+
return record(toolKey) && typeof toolKey.originalName === "string" ? toolKey.originalName : "";
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
function dictionaryText(key) {
|
|
684
|
+
const text = t(key);
|
|
685
|
+
return text === key ? null : text;
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
/** The display name of a tool: the dictionary's name for the tools it knows, else the registered identifier. */
|
|
689
|
+
export function toolName(toolKey) {
|
|
690
|
+
const name = toolIdentifier(toolKey);
|
|
691
|
+
return name ? dictionaryText(`decisions.tool.name.${name}`) ?? name : "";
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
export function operationLabel(toolKey, operation) {
|
|
695
|
+
if (typeof operation !== "string" || operation === "") return "";
|
|
696
|
+
const name = toolIdentifier(toolKey);
|
|
697
|
+
return (name ? dictionaryText(`decisions.tool.op.${name}.${operation}`) : null) ?? dictionaryText(`decisions.tool.op.${operation}`) ?? operation;
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
function operationDescription(toolKey, operation) {
|
|
701
|
+
const name = toolIdentifier(toolKey);
|
|
702
|
+
return (name && operation ? dictionaryText(`decisions.tool.desc.${name}.${operation}`) : null) ?? operationLabel(toolKey, operation);
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
/** The readable lines of a scope: the user's target label first, then every string or string-list value; opaque handles never show. */
|
|
706
|
+
export function scopeLines(scope) {
|
|
707
|
+
if (!record(scope)) return [];
|
|
708
|
+
if (scope.kind === "exact_args") return [{ key: "exact", label: t("decisions.tool.label.target"), value: t("decisions.tool.target.exact") }];
|
|
709
|
+
const lines = [];
|
|
710
|
+
const label = typeof scope.targetLabel === "string" ? scope.targetLabel : typeof scope.targetTitle === "string" ? scope.targetTitle : "";
|
|
711
|
+
if (label) lines.push({ key: "target", label: t("decisions.tool.label.target"), value: label });
|
|
712
|
+
for (const [key, value] of Object.entries(scope)) {
|
|
713
|
+
if (OPAQUE_SCOPE_KEYS.has(key)) continue;
|
|
714
|
+
if (typeof value === "string" && value) lines.push({ key, label: key, value });
|
|
715
|
+
else if (Array.isArray(value) && value.length > 0 && value.every((entry) => typeof entry === "string")) lines.push({ key, label: key, value: value.join(", ") });
|
|
716
|
+
}
|
|
717
|
+
return lines;
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
/** One line for a scope: "label (other values)", the values alone, or the no-scope text. */
|
|
721
|
+
export function scopeText(scope) {
|
|
722
|
+
const lines = scopeLines(scope);
|
|
723
|
+
if (lines.length === 0) return t("decisions.tool.target.none");
|
|
724
|
+
const target = lines.find((line) => line.key === "target");
|
|
725
|
+
const rest = lines.filter((line) => line !== target).map((line) => line.value);
|
|
726
|
+
if (!target) return rest.join(", ");
|
|
727
|
+
return rest.length ? `${target.value} (${rest.join(", ")})` : target.value;
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
/** C7: a borrowed browser tab must be chosen by the user before allow; an owned tab the agent opens itself needs none. */
|
|
731
|
+
export function targetSelectionNeeded(payload) {
|
|
732
|
+
if (!record(payload)) return false;
|
|
733
|
+
const scope = record(payload.scope) ? payload.scope : null;
|
|
734
|
+
if (scope?.selection === "user") return true;
|
|
735
|
+
const browser = toolIdentifier(payload.toolKey) === BROWSER_TOOL || scope?.kind === "browser_target";
|
|
736
|
+
if (!browser || OWNED_TAB_OPERATIONS.has(payload.operation)) return false;
|
|
737
|
+
return typeof scope?.targetHandle !== "string" || scope.targetHandle === "";
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
export function toolGrantView(decision, { agentName = (id) => id } = {}) {
|
|
741
|
+
const payload = toolGrantPayload(decision) ?? {};
|
|
742
|
+
const action = payload.purpose === TOOL_ACTION_PURPOSE;
|
|
743
|
+
const targetNeeded = targetSelectionNeeded(payload);
|
|
744
|
+
const operation = typeof payload.operation === "string" ? payload.operation : "";
|
|
745
|
+
const tool = toolName(payload.toolKey);
|
|
746
|
+
const op = operationLabel(payload.toolKey, operation);
|
|
747
|
+
// F3 D4: a collaboration the owner started has no origin agent; it is named as the owner's own request, never as an unnamed peer.
|
|
748
|
+
const originAgentId = typeof payload.originAgentId === "string" ? payload.originAgentId : "";
|
|
749
|
+
const source = payload.originKind !== "agent_collaboration" ? t("decisions.tool.source.user")
|
|
750
|
+
: originAgentId === "" ? t("decisions.tool.source.owner")
|
|
751
|
+
: t("decisions.tool.source.peer", { name: agentName(originAgentId) || originAgentId });
|
|
752
|
+
const rows = [
|
|
753
|
+
{ key: "agent", label: t("decisions.tool.label.agent"), value: agentName(typeof payload.agentId === "string" ? payload.agentId : "") },
|
|
754
|
+
{ key: "source", label: t("decisions.tool.label.source"), value: source },
|
|
755
|
+
{ key: "tool", label: t("decisions.tool.label.tool"), value: op ? `${tool} - ${op}` : tool },
|
|
756
|
+
{ key: "target", label: t("decisions.tool.label.target"), value: targetNeeded ? t("decisions.tool.target.select") : scopeText(payload.scope) },
|
|
757
|
+
{ key: "operation", label: t("decisions.tool.label.operation"), value: operationDescription(payload.toolKey, operation) },
|
|
758
|
+
{ key: "duration", label: t("decisions.tool.label.duration"), value: t(action ? "decisions.tool.action.duration" : "decisions.tool.duration") },
|
|
759
|
+
];
|
|
760
|
+
return {
|
|
761
|
+
title: t(action ? "decisions.tool.action.title" : "decisions.tool.title"),
|
|
762
|
+
rows,
|
|
763
|
+
note: t(action ? "decisions.tool.action.note" : "decisions.tool.note"),
|
|
764
|
+
restriction: t(action ? "decisions.tool.action.restriction" : READ_OPERATIONS.has(operation) ? "decisions.tool.restriction.read" : "decisions.tool.restriction.other"),
|
|
765
|
+
choices: [{ choice: "allow", label: t("decisions.tool.allow"), cls: "ok" }, { choice: "deny", label: t("decisions.tool.deny"), cls: "danger" }],
|
|
766
|
+
targetNeeded,
|
|
767
|
+
};
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
/** The structured answer (C3): exactly the choice and the digest. Allow needs the chosen tab first; deny never does. */
|
|
771
|
+
export function composeToolGrantResponse(decision, form = {}) {
|
|
772
|
+
const payload = toolGrantPayload(decision);
|
|
773
|
+
if (!payload) return { ok: false, reason: "unsupported" };
|
|
774
|
+
const choice = form.choice;
|
|
775
|
+
if (choice !== "allow" && choice !== "deny") return { ok: false, reason: "incomplete" };
|
|
776
|
+
if (choice === "allow" && targetSelectionNeeded(payload)) return { ok: false, reason: "target_required" };
|
|
777
|
+
return { ok: true, response: { toolGrant: { choice, proposalDigest: payload.proposalDigest } } };
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
export function toolGrantOutcome(decision) {
|
|
781
|
+
if (!isToolGrant(decision) || !record(decision.answer)) return null;
|
|
782
|
+
if (decision.answer.cancelled === true) return "cancelled";
|
|
783
|
+
const grant = record(decision.answer.toolGrant) ? decision.answer.toolGrant : null;
|
|
784
|
+
if (grant?.choice === "allow") return "allowed";
|
|
785
|
+
if (grant?.choice === "deny") return "denied";
|
|
786
|
+
return null;
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
export function toolGrantOutcomeText(decision) {
|
|
790
|
+
const outcome = toolGrantOutcome(decision);
|
|
791
|
+
return outcome === null ? null : t(`decisions.tool.outcome.${outcome}`);
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
/** A gateway refusal in the user's words; the code itself never reaches the screen. */
|
|
795
|
+
export function toolErrorText(error, status = 0) {
|
|
796
|
+
const key = typeof error === "string" ? TOOL_ERROR_KEYS[error] : undefined;
|
|
797
|
+
return t(`decisions.tool.error.${key ?? (status === 403 && error ? "forbidden" : "generic")}`);
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
function httpOrigin(url) {
|
|
801
|
+
try {
|
|
802
|
+
const parsed = new URL(url);
|
|
803
|
+
return parsed.protocol === "https:" || parsed.protocol === "http:" ? parsed : null;
|
|
804
|
+
} catch {
|
|
805
|
+
return null;
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
/** The open tabs the gateway listed for the user's screen (never for the model): suggested ones first, no local or non-web pages. */
|
|
810
|
+
export function targetCandidates(body) {
|
|
811
|
+
if (!record(body) || !Array.isArray(body.candidates)) return [];
|
|
812
|
+
const list = [];
|
|
813
|
+
for (const entry of body.candidates) {
|
|
814
|
+
if (!record(entry) || typeof entry.id !== "string" || entry.id === "" || typeof entry.url !== "string") continue;
|
|
815
|
+
const parsed = httpOrigin(entry.url);
|
|
816
|
+
if (!parsed || parsed.hostname === "localhost" || parsed.hostname === "127.0.0.1" || parsed.hostname === "[::1]" || parsed.hostname === "0.0.0.0") continue;
|
|
817
|
+
const title = typeof entry.title === "string" && entry.title.trim() ? entry.title.trim() : parsed.hostname;
|
|
818
|
+
list.push({ id: entry.id, title, url: entry.url, origin: parsed.origin, suggested: entry.suggested === true });
|
|
819
|
+
}
|
|
820
|
+
return [...list.filter((entry) => entry.suggested), ...list.filter((entry) => !entry.suggested)];
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
export function candidatesPath(decision) {
|
|
824
|
+
return `/api/decisions/${encodeURIComponent(decision.id)}/aside-targets`;
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
/** Binds the chosen tab to the proposal; the gateway answers with the re-proposed decision whose digest allow then confirms. */
|
|
828
|
+
export function designateRequest(decision, candidateId, newId = () => crypto.randomUUID()) {
|
|
829
|
+
return { method: "POST", path: `/api/decisions/${encodeURIComponent(decision.id)}/aside-target`, body: { operationId: newId(), expectedRevision: decision.revision, candidateId } };
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
export function interpretDesignate(status, body) {
|
|
833
|
+
const decision = record(body) && typeof body.id === "string" && typeof body.revision === "number" ? body : null;
|
|
834
|
+
const latest = record(body) && record(body.decision) ? body.decision : null;
|
|
835
|
+
const error = record(body) && typeof body.error === "string" ? body.error : null;
|
|
836
|
+
if (status === 200 && decision) return { kind: "accepted", decision };
|
|
837
|
+
if (status === 409) return { kind: "conflict", decision: latest };
|
|
838
|
+
if (status === 404) return { kind: "unsupported", error, status };
|
|
839
|
+
if (status === 400 || status === 401 || status === 403 || status === 410) return { kind: "refused", error, status };
|
|
840
|
+
return { kind: "unavailable", error, status };
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
// Artifact review holds (agent-collaboration-292 owner gap, hold UI): the inbox card of a security decision whose payload
|
|
844
|
+
// the files.read track built with purpose collaboration_artifact_review. The gateway's DecisionInfo carries
|
|
845
|
+
// `artifactReview` (the hold state, the reading agent, the file, the part asked for and the finding summary: kinds and
|
|
846
|
+
// counts only, never a flagged value). Allow and deny go to the owner hold route with the revision on screen and a fresh
|
|
847
|
+
// submissionId per click; the generic answer route refuses these decisions (security_endpoint_required), so no draft,
|
|
848
|
+
// recheck or dismiss is built for them.
|
|
849
|
+
export const ARTIFACT_REVIEW_PURPOSE = "collaboration_artifact_review";
|
|
850
|
+
export const ARTIFACT_HOLDS_PATH = "/api/agent-collaboration/holds";
|
|
851
|
+
const REVIEW_STATES = new Set(["pending", "allowed", "denied", "cancelled", "invalidated"]);
|
|
852
|
+
const REVIEW_VIEWS = new Set(["raw", "text", "pages"]);
|
|
853
|
+
|
|
854
|
+
/** The validated owner view of a held read, or null when the decision carries none (an ordinary or message-hold decision). */
|
|
855
|
+
export function artifactReview(decision) {
|
|
856
|
+
const review = record(decision?.artifactReview) ? decision.artifactReview : null;
|
|
857
|
+
if (!review || typeof review.holdId !== "string" || review.holdId === "" || !Number.isSafeInteger(review.revision) || review.revision < 1 ||
|
|
858
|
+
!REVIEW_STATES.has(review.state) || !REVIEW_VIEWS.has(review.view) || !record(review.range) ||
|
|
859
|
+
!Number.isSafeInteger(review.range.start) || !Number.isSafeInteger(review.range.end)) return null;
|
|
860
|
+
return review;
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
export function isArtifactReview(decision) {
|
|
864
|
+
return artifactReview(decision) !== null;
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
/** The finding summary line: each kind with its count, never a value; the words for no finding and for a hold without a summary. */
|
|
868
|
+
export function reviewFindingsText(summary) {
|
|
869
|
+
if (!record(summary) || !Array.isArray(summary.findings)) return t("decisions.artifact.findings.unknown");
|
|
870
|
+
const items = summary.findings.filter((f) => record(f) && typeof f.kind === "string" && Number.isSafeInteger(f.count) && f.count > 0);
|
|
871
|
+
if (items.length === 0) return t("decisions.artifact.findings.none");
|
|
872
|
+
return items.map((f) => t("decisions.artifact.findings.item", { kind: dictionaryText(`decisions.artifact.kind.${f.kind}`) ?? f.kind, count: f.count })).join(", ");
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
/** The pages the reader could not check (no text layer) as the card's warning; null when every page was scanned. */
|
|
876
|
+
export function reviewUnscannedText(summary) {
|
|
877
|
+
const pages = record(summary) && Array.isArray(summary.unscannedPages) ? summary.unscannedPages.filter((p) => Number.isSafeInteger(p) && p > 0) : [];
|
|
878
|
+
return pages.length ? t("decisions.artifact.unscanned", { pages: pages.join(", ") }) : null;
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
function reviewPartText(review) {
|
|
882
|
+
if (review.view === "raw") return t("decisions.artifact.part.raw");
|
|
883
|
+
if (review.view === "text") return t("decisions.artifact.part.text");
|
|
884
|
+
return review.range.end > review.range.start
|
|
885
|
+
? t("decisions.artifact.part.pages", { start: review.range.start, end: review.range.end })
|
|
886
|
+
: t("decisions.artifact.part.page", { page: review.range.start });
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
/** The outcome of a reviewed hold in the owner's words (allowed splits by how far the resume got); null while it is pending. */
|
|
890
|
+
export function reviewOutcomeText(review) {
|
|
891
|
+
switch (review.state) {
|
|
892
|
+
case "allowed": {
|
|
893
|
+
const resume = review.resumeState;
|
|
894
|
+
if (resume === "released") return t("decisions.artifact.outcome.allowed.released");
|
|
895
|
+
if (resume === "queued" || resume === "dispatched") return t("decisions.artifact.outcome.allowed.resumed");
|
|
896
|
+
if (resume === "unrequested") return t("decisions.artifact.outcome.allowed.waiting");
|
|
897
|
+
return t("decisions.artifact.outcome.allowed.stopped", { state: typeof resume === "string" ? resume : "unknown" });
|
|
898
|
+
}
|
|
899
|
+
case "denied": return t("decisions.artifact.outcome.denied");
|
|
900
|
+
case "cancelled": return t("decisions.artifact.outcome.cancelled");
|
|
901
|
+
case "invalidated": return t("decisions.artifact.outcome.invalidated");
|
|
902
|
+
default: return null;
|
|
903
|
+
}
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
/** What the card shows: title, the four rows, the unscanned warning, the question while pending, the outcome once reviewed, and the two choices. */
|
|
907
|
+
export function artifactReviewView(decision, { agentName = (id) => id, mainName = "main" } = {}) {
|
|
908
|
+
const review = artifactReview(decision);
|
|
909
|
+
if (!review) return null;
|
|
910
|
+
const actions = Array.isArray(decision.allowedActions) ? decision.allowedActions : [];
|
|
911
|
+
const pending = review.state === "pending" && actions.includes("answer");
|
|
912
|
+
return {
|
|
913
|
+
holdId: review.holdId, revision: review.revision, state: review.state, resumeState: review.resumeState,
|
|
914
|
+
title: t("decisions.artifact.title"),
|
|
915
|
+
chip: review.state === "pending" ? null : t(`decisions.artifact.chip.${review.state}`),
|
|
916
|
+
rows: [
|
|
917
|
+
{ key: "agent", label: t("decisions.artifact.label.agent"), value: review.agentId === null ? t("decisions.artifact.agent.main", { name: mainName }) : agentName(review.agentId) },
|
|
918
|
+
{ key: "file", label: t("decisions.artifact.label.file"), value: typeof review.fileName === "string" && review.fileName ? review.fileName : review.artifactId },
|
|
919
|
+
{ key: "part", label: t("decisions.artifact.label.part"), value: reviewPartText(review) },
|
|
920
|
+
{ key: "findings", label: t("decisions.artifact.label.findings"), value: reviewFindingsText(review.summary) },
|
|
921
|
+
],
|
|
922
|
+
warning: reviewUnscannedText(review.summary),
|
|
923
|
+
question: pending ? t("decisions.artifact.question") : null,
|
|
924
|
+
outcome: reviewOutcomeText(review),
|
|
925
|
+
pending,
|
|
926
|
+
choices: [{ choice: "allow", label: t("decisions.artifact.allow"), cls: "ok" }, { choice: "deny", label: t("decisions.artifact.deny"), cls: "danger" }],
|
|
927
|
+
};
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
/** The owner call of one click: the revision on screen and a fresh submissionId every time; null unless the hold still takes an answer. */
|
|
931
|
+
export function artifactReviewRequest(decision, choice, newId = () => crypto.randomUUID()) {
|
|
932
|
+
const review = artifactReview(decision);
|
|
933
|
+
if (!review || (choice !== "allow" && choice !== "deny")) return null;
|
|
934
|
+
const actions = Array.isArray(decision.allowedActions) ? decision.allowedActions : [];
|
|
935
|
+
if (review.state !== "pending" || !actions.includes("answer")) return null;
|
|
936
|
+
return { method: "POST", path: `${ARTIFACT_HOLDS_PATH}/${encodeURIComponent(review.holdId)}`, headers: { "content-type": "application/json" },
|
|
937
|
+
body: JSON.stringify({ choice, expectedRevision: review.revision, submissionId: newId() }) };
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
/**
|
|
941
|
+
* What the hold route's answer means. 200 carries the review receipt (reviewState, revision, resumeState) and reads as
|
|
942
|
+
* the outcome; every refusal is a sentence: 409 is a conflict with the latest state (the card reloads it), 401/403 mean
|
|
943
|
+
* the gateway did not accept this browser as the owner, 503 owner_auth_unavailable means the action is not available
|
|
944
|
+
* yet, 404 means the hold is gone, 0 is the network. Nothing here ever claims a success the gateway did not report.
|
|
945
|
+
*/
|
|
946
|
+
export function artifactReviewOutcome(status, body) {
|
|
947
|
+
const code = record(body) && typeof body.error === "string" ? body.error : null;
|
|
948
|
+
const refusal = (kind, line) => ({ ok: false, kind, revision: null, resumeState: null, line });
|
|
949
|
+
if (status === 200 && record(body) && (body.reviewState === "allowed" || body.reviewState === "denied" || body.reviewState === "cancelled")) {
|
|
950
|
+
const resumeState = typeof body.resumeState === "string" ? body.resumeState : "unknown";
|
|
951
|
+
return { ok: true, kind: body.reviewState, revision: Number.isSafeInteger(body.revision) ? body.revision : null, resumeState,
|
|
952
|
+
line: reviewOutcomeText({ state: body.reviewState, resumeState }) };
|
|
953
|
+
}
|
|
954
|
+
if (status === 0) return refusal("network", t("decisions.artifact.error.network"));
|
|
955
|
+
if (status === 503 && code === "owner_auth_unavailable") return refusal("unavailable", t("decisions.artifact.error.unavailable"));
|
|
956
|
+
if (status === 401 || status === 403) return refusal("owner", t("decisions.artifact.error.ownerRequired"));
|
|
957
|
+
if (status === 404) return refusal("gone", t("decisions.artifact.error.gone"));
|
|
958
|
+
if (status === 409) return refusal("conflict", t("decisions.artifact.error.conflict"));
|
|
959
|
+
return refusal("failed", t("decisions.artifact.error.failed", { reason: code ?? `HTTP ${status}` }));
|
|
960
|
+
}
|
package/web/gc.d.ts
CHANGED
|
@@ -1,15 +1,36 @@
|
|
|
1
|
-
export
|
|
2
|
-
export interface
|
|
1
|
+
export type GcRole = "reconcile" | "gc";
|
|
2
|
+
export interface GcCandidateBody {
|
|
3
|
+
id: string;
|
|
4
|
+
collector: string;
|
|
5
|
+
target: string;
|
|
6
|
+
reason: string;
|
|
7
|
+
ageMs: number | null;
|
|
8
|
+
bytes: number | null;
|
|
9
|
+
details?: Record<string, unknown>;
|
|
10
|
+
verdict?: { kind: string; why: string };
|
|
11
|
+
confirmation?: string;
|
|
12
|
+
reversibility?: string;
|
|
13
|
+
}
|
|
14
|
+
export interface GcKeptBody { id: string; collector: string; target: string; why: string; bytes: number | null; reversibility?: string }
|
|
15
|
+
export interface GcRow { id: string; role: GcRole; selectable: boolean; label: string; sub: string; title: string; badge: string | null; note: string | null }
|
|
16
|
+
export interface GcGroup { role: GcRole; title: string; count: number; hidden: boolean; rows: GcRow[] }
|
|
17
|
+
export interface AuditRow { at: string; text: string; title: string; error: boolean }
|
|
3
18
|
|
|
4
|
-
export const
|
|
5
|
-
export const
|
|
19
|
+
export const HOUSEKEEPING_PATH: string;
|
|
20
|
+
export const HOUSEKEEPING_CLEAN_PATH: string;
|
|
21
|
+
export const HOUSEKEEPING_AUDIT_PATH: string;
|
|
22
|
+
export const AUDIT_LINES: number;
|
|
23
|
+
export const ROLES: readonly GcRole[];
|
|
6
24
|
export const KIND_LABELS: Readonly<Record<string, string>>;
|
|
7
25
|
export const GC_EMPTY_LINE: string;
|
|
8
26
|
export const GC_FAILED_LINE: string;
|
|
9
27
|
export function formatBytes(bytes: unknown): string;
|
|
10
28
|
export function formatAge(ms: number): string;
|
|
11
|
-
export function
|
|
29
|
+
export function reasonLabel(reason: string): string;
|
|
30
|
+
export function rowFlags(role: string, kept: boolean, reversibility: string | undefined): { selectable: boolean; irreversible: boolean };
|
|
31
|
+
export function gcView(report: unknown): { summary: string; groups: GcGroup[]; error: boolean };
|
|
12
32
|
export function selectAllState(ticked: number, total: number): { hidden: boolean; checked: boolean; indeterminate: boolean };
|
|
13
33
|
export function selectAllTarget(ticked: number, total: number): boolean;
|
|
14
34
|
export function cleanRequest(ids: Iterable<string>): { method: "POST"; headers: { "content-type": "application/json" }; body: string };
|
|
15
35
|
export function cleanResultLine(result: unknown): string;
|
|
36
|
+
export function auditView(body: unknown): { line: string; error: boolean; rows: AuditRow[] };
|
package/web/gc.js
CHANGED
|
@@ -3,14 +3,22 @@ import strings from "./i18n/gc.js";
|
|
|
3
3
|
|
|
4
4
|
registerStrings(strings);
|
|
5
5
|
|
|
6
|
-
// Improvement 66
|
|
7
|
-
// can exercise them. The gateway lists candidates at GET /api/
|
|
6
|
+
// Improvement 66 and housekeeping todo 13: the settings cleanup area. Only decisions live here, with no DOM, so
|
|
7
|
+
// test/unit/gc-web.test.ts can exercise them. The gateway lists candidates by role at GET /api/housekeeping (상태 정리 rows
|
|
8
|
+
// wait for main, 회수 rows can be ticked, kept items are information) and cleans only the ids the user ticked.
|
|
8
9
|
|
|
9
|
-
export const
|
|
10
|
-
export const
|
|
10
|
+
export const HOUSEKEEPING_PATH = "/api/housekeeping";
|
|
11
|
+
export const HOUSEKEEPING_CLEAN_PATH = "/api/housekeeping/clean";
|
|
12
|
+
export const HOUSEKEEPING_AUDIT_PATH = "/api/housekeeping/audit";
|
|
13
|
+
export const AUDIT_LINES = 20;
|
|
14
|
+
export const ROLES = Object.freeze(["reconcile", "gc"]);
|
|
11
15
|
export let KIND_LABELS;
|
|
12
16
|
export let GC_EMPTY_LINE = t("gc.empty");
|
|
13
17
|
export let GC_FAILED_LINE = t("gc.failed");
|
|
18
|
+
let REASON_LABELS;
|
|
19
|
+
// Every code with a `gc.reason.<code>` entry in the dictionary; test/unit/gc-web.test.ts checks that every code the
|
|
20
|
+
// collectors, the engine and the API emit has one.
|
|
21
|
+
const REASON_CODES = Object.keys(strings.en).filter((key) => key.startsWith("gc.reason.")).map((key) => key.slice("gc.reason.".length));
|
|
14
22
|
function refreshStrings() {
|
|
15
23
|
KIND_LABELS = Object.freeze({
|
|
16
24
|
"db-backup": t("gc.dbBackup"),
|
|
@@ -19,7 +27,16 @@ function refreshStrings() {
|
|
|
19
27
|
"temp-dir": t("gc.tempDir"),
|
|
20
28
|
"rpc-daemon": t("gc.rpcDaemon"),
|
|
21
29
|
"agent-trash": t("gc.agentTrash"),
|
|
30
|
+
"agent-file-trash": t("gc.agentFileTrash"),
|
|
31
|
+
"agent-drafts": t("gc.agentDrafts"),
|
|
32
|
+
"orphan-test-process": t("gc.orphanTestProcess"),
|
|
33
|
+
worktree: t("gc.worktree"),
|
|
34
|
+
"docker-image": t("gc.dockerImage"),
|
|
35
|
+
"docker-build-cache": t("gc.dockerBuildCache"),
|
|
36
|
+
"reconcile-work": t("gc.reconcileWork"),
|
|
37
|
+
"attachment-cleanup": t("gc.attachmentCleanup"),
|
|
22
38
|
});
|
|
39
|
+
REASON_LABELS = Object.freeze(Object.fromEntries(REASON_CODES.map((code) => [code, t(`gc.reason.${code}`)])));
|
|
23
40
|
GC_EMPTY_LINE = t("gc.empty");
|
|
24
41
|
GC_FAILED_LINE = t("gc.failed");
|
|
25
42
|
}
|
|
@@ -40,16 +57,79 @@ export function formatAge(ms) {
|
|
|
40
57
|
return t("gc.ageMinutes", { count: minutes });
|
|
41
58
|
}
|
|
42
59
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
60
|
+
// A reason is either a sentence the gateway already wrote in the UI language (the six old kinds) or a code of a newer
|
|
61
|
+
// collector (`old_tag`, `reconcile_confirm_no_report`); a code the dictionary does not know is shown as written.
|
|
62
|
+
// `kept:<why>` (a picked item a rule now keeps, in a failed clean or the log) reads as the kept line with the label of <why>.
|
|
63
|
+
export function reasonLabel(reason) {
|
|
64
|
+
if (reason.startsWith("kept:")) return t("gc.keptReason", { why: reasonLabel(reason.slice("kept:".length)) });
|
|
65
|
+
return REASON_LABELS[reason] ?? reason;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function kindLabel(collector) {
|
|
69
|
+
return KIND_LABELS[collector] ?? collector;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// The last segment of a path, or the tag of an image reference (`zigmini-gateway:0.7.0-beta.3`); a target with spaces is
|
|
73
|
+
// a description, not a name.
|
|
74
|
+
function shortName(target) {
|
|
75
|
+
if (target.includes(" ")) return null;
|
|
76
|
+
return target.slice((target.includes("/") ? target.lastIndexOf("/") : target.lastIndexOf(":")) + 1);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function rowLabel(collector, name) {
|
|
80
|
+
return name === null ? kindLabel(collector) : `${kindLabel(collector)} · ${name}`;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Only an open 회수 candidate can be ticked: a 상태 정리 row waits for main and a kept item is information. "Cannot be
|
|
84
|
+
// undone" marks a tickable row whose collector cannot restore what it removes (Docker images).
|
|
85
|
+
export function rowFlags(role, kept, reversibility) {
|
|
86
|
+
const selectable = role === "gc" && !kept;
|
|
87
|
+
return { selectable, irreversible: selectable && reversibility === "irreversible" };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function candidateRow(role, c) {
|
|
91
|
+
const flags = rowFlags(role, false, c.reversibility);
|
|
92
|
+
const reconcile = role === "reconcile";
|
|
93
|
+
const parts = [reasonLabel(c.reason)];
|
|
94
|
+
if (!reconcile) parts.push(...(typeof c.ageMs === "number" ? [formatAge(c.ageMs)] : []), formatBytes(c.bytes));
|
|
95
|
+
return {
|
|
46
96
|
id: c.id,
|
|
47
|
-
|
|
48
|
-
|
|
97
|
+
role,
|
|
98
|
+
selectable: flags.selectable,
|
|
99
|
+
label: rowLabel(c.collector, reconcile ? c.details?.title ?? c.target : shortName(c.target)),
|
|
100
|
+
sub: parts.join(" · "),
|
|
49
101
|
title: c.target,
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
102
|
+
badge: flags.irreversible ? t("gc.irreversible") : null,
|
|
103
|
+
note: reconcile ? t(c.details?.kind === "close" ? "gc.reconcileClosed" : "gc.reconcileNote") : null,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function keptRow(role, k) {
|
|
108
|
+
return {
|
|
109
|
+
id: k.id,
|
|
110
|
+
role,
|
|
111
|
+
selectable: false,
|
|
112
|
+
label: rowLabel(k.collector, shortName(k.target)),
|
|
113
|
+
sub: `${reasonLabel(k.why)} · ${formatBytes(k.bytes)}`,
|
|
114
|
+
title: k.target,
|
|
115
|
+
badge: null,
|
|
116
|
+
note: null,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// One group per role, 상태 정리 first. `count` is the open candidates; kept items follow them as read-only rows and a group
|
|
121
|
+
// with no rows at all is hidden. The summary counts both roles and shows the 회수 bytes.
|
|
122
|
+
export function gcView(report) {
|
|
123
|
+
const roles = report?.roles;
|
|
124
|
+
if (roles === null || typeof roles !== "object" || !ROLES.every((role) => Array.isArray(roles[role]?.candidates))) return { summary: t("gc.failed"), groups: [], error: true };
|
|
125
|
+
const groups = ROLES.map((role) => {
|
|
126
|
+
const kept = Array.isArray(roles[role].kept) ? roles[role].kept : [];
|
|
127
|
+
const rows = [...roles[role].candidates.map((c) => candidateRow(role, c)), ...kept.map((k) => keptRow(role, k))];
|
|
128
|
+
return { role, title: t(`gc.role.${role}`), count: roles[role].candidates.length, hidden: rows.length === 0, rows };
|
|
129
|
+
});
|
|
130
|
+
const count = groups.reduce((sum, group) => sum + group.count, 0);
|
|
131
|
+
const summary = count === 0 ? t("gc.empty") : t("gc.summary", { count, bytes: formatBytes(roles.gc.bytes) });
|
|
132
|
+
return { summary, groups, error: false };
|
|
53
133
|
}
|
|
54
134
|
|
|
55
135
|
// Improvement 142: the select-all box beside the summary. It is hidden with no rows, checked when every row is ticked and
|
|
@@ -70,6 +150,26 @@ export function cleanRequest(ids) {
|
|
|
70
150
|
export function cleanResultLine(result) {
|
|
71
151
|
const cleaned = result?.cleaned?.length ?? 0;
|
|
72
152
|
const failed = result?.failed?.length ?? 0;
|
|
153
|
+
const kept = result?.kept?.length ?? 0;
|
|
73
154
|
const missing = result?.missing?.length ?? 0;
|
|
74
|
-
return t("gc.cleanResult", {
|
|
155
|
+
return t("gc.cleanResult", {
|
|
156
|
+
cleaned,
|
|
157
|
+
failed: failed > 0 ? t("gc.failedCount", { count: failed }) : "",
|
|
158
|
+
kept: kept > 0 ? t("gc.keptCount", { count: kept }) : "",
|
|
159
|
+
missing: missing > 0 ? t("gc.missingCount", { count: missing }) : "",
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function auditRow(line) {
|
|
164
|
+
const what = rowLabel(line.collector, line.target === null ? null : shortName(line.target));
|
|
165
|
+
const error = reasonLabel(line.error ?? "");
|
|
166
|
+
const outcome = line.verdict === "error" ? t("gc.audit.error", { error }) : line.ok ? t(`gc.method.${line.method}`) : t("gc.audit.failedLine", { error });
|
|
167
|
+
return { at: line.at, text: `${t(`gc.actor.${line.actor}`)} · ${what} · ${outcome}`, title: line.target ?? "", error: !line.ok };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// 기록 보기: the audit lines GET /api/housekeeping/audit returned (oldest first), newest first for reading.
|
|
171
|
+
export function auditView(body) {
|
|
172
|
+
if (body === null || typeof body !== "object" || !Array.isArray(body.lines)) return { line: t("gc.audit.failed"), error: true, rows: [] };
|
|
173
|
+
const rows = body.lines.map(auditRow).reverse();
|
|
174
|
+
return { line: rows.length === 0 ? t("gc.audit.empty") : "", error: false, rows };
|
|
75
175
|
}
|