From 69a5e93190dac1661115828d8d520345044ed716 Mon Sep 17 00:00:00 2001 From: Holger Selover-Stephan Date: Sun, 25 Oct 2026 18:20:01 +0000 Subject: [PATCH 1/7] Refresh schema snapshot @36d2c9b9 and bind the automation approval operations (#1071) Snapshot read from hadron-server 36d2c9b98 (== origin/main, includes S1 #2441 at aef8e83b and #2434). Binds automationApprovals, automationApproval, approveAutomation and declineAutomation only; previewAutomation and reAdoptAutomation stay unbound (no call site). Worker: Jack (cli-engineer) Co-Authored-By: Claude Sonnet 5.5 --- internal/api/gen/generated.go | 748 +++++++++++++++++++++++- internal/api/queries/automation.graphql | 82 +++ internal/api/unbound-ops.txt | 12 +- schema/schema.graphql | 471 +++++++++++++-- 4 files changed, 1245 insertions(+), 68 deletions(-) create mode 100644 internal/api/queries/automation.graphql diff --git a/internal/api/gen/generated.go b/internal/api/gen/generated.go index 9bfff62e..dcf091f4 100644 --- a/internal/api/gen/generated.go +++ b/internal/api/gen/generated.go @@ -1364,7 +1364,7 @@ func (v *AgentTaskAssignAssignAgentTask) __premarshalJSON() (*__premarshalAgentT // AgentTaskAssignResponse is returned by AgentTaskAssign on success. type AgentTaskAssignResponse struct { // #1705 — assign a task with no assignee — a queued task (pending) or a BACKLOG task: the - // task's owner, a worker the owner reports to, or whoever manages the App + // task's owner, a worker the owner reports to, directly or up the reportsTo chain, or whoever manages the App // (hrn:node:hadronmemory.com:specs:cor:agt:050:09). closeSessionWhenDone, when // given, replaces the task's value; omitted, the stored value is kept. Assigning a BACKLOG task moves it to // pending (hrn:node:hadronmemory.com:specs:cor:agt:050:02, #1754). Refusals: TASK_NOT_FOUND, TASK_NOT_OWNER, @@ -1593,7 +1593,7 @@ func (v *AgentTaskCancelCancelAgentTask) __premarshalJSON() (*__premarshalAgentT // AgentTaskCancelResponse is returned by AgentTaskCancel on success. type AgentTaskCancelResponse struct { // #1705 — cancel a pending, started or backlog task: its owner, a worker the owner reports - // to, or whoever manages the App (hrn:node:hadronmemory.com:specs:cor:agt:050:09; a backlog task directly, + // to, directly or up the reportsTo chain, or whoever manages the App (hrn:node:hadronmemory.com:specs:cor:agt:050:09; a backlog task directly, // Holger team chat #8150). Refusals: // TASK_NOT_FOUND, TASK_NOT_OWNER, TASK_ENDED, SESSION_NOT_BOUND, APP_UNINSTALLED. CancelAgentTask *AgentTaskCancelCancelAgentTask `json:"cancelAgentTask"` @@ -3309,7 +3309,7 @@ type AgentTaskParkResponse struct { // #1754 — park a queued task in the backlog (hrn:node:hadronmemory.com:specs:cor:agt:050:02): pending // with no assignee becomes BACKLOG — unassigned, never matched, never waited // on, no alarms. Whoever may assign a task may park it: its owner, a worker - // the owner reports to, or whoever manages the App + // the owner reports to, directly or up the reportsTo chain, or whoever manages the App // (hrn:node:hadronmemory.com:specs:cor:agt:050:09). Refusals: TASK_NOT_FOUND, TASK_NOT_OWNER, // TASK_ENDED, TASK_INVALID_TRANSITION (not pending, or an assignee), // SESSION_NOT_BOUND, APP_UNINSTALLED. @@ -3849,7 +3849,7 @@ type AgentTaskReadyResponse struct { // #1754 — make a backlog task ready to be worked // (hrn:node:hadronmemory.com:specs:cor:agt:050:02): BACKLOG becomes PENDING and the task enters // the queue. Whoever may assign a task may make it ready: its owner, a worker - // the owner reports to, or whoever manages the App + // the owner reports to, directly or up the reportsTo chain, or whoever manages the App // (hrn:node:hadronmemory.com:specs:cor:agt:050:09). Refusals: TASK_NOT_FOUND, TASK_NOT_OWNER, // TASK_ENDED, TASK_INVALID_TRANSITION (not backlog), SESSION_NOT_BOUND, APP_UNINSTALLED. MakeAgentTaskReady *AgentTaskReadyMakeAgentTaskReadyAgentTask `json:"makeAgentTaskReady"` @@ -4084,7 +4084,7 @@ func (v *AgentTaskReassignReassignAgentTask) __premarshalJSON() (*__premarshalAg // AgentTaskReassignResponse is returned by AgentTaskReassign on success. type AgentTaskReassignResponse struct { - // #1705 — move a task to another assignee: the task's owner, a worker the owner reports to, or whoever + // #1705 — move a task to another assignee: the task's owner, a worker the owner reports to, directly or up the reportsTo chain, or whoever // manages the App (hrn:node:hadronmemory.com:specs:cor:agt:050:09). A started task stays started; the former // assignee can no longer move it. Refusals: TASK_NOT_FOUND, TASK_NOT_OWNER, // TASK_ENDED, TASK_INVALID_TRANSITION (no assignee yet, or the same one), @@ -6992,6 +6992,12 @@ func (v *AppDeliverySettingsInput) GetStuckWindowSeconds() *int { return v.Stuck // Filter for the uniform apps() list (#473). Clauses AND-combine. type AppFilter struct { + // #2343 — restricts the list to Apps that install this Agent (the AppAgent + // join; an App has no separate primary-agent column to match). Accepts an ID + // or a fully-qualified URN. The Agent must be one the caller can read: an + // unreadable, deleted, unresolvable or nonexistent Agent yields an empty page, + // never a not-found or forbidden error. Combines with ownedByMe and orgId. + AgentId *string `json:"agentId"` // #782 — true restricts the list to the caller's OWN user-owned (org-less) // apps: organizationId IS NULL AND ownerUserId = the caller. For ALL callers // INCLUDING platform ADMIN/OWNER (owner scope is never an admin-bypass @@ -7001,6 +7007,9 @@ type AppFilter struct { OwnedByMe *bool `json:"ownedByMe,omitempty"` } +// GetAgentId returns AppFilter.AgentId, and is useful for accessing the field via an interface. +func (v *AppFilter) GetAgentId() *string { return v.AgentId } + // GetOwnedByMe returns AppFilter.OwnedByMe, and is useful for accessing the field via an interface. func (v *AppFilter) GetOwnedByMe() *bool { return v.OwnedByMe } @@ -8232,6 +8241,15 @@ func (v *ApplyMemoryConfigTemplateResponse) GetApplyMemoryConfigTemplate() *Appl return v.ApplyMemoryConfigTemplate } +// ApproveAutomationResponse is returned by ApproveAutomation on success. +type ApproveAutomationResponse struct { + // #2441: adopt the automation by the hash the adopter reviewed (automationApproval.hash). Same door as reAdoptAutomation: one APPROVE event per successful call, even when nothing changed. A changed automation answers AUTOMATION_CHANGED. + ApproveAutomation bool `json:"approveAutomation"` +} + +// GetApproveAutomation returns ApproveAutomationResponse.ApproveAutomation, and is useful for accessing the field via an interface. +func (v *ApproveAutomationResponse) GetApproveAutomation() bool { return v.ApproveAutomation } + // ApproveMemoryNodesApproveMemoryNodesApproveMemoryNodesResult includes the requested fields of the GraphQL type ApproveMemoryNodesResult. // The GraphQL type's documentation follows. // @@ -8388,7 +8406,7 @@ func (v *ApproveMemoryNodesApproveMemoryNodesApproveMemoryNodesResultApprovedNod // ApproveMemoryNodesResponse is returned by ApproveMemoryNodes on success. type ApproveMemoryNodesResponse struct { - // #1591 — approve the current revision of every live, unminted node of a memory (ID or fully-qualified URN) that isn't approved yet, in one transaction. Placeholders are skipped and reported. + // #1591 — approve the current revision of every live node of a memory (ID or fully-qualified URN) in the mint scope (unminted, or minted and changed since or with no recorded minted revision, #1612) that isn't approved yet, in one transaction. Placeholders are skipped and reported. ApproveMemoryNodes *ApproveMemoryNodesApproveMemoryNodesApproveMemoryNodesResult `json:"approveMemoryNodes"` } @@ -9008,6 +9026,360 @@ type AuthContextResponse struct { // GetAuthContext returns AuthContextResponse.AuthContext, and is useful for accessing the field via an interface. func (v *AuthContextResponse) GetAuthContext() *AuthContextAuthContext { return v.AuthContext } +// #2441: UNADOPTED = no valid adoption; ADOPTED = a valid adoption; CHANGED = the automation differs from what was adopted (computed on open only), so the next run fails AUTOMATION_CHANGED. +type AutomationAdoptionState string + +const ( + AutomationAdoptionStateAdopted AutomationAdoptionState = "ADOPTED" + AutomationAdoptionStateChanged AutomationAdoptionState = "CHANGED" + AutomationAdoptionStateUnadopted AutomationAdoptionState = "UNADOPTED" +) + +var AllAutomationAdoptionState = []AutomationAdoptionState{ + AutomationAdoptionStateAdopted, + AutomationAdoptionStateChanged, + AutomationAdoptionStateUnadopted, +} + +// AutomationApprovalAutomationApproval includes the requested fields of the GraphQL type AutomationApproval. +// The GraphQL type's documentation follows. +// +// #2441: what an adopter reviews before approving. depth carries only what previewAutomation returns to the same caller, plus the registered resources with URL userinfo and query removed. +type AutomationApprovalAutomationApproval struct { + State AutomationAdoptionState `json:"state"` + Hash string `json:"hash"` + Summary *AutomationApprovalAutomationApprovalSummaryAutomationSummary `json:"summary"` +} + +// GetState returns AutomationApprovalAutomationApproval.State, and is useful for accessing the field via an interface. +func (v *AutomationApprovalAutomationApproval) GetState() AutomationAdoptionState { return v.State } + +// GetHash returns AutomationApprovalAutomationApproval.Hash, and is useful for accessing the field via an interface. +func (v *AutomationApprovalAutomationApproval) GetHash() string { return v.Hash } + +// GetSummary returns AutomationApprovalAutomationApproval.Summary, and is useful for accessing the field via an interface. +func (v *AutomationApprovalAutomationApproval) GetSummary() *AutomationApprovalAutomationApprovalSummaryAutomationSummary { + return v.Summary +} + +// AutomationApprovalAutomationApprovalSummaryAutomationSummary includes the requested fields of the GraphQL type AutomationSummary. +// The GraphQL type's documentation follows. +// +// #2441: plain-language facts derived from the previewed automation. A fact the pin cannot show says not stated; nothing here is a maximum the approval enforces. +type AutomationApprovalAutomationApprovalSummaryAutomationSummary struct { + Name string `json:"name"` + Schedule *string `json:"schedule"` + Reads []string `json:"reads"` + Writes []string `json:"writes"` + Tools []string `json:"tools"` + Limits []string `json:"limits"` + CannotDo []string `json:"cannotDo"` + Flows []*AutomationApprovalAutomationApprovalSummaryAutomationSummaryFlowsAutomationFlowSummary `json:"flows"` +} + +// GetName returns AutomationApprovalAutomationApprovalSummaryAutomationSummary.Name, and is useful for accessing the field via an interface. +func (v *AutomationApprovalAutomationApprovalSummaryAutomationSummary) GetName() string { + return v.Name +} + +// GetSchedule returns AutomationApprovalAutomationApprovalSummaryAutomationSummary.Schedule, and is useful for accessing the field via an interface. +func (v *AutomationApprovalAutomationApprovalSummaryAutomationSummary) GetSchedule() *string { + return v.Schedule +} + +// GetReads returns AutomationApprovalAutomationApprovalSummaryAutomationSummary.Reads, and is useful for accessing the field via an interface. +func (v *AutomationApprovalAutomationApprovalSummaryAutomationSummary) GetReads() []string { + return v.Reads +} + +// GetWrites returns AutomationApprovalAutomationApprovalSummaryAutomationSummary.Writes, and is useful for accessing the field via an interface. +func (v *AutomationApprovalAutomationApprovalSummaryAutomationSummary) GetWrites() []string { + return v.Writes +} + +// GetTools returns AutomationApprovalAutomationApprovalSummaryAutomationSummary.Tools, and is useful for accessing the field via an interface. +func (v *AutomationApprovalAutomationApprovalSummaryAutomationSummary) GetTools() []string { + return v.Tools +} + +// GetLimits returns AutomationApprovalAutomationApprovalSummaryAutomationSummary.Limits, and is useful for accessing the field via an interface. +func (v *AutomationApprovalAutomationApprovalSummaryAutomationSummary) GetLimits() []string { + return v.Limits +} + +// GetCannotDo returns AutomationApprovalAutomationApprovalSummaryAutomationSummary.CannotDo, and is useful for accessing the field via an interface. +func (v *AutomationApprovalAutomationApprovalSummaryAutomationSummary) GetCannotDo() []string { + return v.CannotDo +} + +// GetFlows returns AutomationApprovalAutomationApprovalSummaryAutomationSummary.Flows, and is useful for accessing the field via an interface. +func (v *AutomationApprovalAutomationApprovalSummaryAutomationSummary) GetFlows() []*AutomationApprovalAutomationApprovalSummaryAutomationSummaryFlowsAutomationFlowSummary { + return v.Flows +} + +// AutomationApprovalAutomationApprovalSummaryAutomationSummaryFlowsAutomationFlowSummary includes the requested fields of the GraphQL type AutomationFlowSummary. +// The GraphQL type's documentation follows. +// +// #2441: one flow of an automation (the entry flow or a flow it routes to). +type AutomationApprovalAutomationApprovalSummaryAutomationSummaryFlowsAutomationFlowSummary struct { + Name string `json:"name"` + Urn string `json:"urn"` + Tools []string `json:"tools"` + Writes []string `json:"writes"` +} + +// GetName returns AutomationApprovalAutomationApprovalSummaryAutomationSummaryFlowsAutomationFlowSummary.Name, and is useful for accessing the field via an interface. +func (v *AutomationApprovalAutomationApprovalSummaryAutomationSummaryFlowsAutomationFlowSummary) GetName() string { + return v.Name +} + +// GetUrn returns AutomationApprovalAutomationApprovalSummaryAutomationSummaryFlowsAutomationFlowSummary.Urn, and is useful for accessing the field via an interface. +func (v *AutomationApprovalAutomationApprovalSummaryAutomationSummaryFlowsAutomationFlowSummary) GetUrn() string { + return v.Urn +} + +// GetTools returns AutomationApprovalAutomationApprovalSummaryAutomationSummaryFlowsAutomationFlowSummary.Tools, and is useful for accessing the field via an interface. +func (v *AutomationApprovalAutomationApprovalSummaryAutomationSummaryFlowsAutomationFlowSummary) GetTools() []string { + return v.Tools +} + +// GetWrites returns AutomationApprovalAutomationApprovalSummaryAutomationSummaryFlowsAutomationFlowSummary.Writes, and is useful for accessing the field via an interface. +func (v *AutomationApprovalAutomationApprovalSummaryAutomationSummaryFlowsAutomationFlowSummary) GetWrites() []string { + return v.Writes +} + +// AutomationApprovalDepthAutomationApproval includes the requested fields of the GraphQL type AutomationApproval. +// The GraphQL type's documentation follows. +// +// #2441: what an adopter reviews before approving. depth carries only what previewAutomation returns to the same caller, plus the registered resources with URL userinfo and query removed. +type AutomationApprovalDepthAutomationApproval struct { + State AutomationAdoptionState `json:"state"` + Hash string `json:"hash"` + Depth json.RawMessage `json:"depth"` + Summary *AutomationApprovalDepthAutomationApprovalSummaryAutomationSummary `json:"summary"` +} + +// GetState returns AutomationApprovalDepthAutomationApproval.State, and is useful for accessing the field via an interface. +func (v *AutomationApprovalDepthAutomationApproval) GetState() AutomationAdoptionState { + return v.State +} + +// GetHash returns AutomationApprovalDepthAutomationApproval.Hash, and is useful for accessing the field via an interface. +func (v *AutomationApprovalDepthAutomationApproval) GetHash() string { return v.Hash } + +// GetDepth returns AutomationApprovalDepthAutomationApproval.Depth, and is useful for accessing the field via an interface. +func (v *AutomationApprovalDepthAutomationApproval) GetDepth() json.RawMessage { return v.Depth } + +// GetSummary returns AutomationApprovalDepthAutomationApproval.Summary, and is useful for accessing the field via an interface. +func (v *AutomationApprovalDepthAutomationApproval) GetSummary() *AutomationApprovalDepthAutomationApprovalSummaryAutomationSummary { + return v.Summary +} + +// AutomationApprovalDepthAutomationApprovalSummaryAutomationSummary includes the requested fields of the GraphQL type AutomationSummary. +// The GraphQL type's documentation follows. +// +// #2441: plain-language facts derived from the previewed automation. A fact the pin cannot show says not stated; nothing here is a maximum the approval enforces. +type AutomationApprovalDepthAutomationApprovalSummaryAutomationSummary struct { + Name string `json:"name"` + Schedule *string `json:"schedule"` + Reads []string `json:"reads"` + Writes []string `json:"writes"` + Tools []string `json:"tools"` + Limits []string `json:"limits"` + CannotDo []string `json:"cannotDo"` + Flows []*AutomationApprovalDepthAutomationApprovalSummaryAutomationSummaryFlowsAutomationFlowSummary `json:"flows"` +} + +// GetName returns AutomationApprovalDepthAutomationApprovalSummaryAutomationSummary.Name, and is useful for accessing the field via an interface. +func (v *AutomationApprovalDepthAutomationApprovalSummaryAutomationSummary) GetName() string { + return v.Name +} + +// GetSchedule returns AutomationApprovalDepthAutomationApprovalSummaryAutomationSummary.Schedule, and is useful for accessing the field via an interface. +func (v *AutomationApprovalDepthAutomationApprovalSummaryAutomationSummary) GetSchedule() *string { + return v.Schedule +} + +// GetReads returns AutomationApprovalDepthAutomationApprovalSummaryAutomationSummary.Reads, and is useful for accessing the field via an interface. +func (v *AutomationApprovalDepthAutomationApprovalSummaryAutomationSummary) GetReads() []string { + return v.Reads +} + +// GetWrites returns AutomationApprovalDepthAutomationApprovalSummaryAutomationSummary.Writes, and is useful for accessing the field via an interface. +func (v *AutomationApprovalDepthAutomationApprovalSummaryAutomationSummary) GetWrites() []string { + return v.Writes +} + +// GetTools returns AutomationApprovalDepthAutomationApprovalSummaryAutomationSummary.Tools, and is useful for accessing the field via an interface. +func (v *AutomationApprovalDepthAutomationApprovalSummaryAutomationSummary) GetTools() []string { + return v.Tools +} + +// GetLimits returns AutomationApprovalDepthAutomationApprovalSummaryAutomationSummary.Limits, and is useful for accessing the field via an interface. +func (v *AutomationApprovalDepthAutomationApprovalSummaryAutomationSummary) GetLimits() []string { + return v.Limits +} + +// GetCannotDo returns AutomationApprovalDepthAutomationApprovalSummaryAutomationSummary.CannotDo, and is useful for accessing the field via an interface. +func (v *AutomationApprovalDepthAutomationApprovalSummaryAutomationSummary) GetCannotDo() []string { + return v.CannotDo +} + +// GetFlows returns AutomationApprovalDepthAutomationApprovalSummaryAutomationSummary.Flows, and is useful for accessing the field via an interface. +func (v *AutomationApprovalDepthAutomationApprovalSummaryAutomationSummary) GetFlows() []*AutomationApprovalDepthAutomationApprovalSummaryAutomationSummaryFlowsAutomationFlowSummary { + return v.Flows +} + +// AutomationApprovalDepthAutomationApprovalSummaryAutomationSummaryFlowsAutomationFlowSummary includes the requested fields of the GraphQL type AutomationFlowSummary. +// The GraphQL type's documentation follows. +// +// #2441: one flow of an automation (the entry flow or a flow it routes to). +type AutomationApprovalDepthAutomationApprovalSummaryAutomationSummaryFlowsAutomationFlowSummary struct { + Name string `json:"name"` + Urn string `json:"urn"` + Tools []string `json:"tools"` + Writes []string `json:"writes"` +} + +// GetName returns AutomationApprovalDepthAutomationApprovalSummaryAutomationSummaryFlowsAutomationFlowSummary.Name, and is useful for accessing the field via an interface. +func (v *AutomationApprovalDepthAutomationApprovalSummaryAutomationSummaryFlowsAutomationFlowSummary) GetName() string { + return v.Name +} + +// GetUrn returns AutomationApprovalDepthAutomationApprovalSummaryAutomationSummaryFlowsAutomationFlowSummary.Urn, and is useful for accessing the field via an interface. +func (v *AutomationApprovalDepthAutomationApprovalSummaryAutomationSummaryFlowsAutomationFlowSummary) GetUrn() string { + return v.Urn +} + +// GetTools returns AutomationApprovalDepthAutomationApprovalSummaryAutomationSummaryFlowsAutomationFlowSummary.Tools, and is useful for accessing the field via an interface. +func (v *AutomationApprovalDepthAutomationApprovalSummaryAutomationSummaryFlowsAutomationFlowSummary) GetTools() []string { + return v.Tools +} + +// GetWrites returns AutomationApprovalDepthAutomationApprovalSummaryAutomationSummaryFlowsAutomationFlowSummary.Writes, and is useful for accessing the field via an interface. +func (v *AutomationApprovalDepthAutomationApprovalSummaryAutomationSummaryFlowsAutomationFlowSummary) GetWrites() []string { + return v.Writes +} + +// AutomationApprovalDepthResponse is returned by AutomationApprovalDepth on success. +type AutomationApprovalDepthResponse struct { + // #2441: state, hash, plain summary and graph depth for one trigger. MANUAL takes the origin run id. Same gate as approveAutomation. + AutomationApproval *AutomationApprovalDepthAutomationApproval `json:"automationApproval"` +} + +// GetAutomationApproval returns AutomationApprovalDepthResponse.AutomationApproval, and is useful for accessing the field via an interface. +func (v *AutomationApprovalDepthResponse) GetAutomationApproval() *AutomationApprovalDepthAutomationApproval { + return v.AutomationApproval +} + +// AutomationApprovalResponse is returned by AutomationApproval on success. +type AutomationApprovalResponse struct { + // #2441: state, hash, plain summary and graph depth for one trigger. MANUAL takes the origin run id. Same gate as approveAutomation. + AutomationApproval *AutomationApprovalAutomationApproval `json:"automationApproval"` +} + +// GetAutomationApproval returns AutomationApprovalResponse.AutomationApproval, and is useful for accessing the field via an interface. +func (v *AutomationApprovalResponse) GetAutomationApproval() *AutomationApprovalAutomationApproval { + return v.AutomationApproval +} + +// AutomationApprovalsAutomationApprovalsAutomationApprovalPage includes the requested fields of the GraphQL type AutomationApprovalPage. +type AutomationApprovalsAutomationApprovalsAutomationApprovalPage struct { + Rows []*AutomationApprovalsAutomationApprovalsAutomationApprovalPageRowsAutomationApprovalRow `json:"rows"` + NextCursor *string `json:"nextCursor"` + Omitted []*AutomationApprovalsAutomationApprovalsAutomationApprovalPageOmittedAutomationApprovalOmission `json:"omitted"` +} + +// GetRows returns AutomationApprovalsAutomationApprovalsAutomationApprovalPage.Rows, and is useful for accessing the field via an interface. +func (v *AutomationApprovalsAutomationApprovalsAutomationApprovalPage) GetRows() []*AutomationApprovalsAutomationApprovalsAutomationApprovalPageRowsAutomationApprovalRow { + return v.Rows +} + +// GetNextCursor returns AutomationApprovalsAutomationApprovalsAutomationApprovalPage.NextCursor, and is useful for accessing the field via an interface. +func (v *AutomationApprovalsAutomationApprovalsAutomationApprovalPage) GetNextCursor() *string { + return v.NextCursor +} + +// GetOmitted returns AutomationApprovalsAutomationApprovalsAutomationApprovalPage.Omitted, and is useful for accessing the field via an interface. +func (v *AutomationApprovalsAutomationApprovalsAutomationApprovalPage) GetOmitted() []*AutomationApprovalsAutomationApprovalsAutomationApprovalPageOmittedAutomationApprovalOmission { + return v.Omitted +} + +// AutomationApprovalsAutomationApprovalsAutomationApprovalPageOmittedAutomationApprovalOmission includes the requested fields of the GraphQL type AutomationApprovalOmission. +type AutomationApprovalsAutomationApprovalsAutomationApprovalPageOmittedAutomationApprovalOmission struct { + TriggerKind AutomationTriggerKind `json:"triggerKind"` + Reason string `json:"reason"` +} + +// GetTriggerKind returns AutomationApprovalsAutomationApprovalsAutomationApprovalPageOmittedAutomationApprovalOmission.TriggerKind, and is useful for accessing the field via an interface. +func (v *AutomationApprovalsAutomationApprovalsAutomationApprovalPageOmittedAutomationApprovalOmission) GetTriggerKind() AutomationTriggerKind { + return v.TriggerKind +} + +// GetReason returns AutomationApprovalsAutomationApprovalsAutomationApprovalPageOmittedAutomationApprovalOmission.Reason, and is useful for accessing the field via an interface. +func (v *AutomationApprovalsAutomationApprovalsAutomationApprovalPageOmittedAutomationApprovalOmission) GetReason() string { + return v.Reason +} + +// AutomationApprovalsAutomationApprovalsAutomationApprovalPageRowsAutomationApprovalRow includes the requested fields of the GraphQL type AutomationApprovalRow. +type AutomationApprovalsAutomationApprovalsAutomationApprovalPageRowsAutomationApprovalRow struct { + TriggerKind AutomationTriggerKind `json:"triggerKind"` + TriggerRef string `json:"triggerRef"` + Name string `json:"name"` + State AutomationAdoptionState `json:"state"` + Headline string `json:"headline"` +} + +// GetTriggerKind returns AutomationApprovalsAutomationApprovalsAutomationApprovalPageRowsAutomationApprovalRow.TriggerKind, and is useful for accessing the field via an interface. +func (v *AutomationApprovalsAutomationApprovalsAutomationApprovalPageRowsAutomationApprovalRow) GetTriggerKind() AutomationTriggerKind { + return v.TriggerKind +} + +// GetTriggerRef returns AutomationApprovalsAutomationApprovalsAutomationApprovalPageRowsAutomationApprovalRow.TriggerRef, and is useful for accessing the field via an interface. +func (v *AutomationApprovalsAutomationApprovalsAutomationApprovalPageRowsAutomationApprovalRow) GetTriggerRef() string { + return v.TriggerRef +} + +// GetName returns AutomationApprovalsAutomationApprovalsAutomationApprovalPageRowsAutomationApprovalRow.Name, and is useful for accessing the field via an interface. +func (v *AutomationApprovalsAutomationApprovalsAutomationApprovalPageRowsAutomationApprovalRow) GetName() string { + return v.Name +} + +// GetState returns AutomationApprovalsAutomationApprovalsAutomationApprovalPageRowsAutomationApprovalRow.State, and is useful for accessing the field via an interface. +func (v *AutomationApprovalsAutomationApprovalsAutomationApprovalPageRowsAutomationApprovalRow) GetState() AutomationAdoptionState { + return v.State +} + +// GetHeadline returns AutomationApprovalsAutomationApprovalsAutomationApprovalPageRowsAutomationApprovalRow.Headline, and is useful for accessing the field via an interface. +func (v *AutomationApprovalsAutomationApprovalsAutomationApprovalPageRowsAutomationApprovalRow) GetHeadline() string { + return v.Headline +} + +// AutomationApprovalsResponse is returned by AutomationApprovals on success. +type AutomationApprovalsResponse struct { + // #2441: the schedules and webhooks of an App that the caller may adopt, with their adoption state. Authorization first, then ordered by (name, kind, id) and paged: limit defaults to 20, maximum 50; after is the previous page's nextCursor. MANUAL triggers are never listed (consent is per run); omitted says so. Only an adopter sees any row. + AutomationApprovals *AutomationApprovalsAutomationApprovalsAutomationApprovalPage `json:"automationApprovals"` +} + +// GetAutomationApprovals returns AutomationApprovalsResponse.AutomationApprovals, and is useful for accessing the field via an interface. +func (v *AutomationApprovalsResponse) GetAutomationApprovals() *AutomationApprovalsAutomationApprovalsAutomationApprovalPage { + return v.AutomationApprovals +} + +type AutomationTriggerKind string + +const ( + AutomationTriggerKindManual AutomationTriggerKind = "MANUAL" + AutomationTriggerKindSchedule AutomationTriggerKind = "SCHEDULE" + AutomationTriggerKindWebhook AutomationTriggerKind = "WEBHOOK" +) + +var AllAutomationTriggerKind = []AutomationTriggerKind{ + AutomationTriggerKindManual, + AutomationTriggerKindSchedule, + AutomationTriggerKindWebhook, +} + // Why a worker or a model family is unavailable for work (hrn:node:hadronmemory.com:specs:cor:agt:060:02; held was dropped, team chat #7449). type AvailabilityReason string @@ -19892,6 +20264,15 @@ func (v *CreditLedgerEntryFields) GetCreatedAt() string { return v.CreatedAt } // GetCreatedBy returns CreditLedgerEntryFields.CreatedBy, and is useful for accessing the field via an interface. func (v *CreditLedgerEntryFields) GetCreatedBy() *string { return v.CreatedBy } +// DeclineAutomationResponse is returned by DeclineAutomation on success. +type DeclineAutomationResponse struct { + // #2441: decline. An adopted trigger is revoked, so its next run fails AUTOMATION_UNADOPTED (REVOKE event); with nothing adopted it changes no state (DECLINE event). A MANUAL origin deletes its consent row. Runs already started keep their snapshot. + DeclineAutomation bool `json:"declineAutomation"` +} + +// GetDeclineAutomation returns DeclineAutomationResponse.DeclineAutomation, and is useful for accessing the field via an interface. +func (v *DeclineAutomationResponse) GetDeclineAutomation() bool { return v.DeclineAutomation } + // DeleteAgentResponse is returned by DeleteAgent on success. type DeleteAgentResponse struct { // Soft-delete an Agent and its owned system memory. Requires management @@ -19899,6 +20280,11 @@ type DeleteAgentResponse struct { // App install or live parent Agent import references it, or a live Agent // extends it as its base. AgentMemoryItem attachments do not block deletion. // + // Also refused with DECISION_CONTAINED (#1641) while the Agent's system memory holds a decision or a + // decision log: removing the memory would remove them, so the Agent is kept. This guard is live + // whether or not minted-corpus permanence is enforced; the separate minted-corpus refusal applies only + // when that enforcement is on. + // // See hrn:node:hadronmemory.com:specs:cor:dmo:050:14. // // Accepts the entity's ID or URN. @@ -19952,8 +20338,10 @@ func (v *DeleteAppResponse) GetDeleteApp() bool { return v.DeleteApp } // DeleteChannelResponse is returned by DeleteChannel on success. type DeleteChannelResponse struct { // Spec 049 Phase 4 — soft-delete a Channel AND its chat root together (the - // protection holds while either exists, so the address stays reserved and a - // restore brings both back). Host memory write access required. An App's + // protection holds while either exists). There is no restore: creating a + // Channel at the same address adopts it and starts clean, so the old + // transcript does not come back (#1257, #1226). Host memory write access + // required. An App's // default Channel is unlinked from the App. ref: its id or its address // (`Channel.chatRootUrn`, #1171). DeleteChannel bool `json:"deleteChannel"` @@ -27320,7 +27708,7 @@ type IssueHostOperationResponse struct { // #1709 — issue an operation to one of the App's registered hosts (hrn:node:hadronmemory.com:specs:cor:agt:070:05). // A type outside the closed set is HOST_OPERATION_TYPE_UNKNOWN (hrn:node:hadronmemory.com:specs:cor:agt:070:03), a // missing, extra or malformed argument BAD_HOST_OPERATION_ARGS. worktree.create and worktree.remove: the worker's - // holder, the worker it reports to, or whoever manages the App (else HOST_OPERATION_NOT_PERMITTED). worker.start: + // holder, the worker it reports to directly (not further up the chain), or whoever manages the App (else HOST_OPERATION_NOT_PERMITTED). worker.start: // whoever manages the App only, with an optional taskId, for a held worker with no live session and no start in // flight, in a worktree and family the live host registers for it (WORKER_NOT_HELD, WORKER_LIVE, // WORKER_START_PENDING). worker.end: whoever manages the App only (else HOST_OPERATION_NOT_PERMITTED), naming an open @@ -30347,7 +30735,7 @@ type MintMemoryNodesMintMemoryNodesSpecCorpusMintReport struct { Minted bool `json:"minted"` // #1591 — how many nodes this call minted (0 on a dry run or refusal). MintedCount int `json:"mintedCount"` - // #1591 — the locs this call minted, or would mint on a dry run, ordered by loc. + // #1591 — the locs of NEW nodes this call minted, or would mint on a dry run, ordered by loc. MintLocs []string `json:"mintLocs"` StaleAbstractsBlock bool `json:"staleAbstractsBlock"` Blockers []*MintMemoryNodesMintMemoryNodesSpecCorpusMintReportBlockersSpecMintFinding `json:"blockers"` @@ -30508,18 +30896,21 @@ func (v *MintMemoryNodesMintMemoryNodesSpecCorpusMintReportStaleAbstractsSpecMin // MintMemoryNodesResponse is returned by MintMemoryNodes on success. type MintMemoryNodesResponse struct { - // #1591 — mint the unminted live nodes of a memory, at once, excluding - // comment nodes and the decision role family. Each node is + // #1591 — mint the live nodes of a memory in the mint scope, at once, + // excluding comment nodes and the decision role family. The scope is the + // unminted nodes plus (#1612) minted nodes whose content changed since their + // minted revision or that have no recorded minted revision; a re-mint keeps + // the first mint's time and minter. Each node is // stamped with its current revision and that revision's approval hash // (Node.mintStatus); a minted node's moves then record URN aliases. Refuses - // SPEC_CORPUS_MINT_BLOCKED while any blocker remains among the unminted - // nodes: a placeholder, an unresolved reference, a structural lint error over + // SPEC_CORPUS_MINT_BLOCKED while any blocker remains among the nodes in + // scope: a placeholder, an unresolved reference, a structural lint error over // spec-marked or citation-shaped nodes (nodetype-info, tag-spec, serialization-leak, // duplicate-loc), a stale abstract once staleAbstractsBlock is true, // NOT_APPROVED (the current revision is not approved), TAMPERED (approved // text changed outside the revision system), or CITES_UNMINTED (an - // edge into another memory's node that is not minted). Nodes minted earlier - // are not re-checked. dryRun returns the full report without minting. Any + // edge into another memory's node that is not minted). Minted nodes outside + // the scope are not re-checked. dryRun returns the full report without minting. Any // writer of the memory. memoryRef accepts the ID or URN. A real run holds // every write to the memory off while it checks and stamps, and refuses // SPEC_CORPUS_BUSY (retryable) if a write is already in flight. Encrypted @@ -39467,9 +39858,14 @@ type ResolveUrnResponse struct { // Access is checked by kind: memory records use the memory-record read gate; nodes use the // readable-memory set; organizations require membership (or platform administration); // agents admit public visibility or owner/org access; Apps admit owner/org access; - // workers use the shared worker-read gate. App runs require org access, and node - // revisions use their captured memory (falling back to the current node's memory). - // A retired worker still resolves. Dedicated queries can have different refusal shapes. + // workers use the shared worker-read gate. Users resolve for themselves, a platform + // administrator, or a caller who shares an organization with them. App runs require org + // access, and node revisions use their captured memory (falling back to the current + // node's memory); a retracted comment's revision history resolves only for memory + // managers. Under impersonation only the support session's shared organization admits + // organizations, Apps, non-public Agents, workers and App runs, and a user-owned App or + // non-public Agent is not visible; a PUBLIC Agent still resolves. A retired worker still resolves. The kinds above are checked by kind, + // not an exhaustive promise. Dedicated queries can have different refusal shapes. // // The type prefix is the dispatch key; an untyped root:slug is ambiguous across kinds // and returns null. The optional #data fragment is supported only for nodes and App runs. @@ -40935,8 +41331,11 @@ func (v *RouterRegisterResponse) GetAttendeeRegister() []*RouterRegisterAttendee // RunTaskResponse is returned by RunTask on success. type RunTaskResponse struct { - // Run a task — a runnable node (isRunnable=true) — identified by a single - // nodeRef (a node PK or a fully-qualified node URN, hrn:node:::), + // Render or execute a node identified by a single nodeRef (a node PK or a + // fully-qualified node URN, hrn:node:::). This explicit-reference + // operation does not require isRunnable=true. The runnable flag governs by-name + // task discovery, resolution and listing (hrn:node:hadronmemory.com:specs:cor:api:060:01). + // The reference is // resolved by the shared resolveNodeRef (#542). A bare loc without a memory // is rejected — pass a full URN or an ID. For loose-name matching and // interactive task selection use the MCP hadron_run_task tool, which @@ -45821,7 +46220,7 @@ func (v *SpecCorpusStateResponse) GetMemory() *SpecCorpusStateMemory { return v. type SpecMintFindingKind string const ( - // #1591 — an edge into another memory's node that is not minted yet (targetLoc is its URN). + // #1591 — an edge, live pending edge or text citation (a prefixed node URN) into another memory's node that is not minted yet (targetLoc is its URN). SpecMintFindingKindCitesUnminted SpecMintFindingKind = "CITES_UNMINTED" // A structural lint error (rule: nodetype-info | tag-spec | serialization-leak | duplicate-loc). SpecMintFindingKindLint SpecMintFindingKind = "LINT" @@ -46582,11 +46981,11 @@ func (v *StopSignalFields) GetEndedByHostKey() bool { return v.EndedByHostKey } type StopSignalLevel string const ( - // Also interrupts each covered worker, to save its work and write a hand-off (the notice is recorded but not yet delivered: no interrupt happens yet). + // Also tells each covered worker to save its work and write a hand-off now. The notice is typed as an ordinary message into a verified-empty input, with no interrupt key (Holger, option A, 2026-10-24); one that cannot be typed is logged and flagged to the coordinator. StopSignalLevelHalt StopSignalLevel = "HALT" - // Also stops ticks and stall time. Each covered worker is told to finish or stop at its next sub-task (the notice is recorded but not yet delivered). + // Also stops ticks and stall time. Each covered worker is told to finish or stop at its next sub-task, by a consumer that has declared its lane (a set is refused until one has). StopSignalLevelPause StopSignalLevel = "PAUSE" - // Stops assignment only: matching skips the covered workers and a manual assignment to one is refused (TASK_STOPPED). Its coordinators are told (the notice is recorded but not yet delivered). + // Stops assignment only: matching skips the covered workers and a manual assignment to one is refused (TASK_STOPPED). Its coordinators are told, by a consumer that has declared their lane (a set is refused until one has). StopSignalLevelPauseDispatch StopSignalLevel = "PAUSE_DISPATCH" ) @@ -50524,8 +50923,9 @@ type TeamStopSignalSetResponse struct { // #2022 (hrn:node:hadronmemory.com:specs:cor:agt:090:01, :06) — set a stop signal. Whoever manages the App: any level and // scope. A host key: a HALT on its own host only. Never replaces another signal. Refusals: APP_MANAGER_REQUIRED, // HOST_SCOPE_REQUIRED, STOP_SIGNAL_SCOPE_FORBIDDEN, STOP_SIGNAL_NOT_FOUND (the target), BAD_USER_INPUT, APP_NOT_FOUND. - // #2173 (R95): until the stop-notice lane is in effect, setting ANY signal is refused with STOP_SIGNAL_UNAVAILABLE (a worker - // would be stopped without being told); lifting and listing are unaffected, and the refusal lifts when the lane is turned on. + // #2173/#2169 (R95): setting ANY signal is refused with STOP_SIGNAL_UNAVAILABLE while a worker it would cover is on a lane with + // no fresh stop-consumer declaration (a worker would be stopped without being told; the extensions name the worker count and the + // lanes). A consumer declares a lane by polling dueWakes with accepts: [STOP]. Lifting and listing are unaffected. SetTeamStopSignal *TeamStopSignalSetSetTeamStopSignal `json:"setTeamStopSignal"` } @@ -61473,6 +61873,22 @@ func (v *__ApplyMemoryConfigTemplateInput) GetExpectedTemplateRevision() *int { // GetDryRun returns __ApplyMemoryConfigTemplateInput.DryRun, and is useful for accessing the field via an interface. func (v *__ApplyMemoryConfigTemplateInput) GetDryRun() bool { return v.DryRun } +// __ApproveAutomationInput is used internally by genqlient +type __ApproveAutomationInput struct { + TriggerKind AutomationTriggerKind `json:"triggerKind"` + TriggerRef string `json:"triggerRef"` + ExpectedHash string `json:"expectedHash"` +} + +// GetTriggerKind returns __ApproveAutomationInput.TriggerKind, and is useful for accessing the field via an interface. +func (v *__ApproveAutomationInput) GetTriggerKind() AutomationTriggerKind { return v.TriggerKind } + +// GetTriggerRef returns __ApproveAutomationInput.TriggerRef, and is useful for accessing the field via an interface. +func (v *__ApproveAutomationInput) GetTriggerRef() string { return v.TriggerRef } + +// GetExpectedHash returns __ApproveAutomationInput.ExpectedHash, and is useful for accessing the field via an interface. +func (v *__ApproveAutomationInput) GetExpectedHash() string { return v.ExpectedHash } + // __ApproveMemoryNodesInput is used internally by genqlient type __ApproveMemoryNodesInput struct { MemoryRef string `json:"memoryRef"` @@ -61541,6 +61957,42 @@ func (v *__AttachMemoryToAppInput) GetAppRef() string { return v.AppRef } // GetAgentRef returns __AttachMemoryToAppInput.AgentRef, and is useful for accessing the field via an interface. func (v *__AttachMemoryToAppInput) GetAgentRef() string { return v.AgentRef } +// __AutomationApprovalDepthInput is used internally by genqlient +type __AutomationApprovalDepthInput struct { + TriggerKind AutomationTriggerKind `json:"triggerKind"` + TriggerRef string `json:"triggerRef"` +} + +// GetTriggerKind returns __AutomationApprovalDepthInput.TriggerKind, and is useful for accessing the field via an interface. +func (v *__AutomationApprovalDepthInput) GetTriggerKind() AutomationTriggerKind { return v.TriggerKind } + +// GetTriggerRef returns __AutomationApprovalDepthInput.TriggerRef, and is useful for accessing the field via an interface. +func (v *__AutomationApprovalDepthInput) GetTriggerRef() string { return v.TriggerRef } + +// __AutomationApprovalInput is used internally by genqlient +type __AutomationApprovalInput struct { + TriggerKind AutomationTriggerKind `json:"triggerKind"` + TriggerRef string `json:"triggerRef"` +} + +// GetTriggerKind returns __AutomationApprovalInput.TriggerKind, and is useful for accessing the field via an interface. +func (v *__AutomationApprovalInput) GetTriggerKind() AutomationTriggerKind { return v.TriggerKind } + +// GetTriggerRef returns __AutomationApprovalInput.TriggerRef, and is useful for accessing the field via an interface. +func (v *__AutomationApprovalInput) GetTriggerRef() string { return v.TriggerRef } + +// __AutomationApprovalsInput is used internally by genqlient +type __AutomationApprovalsInput struct { + AppRef string `json:"appRef"` + After *string `json:"after"` +} + +// GetAppRef returns __AutomationApprovalsInput.AppRef, and is useful for accessing the field via an interface. +func (v *__AutomationApprovalsInput) GetAppRef() string { return v.AppRef } + +// GetAfter returns __AutomationApprovalsInput.After, and is useful for accessing the field via an interface. +func (v *__AutomationApprovalsInput) GetAfter() *string { return v.After } + // __BeginAssetUploadInput is used internally by genqlient type __BeginAssetUploadInput struct { MemoryId string `json:"memoryId"` @@ -62767,6 +63219,18 @@ func (v *__CreditLedgerEntriesInput) GetLimit() *int { return v.Limit } // GetOffset returns __CreditLedgerEntriesInput.Offset, and is useful for accessing the field via an interface. func (v *__CreditLedgerEntriesInput) GetOffset() *int { return v.Offset } +// __DeclineAutomationInput is used internally by genqlient +type __DeclineAutomationInput struct { + TriggerKind AutomationTriggerKind `json:"triggerKind"` + TriggerRef string `json:"triggerRef"` +} + +// GetTriggerKind returns __DeclineAutomationInput.TriggerKind, and is useful for accessing the field via an interface. +func (v *__DeclineAutomationInput) GetTriggerKind() AutomationTriggerKind { return v.TriggerKind } + +// GetTriggerRef returns __DeclineAutomationInput.TriggerRef, and is useful for accessing the field via an interface. +func (v *__DeclineAutomationInput) GetTriggerRef() string { return v.TriggerRef } + // __DeleteAgentInput is used internally by genqlient type __DeleteAgentInput struct { Ref string `json:"ref"` @@ -68880,6 +69344,44 @@ func ApplyMemoryConfigTemplate( return data_, err_ } +// The mutation executed by ApproveAutomation. +const ApproveAutomation_Operation = ` +mutation ApproveAutomation ($triggerKind: AutomationTriggerKind!, $triggerRef: String!, $expectedHash: String!) { + approveAutomation(triggerKind: $triggerKind, triggerRef: $triggerRef, expectedHash: $expectedHash) +} +` + +// Adopts the automation by the hash the adopter just reviewed. A changed +// automation answers AUTOMATION_CHANGED; the CLI never retries with a new hash. +func ApproveAutomation( + ctx_ context.Context, + client_ graphql.Client, + triggerKind AutomationTriggerKind, + triggerRef string, + expectedHash string, +) (data_ *ApproveAutomationResponse, err_ error) { + req_ := &graphql.Request{ + OpName: "ApproveAutomation", + Query: ApproveAutomation_Operation, + Variables: &__ApproveAutomationInput{ + TriggerKind: triggerKind, + TriggerRef: triggerRef, + ExpectedHash: expectedHash, + }, + } + + data_ = &ApproveAutomationResponse{} + resp_ := &graphql.Response{Data: data_} + + err_ = client_.MakeRequest( + ctx_, + req_, + resp_, + ) + + return data_, err_ +} + // The mutation executed by ApproveMemoryNodes. const ApproveMemoryNodes_Operation = ` mutation ApproveMemoryNodes ($memoryRef: String!) { @@ -69199,6 +69701,163 @@ func AuthContext( return data_, err_ } +// The query executed by AutomationApproval. +const AutomationApproval_Operation = ` +query AutomationApproval ($triggerKind: AutomationTriggerKind!, $triggerRef: String!) { + automationApproval(triggerKind: $triggerKind, triggerRef: $triggerRef) { + state + hash + summary { + name + schedule + reads + writes + tools + limits + cannotDo + flows { + name + urn + tools + writes + } + } + } +} +` + +// What an adopter reviews before approving: state, the hash to approve, and the +// plain summary. Selected WITHOUT the graph depth. +func AutomationApproval( + ctx_ context.Context, + client_ graphql.Client, + triggerKind AutomationTriggerKind, + triggerRef string, +) (data_ *AutomationApprovalResponse, err_ error) { + req_ := &graphql.Request{ + OpName: "AutomationApproval", + Query: AutomationApproval_Operation, + Variables: &__AutomationApprovalInput{ + TriggerKind: triggerKind, + TriggerRef: triggerRef, + }, + } + + data_ = &AutomationApprovalResponse{} + resp_ := &graphql.Response{Data: data_} + + err_ = client_.MakeRequest( + ctx_, + req_, + resp_, + ) + + return data_, err_ +} + +// The query executed by AutomationApprovalDepth. +const AutomationApprovalDepth_Operation = ` +query AutomationApprovalDepth ($triggerKind: AutomationTriggerKind!, $triggerRef: String!) { + automationApproval(triggerKind: $triggerKind, triggerRef: $triggerRef) { + state + hash + depth + summary { + name + schedule + reads + writes + tools + limits + cannotDo + flows { + name + urn + tools + writes + } + } + } +} +` + +// The same projection plus the graph (`depth`), for `approval --depth`. +func AutomationApprovalDepth( + ctx_ context.Context, + client_ graphql.Client, + triggerKind AutomationTriggerKind, + triggerRef string, +) (data_ *AutomationApprovalDepthResponse, err_ error) { + req_ := &graphql.Request{ + OpName: "AutomationApprovalDepth", + Query: AutomationApprovalDepth_Operation, + Variables: &__AutomationApprovalDepthInput{ + TriggerKind: triggerKind, + TriggerRef: triggerRef, + }, + } + + data_ = &AutomationApprovalDepthResponse{} + resp_ := &graphql.Response{Data: data_} + + err_ = client_.MakeRequest( + ctx_, + req_, + resp_, + ) + + return data_, err_ +} + +// The query executed by AutomationApprovals. +const AutomationApprovals_Operation = ` +query AutomationApprovals ($appRef: String!, $after: String) { + automationApprovals(appRef: $appRef, after: $after) { + rows { + triggerKind + triggerRef + name + state + headline + } + nextCursor + omitted { + triggerKind + reason + } + } +} +` + +// One page of the adoptable schedules and webhooks of an App. `omitted` names the +// trigger kinds the list never carries (MANUAL: consent is per run). +func AutomationApprovals( + ctx_ context.Context, + client_ graphql.Client, + appRef string, + after *string, +) (data_ *AutomationApprovalsResponse, err_ error) { + req_ := &graphql.Request{ + OpName: "AutomationApprovals", + Query: AutomationApprovals_Operation, + Variables: &__AutomationApprovalsInput{ + AppRef: appRef, + After: after, + }, + } + + data_ = &AutomationApprovalsResponse{} + resp_ := &graphql.Response{Data: data_} + + err_ = client_.MakeRequest( + ctx_, + req_, + resp_, + ) + + return data_, err_ +} + // The mutation executed by BeginAssetUpload. const BeginAssetUpload_Operation = ` mutation BeginAssetUpload ($memoryId: ID!, $filename: String!, $mimeType: String!, $sizeBytes: Int!, $intent: UploadIntent!, $description: String) { @@ -73015,6 +73674,41 @@ func CreditLedgerEntries( return data_, err_ } +// The mutation executed by DeclineAutomation. +const DeclineAutomation_Operation = ` +mutation DeclineAutomation ($triggerKind: AutomationTriggerKind!, $triggerRef: String!) { + declineAutomation(triggerKind: $triggerKind, triggerRef: $triggerRef) +} +` + +// Takes NO hash: decline only removes authority. +func DeclineAutomation( + ctx_ context.Context, + client_ graphql.Client, + triggerKind AutomationTriggerKind, + triggerRef string, +) (data_ *DeclineAutomationResponse, err_ error) { + req_ := &graphql.Request{ + OpName: "DeclineAutomation", + Query: DeclineAutomation_Operation, + Variables: &__DeclineAutomationInput{ + TriggerKind: triggerKind, + TriggerRef: triggerRef, + }, + } + + data_ = &DeclineAutomationResponse{} + resp_ := &graphql.Response{Data: data_} + + err_ = client_.MakeRequest( + ctx_, + req_, + resp_, + ) + + return data_, err_ +} + // The mutation executed by DeleteAgent. const DeleteAgent_Operation = ` mutation DeleteAgent ($ref: ID!) { diff --git a/internal/api/queries/automation.graphql b/internal/api/queries/automation.graphql new file mode 100644 index 00000000..f76bc12b --- /dev/null +++ b/internal/api/queries/automation.graphql @@ -0,0 +1,82 @@ +# Automation approval (#1071, hadron-server#2441). The server derives every +# sentence, state and hash; the CLI binds the wire names and never computes any +# of them. The list carries UNADOPTED/ADOPTED only — CHANGED exists on the +# detail projection alone. + +# One page of the adoptable schedules and webhooks of an App. `omitted` names the +# trigger kinds the list never carries (MANUAL: consent is per run). +query AutomationApprovals($appRef: String!, $after: String) { + automationApprovals(appRef: $appRef, after: $after) { + rows { + triggerKind + triggerRef + name + state + headline + } + nextCursor + omitted { + triggerKind + reason + } + } +} + +# What an adopter reviews before approving: state, the hash to approve, and the +# plain summary. Selected WITHOUT the graph depth. +query AutomationApproval($triggerKind: AutomationTriggerKind!, $triggerRef: String!) { + automationApproval(triggerKind: $triggerKind, triggerRef: $triggerRef) { + state + hash + summary { + name + schedule + reads + writes + tools + limits + cannotDo + flows { + name + urn + tools + writes + } + } + } +} + +# The same projection plus the graph (`depth`), for `approval --depth`. +query AutomationApprovalDepth($triggerKind: AutomationTriggerKind!, $triggerRef: String!) { + automationApproval(triggerKind: $triggerKind, triggerRef: $triggerRef) { + state + hash + depth + summary { + name + schedule + reads + writes + tools + limits + cannotDo + flows { + name + urn + tools + writes + } + } + } +} + +# Adopts the automation by the hash the adopter just reviewed. A changed +# automation answers AUTOMATION_CHANGED; the CLI never retries with a new hash. +mutation ApproveAutomation($triggerKind: AutomationTriggerKind!, $triggerRef: String!, $expectedHash: String!) { + approveAutomation(triggerKind: $triggerKind, triggerRef: $triggerRef, expectedHash: $expectedHash) +} + +# Takes NO hash: decline only removes authority. +mutation DeclineAutomation($triggerKind: AutomationTriggerKind!, $triggerRef: String!) { + declineAutomation(triggerKind: $triggerKind, triggerRef: $triggerRef) +} diff --git a/internal/api/unbound-ops.txt b/internal/api/unbound-ops.txt index b8c16206..6fce82b1 100644 --- a/internal/api/unbound-ops.txt +++ b/internal/api/unbound-ops.txt @@ -5,10 +5,11 @@ # operation and no command reaches it yet. Decide, then commit — either wire it # up (the line disappears) or leave it with a `# reason` noting why not. -[Query] 60 unbound +[Query] 61 unbound agentAIConfig agentAssets agentSubscriptions +agentTickerTemplates aiModelPrice aiServiceConfigs appAIConfig @@ -67,7 +68,7 @@ verifyDecision # server main schema refresh for cli#836; separate CLI parity ou verifyMemoryNodes # cli#802 exposes per-node verifyNode; memory-wide integrity audit needs a separate memory verify command workerWakeSettings # wake-surface v2; cli#828 tracks the paged attention surface, wake settings follow it -[Mutation] 107 unbound +[Mutation] 114 unbound acknowledgeWakes # wake-surface v2 (cor:agt:080:16); cli#828 lane acquireEditorLock addAgentToApp @@ -85,7 +86,11 @@ calendarUpdateEvent claimContextReset # server#2162 context-reset host protocol; host runtime delivery is outside asset-link CLI#897 clearAgentAIConfig clearWorkerIsolation # S7a remains inert; isolation CLI commands are outside the #2138 experiment slice +compileAgentBootPrompt +compileAppAgentBootPrompt +compileWorkerBootPrompt createAgentOrgGrant +createAgentTickerTemplate createAppKey createAppUser createDashboardWidgetPreset @@ -94,6 +99,7 @@ createHomeAssistantInstance createResendSender createRevision createSlackConnection +deleteAgentTickerTemplate deleteChat deleteCommentThread # cli#807 commands slice covers writer operations; admin thread deletion is a later parity slice deleteDashboardWidgetPreset @@ -157,6 +163,7 @@ setHostWorkerAvailability # host-side OUT_OF_TOKENS/HOST_DOWN marks: the cli#83 setMemorySourceToken setQuotaLimit setTeamPause # #2138 server/MCP control; this dispatched CLI slice covers status, settings, live-check and usesTasks only +setUserApiKeyHidden setWorkerWakeSettings # wake-surface v2; cli#828 tracks the paged attention surface, wake settings follow it startAgentSession startChat @@ -166,6 +173,7 @@ testExchangeConnection unlinkGithubApp updateAgentAIConfig updateAgentMemoryRole +updateAgentTickerTemplate updateChatTitle updateDashboardWidgetPreset updateHomeAssistantInstance diff --git a/schema/schema.graphql b/schema/schema.graphql index d11da6c6..b2a470f1 100644 --- a/schema/schema.graphql +++ b/schema/schema.graphql @@ -237,6 +237,11 @@ type Agent { it with setAgentIndexRefs; read the Agent's whole task list with agentTaskList. """ tasksIndexRef: ID + + """ + #2024 — the tickers this Agent declares. Casting a worker from it creates them for the worker (a snapshot: later edits reach only future casts). Readable by anyone who can read the Agent. + """ + tickerTemplates: [AgentTickerTemplate!]! type: AgentType! urn: String! @@ -244,6 +249,11 @@ type Agent { #2138: false means every Worker cast from this Agent counts as having a task and is expected to check its team channel every period (coordinators, the lifecycle agent). Default true. """ usesTasks: Boolean! + + """ + #2024 — whether the caller may create, change or delete this Agent's ticker templates (the Agent ADMIN door, the same as editing the Agent). Null when it cannot be decided. + """ + viewerMayEditTickerTemplates: Boolean visibility: AgentVisibility! } @@ -799,6 +809,58 @@ enum AgentTeamEventSeverity { ALARM } +""" +#2024 — one ticker an Agent declares. Casting a worker from the Agent into an App creates the worker's tickers from the live +templates and subscribes it, in the cast's own transaction. A SNAPSHOT: a later edit or removal reaches only future casts; +existing workers and tickers are untouched. Identity is the id, never the name or the position. The cadence is the requested +one; the cast raises it to the App's floor. Reasons are never empty. +""" +type AgentTickerTemplate { + agent: Agent! + cadenceSeconds: Int! + createdAt: DateTime! + id: ID! + name: String! + + """A timezone and weekly windows; null when none.""" + quietHours: JSON + reasons: [TickerReason!]! + relevance: TickerRelevance! + + """ + Counts edits; an update or delete must name the revision it saw, and a stale one is refused with TICKER_TEMPLATE_REVISION_MISMATCH. + """ + revision: Int! + + """The shape of this record. Only 1 exists.""" + shapeVersion: Int! + updatedAt: DateTime +} + +input AgentTickerTemplateInput { + cadenceSeconds: Int! + name: String! + quietHours: JSON + + """Absent = [READ_TEAM_CHANNEL]; an empty list is refused.""" + reasons: [TickerReason!] + relevance: TickerRelevance +} + +""" +Absent fields are unchanged. Use clearQuietHours to remove the quiet hours. +""" +input AgentTickerTemplatePatch { + cadenceSeconds: Int + clearQuietHours: Boolean + name: String + quietHours: JSON + + """An empty list is refused.""" + reasons: [TickerReason!] + relevance: TickerRelevance +} + enum AgentType { ASSISTANT CHATBOT @@ -1246,6 +1308,17 @@ type App { trainingMode: Boolean! updatedAt: DateTime urn: String! + + """ + #2109 — whether the current caller manages this App: its owner, an owner-role + member, a live ADMIN or OWNER of its organization, or a platform admin for an + organization-owned App. A known fact for an App the caller can read, so false + is an answer and not a refusal. False for an App key, an impersonated + session, a headless run and a request bound to a worker session. This is not + permission to perform every task or worker operation: each operation checks + its own authority. + """ + viewerManages: Boolean! } """Decrypted AI config for an App (owner / org admin only).""" @@ -1359,6 +1432,15 @@ input AppDeliverySettingsInput { """Filter for the uniform apps() list (#473). Clauses AND-combine.""" input AppFilter { + """ + #2343 — restricts the list to Apps that install this Agent (the AppAgent + join; an App has no separate primary-agent column to match). Accepts an ID + or a fully-qualified URN. The Agent must be one the caller can read: an + unreadable, deleted, unresolvable or nonexistent Agent yields an empty page, + never a not-found or forbidden error. Combines with ownedByMe and orgId. + """ + agentId: ID + """ #782 — true restricts the list to the caller's OWN user-owned (org-less) apps: organizationId IS NULL AND ownerUserId = the caller. For ALL callers @@ -1575,11 +1657,13 @@ type AppRun { failed — strictly more than a sum over the hops trail, which has no element for a hop that did not complete. - KNOWN GAP: a hop aborted by the TOOL LOOP (a tool denied by policy, or a - tool handler that threw) is NOT counted. The provider has already billed - that call, but the tool-loop helper discards the accumulated usage when a - tool throws, so the number is not available at the failure site. Such a run - under-reports. Do not treat this as a billing figure. + A tool-loop call that is REJECTED (a provider or transport error, a tool + denied by policy, a handler that threw, hadron_fail) counts the steps the + SDK had already completed, once: the provider billed them. Usage the SDK + did not report is not counted: the step that failed, and any completed step + without token counts. Such a run can still under-report. A rejection before + any step completed counts nothing, and unknown is never recorded as zero. + Do not treat this as a billing figure. NULL means UNKNOWN: a run minted before the counter existed, with no hop trail to back-fill from. 0 means it genuinely spent nothing. @@ -1935,6 +2019,68 @@ input AuthorizeContextResetStopInput { resetId: ID! } +""" +#2441: UNADOPTED = no valid adoption; ADOPTED = a valid adoption; CHANGED = the automation differs from what was adopted (computed on open only), so the next run fails AUTOMATION_CHANGED. +""" +enum AutomationAdoptionState { + ADOPTED + CHANGED + UNADOPTED +} + +""" +#2441: what an adopter reviews before approving. depth carries only what previewAutomation returns to the same caller, plus the registered resources with URL userinfo and query removed. +""" +type AutomationApproval { + depth: JSON! + hash: String! + state: AutomationAdoptionState! + summary: AutomationSummary! +} + +type AutomationApprovalOmission { + reason: String! + triggerKind: AutomationTriggerKind! +} + +type AutomationApprovalPage { + nextCursor: String + omitted: [AutomationApprovalOmission!]! + rows: [AutomationApprovalRow!]! +} + +type AutomationApprovalRow { + headline: String! + name: String! + state: AutomationAdoptionState! + triggerKind: AutomationTriggerKind! + triggerRef: String! +} + +""" +#2441: one flow of an automation (the entry flow or a flow it routes to). +""" +type AutomationFlowSummary { + name: String! + tools: [String!]! + urn: String! + writes: [String!]! +} + +""" +#2441: plain-language facts derived from the previewed automation. A fact the pin cannot show says not stated; nothing here is a maximum the approval enforces. +""" +type AutomationSummary { + cannotDo: [String!]! + flows: [AutomationFlowSummary!]! + limits: [String!]! + name: String! + reads: [String!]! + schedule: String + tools: [String!]! + writes: [String!]! +} + enum AutomationTriggerKind { MANUAL SCHEDULE @@ -2111,7 +2257,8 @@ type CalendarEventTime { """ An event boundary for create/update: exactly one of dateTime (timed) or date (YYYY-MM-DD, all-day). A dateTime needs timeZone unless it carries -its own UTC offset — a zone-less value is rejected, never guessed. +its own UTC offset — a zone-less value is rejected by the calendar tool +(validation_error), never guessed. """ input CalendarEventTimeInput { date: String @@ -2137,6 +2284,26 @@ type CalendarPerson { name: String } +"""#2024 — one ticker a cast would create from an Agent ticker template.""" +type CastPreviewTicker { + """ + What the ticker gets: the larger of the requested cadence and the App's floor. + """ + effectiveCadenceSeconds: Int! + + """ + The generated name: the template name, a middle dot, then the worker name. + """ + name: String! + reasons: [TickerReason!]! + relevance: TickerRelevance! + + """What the template asks for.""" + requestedCadenceSeconds: Int! + templateId: ID! + templateRevision: Int! +} + """ What castWorker WOULD do (#964) — an unsaved projection, deliberately not a Worker (no fake id to mistake for a real row). Scalars only: the ids/names @@ -2168,6 +2335,11 @@ type CastWorkerPreview { """The casting's role: explicit, else the agent's personaRole dressing.""" role: String + + """ + #2024 — the tickers this cast would create, each at its effective cadence (the larger of the template's and the App's floor). Non-reserving: nothing is written or held, so a later cast may differ if the App changes. Empty when the Agent declares none. + """ + tickers: [CastPreviewTicker!]! } """ @@ -2551,6 +2723,24 @@ enum CommentThreadState { RESOLVED } +""" +#2046 — the result of a compile: the new unapproved revision of the compiled node. +""" +type CompiledBootPrompt { + """Always false: a compile never approves or signs.""" + approvalIssued: Boolean! + + """SHA-256 hex of the compiled text.""" + contentHash: String! + node: Node! + + """Whether this compile wrote the pointer.""" + pointerSet: Boolean! + + """The revision this compile wrote.""" + revNo: Int! +} + input CompleteAssetUploadInput { uploadId: ID! } @@ -4608,6 +4798,71 @@ type LiftStopSignalResult { signal: StopSignal! } +""" +#2048 (hrn:node:hadronmemory.com:specs:cor:agt:040:13) — what link lint warns about. +""" +enum LintKind { + """ + The target is missing or the caller cannot read it. Position only: it never says which, or anything about the target. + """ + BROKEN_INCLUDE + + """ + The target is readable but has no revision that meets the include's settings (approved and/or rev=N). + """ + NO_QUALIFYING_REVISION + + """The include pins a revision and a newer revision is approved.""" + PINNED_NEWER_APPROVED + + """ + The include's target is soft-deleted (hrn:node:hadronmemory.com:specs:cor:agt:040:13, R20 A). + """ + RETIRED_TARGET + + """The include says signed; signing is not checked yet.""" + SIGNING_NOT_CHECKED +} + +""" +#2048 — one link-lint warning. A fixed set of fields: there is no free-form detail. +""" +type LintWarning { + kind: LintKind! + + """The newest approved revision, for PINNED_NEWER_APPROVED.""" + newerRevision: Int + + """Reserved for the policy-agent override notice; null for source lint.""" + overriddenNodeLoc: String + + """The revision the include pins, when it pins one.""" + pinnedRevision: Int + + """0-based offset of the include tag in the node's source text.""" + position: Int! + + """ + The target's URN, only for a target the caller can read; null for BROKEN_INCLUDE. + """ + targetUrn: String + text: String! + + """Reserved for the worker signature/trailer lint; null for source lint.""" + workerField: String +} + +"""#2048 — one page of warnings, ordered by position.""" +type LintWarningPage { + items: [LintWarning!]! + + """Pass as after for the next page; null on the last.""" + nextCursor: String + + """All warnings that hold, not just this page.""" + total: Int! +} + """ One registered EXTERNAL MCP server (hadrontool-mcp conduit registry). Org-owned. Static auth headers are encrypted at rest and WRITE-ONLY: @@ -5618,7 +5873,12 @@ type Mutation { applyMemoryConfigTemplate(dryRun: Boolean, expectedTemplateRevision: Int, memoryRef: ID!, templateRef: ID!): MemoryConfigApplyResult! """ - #1591 — approve the current revision of every live, unminted node of a memory (ID or fully-qualified URN) that isn't approved yet, in one transaction. Placeholders are skipped and reported. + #2441: adopt the automation by the hash the adopter reviewed (automationApproval.hash). Same door as reAdoptAutomation: one APPROVE event per successful call, even when nothing changed. A changed automation answers AUTOMATION_CHANGED. + """ + approveAutomation(expectedHash: String!, triggerKind: AutomationTriggerKind!, triggerRef: String!): Boolean! + + """ + #1591 — approve the current revision of every live node of a memory (ID or fully-qualified URN) in the mint scope (unminted, or minted and changed since or with no recorded minted revision, #1612) that isn't approved yet, in one transaction. Placeholders are skipped and reported. """ approveMemoryNodes(memoryRef: String!): ApproveMemoryNodesResult! @@ -5649,7 +5909,7 @@ type Mutation { """ #1705 — assign a task with no assignee — a queued task (pending) or a BACKLOG task: the - task's owner, a worker the owner reports to, or whoever manages the App + task's owner, a worker the owner reports to, directly or up the reportsTo chain, or whoever manages the App (hrn:node:hadronmemory.com:specs:cor:agt:050:09). closeSessionWhenDone, when given, replaces the task's value; omitted, the stored value is kept. Assigning a BACKLOG task moves it to pending (hrn:node:hadronmemory.com:specs:cor:agt:050:02, #1754). Refusals: TASK_NOT_FOUND, TASK_NOT_OWNER, @@ -5726,7 +5986,7 @@ type Mutation { """ #1705 — cancel a pending, started or backlog task: its owner, a worker the owner reports - to, or whoever manages the App (hrn:node:hadronmemory.com:specs:cor:agt:050:09; a backlog task directly, + to, directly or up the reportsTo chain, or whoever manages the App (hrn:node:hadronmemory.com:specs:cor:agt:050:09; a backlog task directly, Holger team chat #8150). Refusals: TASK_NOT_FOUND, TASK_NOT_OWNER, TASK_ENDED, SESSION_NOT_BOUND, APP_UNINSTALLED. """ @@ -5918,6 +6178,34 @@ type Mutation { first: at most 200, default 20. """ collectHostOperations(appRef: String!, first: Int): [HostOperation!]! + + """ + #2046 (S9-4, hrn:node:hadronmemory.com:specs:cor:agt:040:12) — compile an Agent's boot prompt from its source ('boot:source') in the Agent's + system memory into the compiled node 'boot:compiled' there, as a NEW unapproved revision (every compile writes + one, even when the text is unchanged), with its provenance and a derived 'compiled-from' edge to the source. + Sets 'Agent.bootPromptRef' unless 'setPointer: false'. Never approves or signs ('approvalIssued' is always false). + + Whoever manages the Agent, as themselves: a request bound to a worker session, a non-manager, an App key, an + impersonation, a run, and a target that does not exist are alike refused BOOT_PROMPT_COMPILE_NOT_PERMITTED. Only then: BOOT_PROMPT_TARGET_RETIRED, BOOT_PROMPT_NO_SOURCE, BOOT_PROMPT_SOURCE_INVALID, the + include refusals (BOOT_PROMPT_INCLUDE_*, _GRANT_REQUIRED, _COMPILE_LIMIT_EXCEEDED, with the tag's 'position'), + and CONFLICT when the compile could not settle inside its 30-second budget (retry). + A refusal changes nothing. No MCP twin. + """ + compileAgentBootPrompt(agentRef: String!, setPointer: Boolean = true): CompiledBootPrompt! + + """ + #2046 — compile an installed Agent's boot prompt for one App into 'boot:compiled' in the App's install memory + (the App's own 'boot:source' if it has a live one, else the Agent's, with the App's replacing nodes). Always sets + 'AppAgent.bootPromptRef'. Whoever manages the App; otherwise as compileAgentBootPrompt. + """ + compileAppAgentBootPrompt(agentRef: String!, appRef: String!): CompiledBootPrompt! + + """ + #2046 — compile a customised worker's boot prompt from 'boot:workers::source' into + 'boot:workers::compiled' in the App's install memory. Sets the worker's pointer unless 'setPointer: false'. + Whoever manages the worker's App; otherwise as compileAgentBootPrompt. workerRef is the worker's id or URN. + """ + compileWorkerBootPrompt(setPointer: Boolean = true, workerRef: ID!): CompiledBootPrompt! completeAssetUpload(input: CompleteAssetUploadInput!): Asset! """ @@ -5978,6 +6266,13 @@ type Mutation { """ createAgentTask(input: CreateAgentTaskInput!): AgentTask! + """ + #2024 — declare a ticker on an Agent. Whoever may edit the Agent (ADMIN on it); an App manager is not thereby an Agent editor. + Affects only FUTURE casts. Refusals: TICKER_TEMPLATE_NAME_TAKEN, TICKER_TEMPLATE_LIMIT_REACHED, TICKER_CADENCE_OUT_OF_RANGE, + TICKER_QUIET_HOURS_INVALID, BAD_USER_INPUT (reasons), AGENT_NOT_FOUND, FORBIDDEN. + """ + createAgentTickerTemplate(agentRef: String!, input: AgentTickerTemplateInput!): AgentTickerTemplate! + """ Create a webhook trigger — returns the URL path + platform token ONCE (D-2026-05-02). """ @@ -6652,18 +6947,33 @@ type Mutation { """ createUserInvitation(email: String, expiresInDays: Int, githubUsername: String, maxActivations: Int, memberRole: Role!, name: String, orgId: ID!): UserInvitation! + """ + #2441: decline. An adopted trigger is revoked, so its next run fails AUTOMATION_UNADOPTED (REVOKE event); with nothing adopted it changes no state (DECLINE event). A MANUAL origin deletes its consent row. Runs already started keep their snapshot. + """ + declineAutomation(triggerKind: AutomationTriggerKind!, triggerRef: String!): Boolean! + """ Soft-delete an Agent and its owned system memory. Requires management access to the readable Agent. Refuses while a live App install or live parent Agent import references it, or a live Agent extends it as its base. AgentMemoryItem attachments do not block deletion. + Also refused with DECISION_CONTAINED (#1641) while the Agent's system memory holds a decision or a + decision log: removing the memory would remove them, so the Agent is kept. This guard is live + whether or not minted-corpus permanence is enforced; the separate minted-corpus refusal applies only + when that enforcement is on. + See hrn:node:hadronmemory.com:specs:cor:dmo:050:14. Accepts the entity's ID or URN. """ deleteAgent(ref: ID!): Boolean! deleteAgentSchedule(id: ID!): Boolean! + + """ + #2024 — remove a template (it stays as history; its name may be reused). expectedRevision as for update. Tickers already generated from it are untouched. + """ + deleteAgentTickerTemplate(expectedRevision: Int!, id: ID!): Boolean! deleteAgentWebhook(id: ID!): Boolean! """ @@ -6683,8 +6993,10 @@ type Mutation { """ Spec 049 Phase 4 — soft-delete a Channel AND its chat root together (the - protection holds while either exists, so the address stays reserved and a - restore brings both back). Host memory write access required. An App's + protection holds while either exists). There is no restore: creating a + Channel at the same address adopts it and starts clean, so the old + transcript does not come back (#1257, #1226). Host memory write access + required. An App's default Channel is unlinked from the App. ref: its id or its address (`Channel.chatRootUrn`, #1171). """ @@ -7216,9 +7528,12 @@ type Mutation { """ S7 (#2023, R85) — isolate a worker until a time (default 2 hours, at most 7 days; a later time extends, an earlier one shortens). - Only a person managing the App or holding the worker, acting as themselves; a worker session (even a manager's), an App key or an - impersonation is refused with WORKER_ISOLATION_FORBIDDEN. Never touches the worker's own pause. - While S7 activation is off, setting an isolation is refused with WORKER_ISOLATION_UNAVAILABLE; clearing is unaffected. + Only a person managing the App or holding the worker, acting as themselves. Refusals, in the order they are checked: + an unknown worker, or one the caller cannot read (a pure App key among them), is WORKER_NOT_FOUND; a worker session (even a + manager's) is WORKER_ISOLATION_FORBIDDEN; while S7 activation is off, setting an isolation is WORKER_ISOLATION_UNAVAILABLE + (clearing is unaffected), so an ordinary person gets this before the manager/holder check; an impersonation that reaches the + write is refused FORBIDDEN (read-only); a participant who neither manages the App nor holds the worker is + WORKER_ISOLATION_FORBIDDEN. Never touches the worker's own pause. """ isolateWorker(until: DateTime, workerRef: String!): WorkerWakeSettings! @@ -7226,7 +7541,7 @@ type Mutation { #1709 — issue an operation to one of the App's registered hosts (hrn:node:hadronmemory.com:specs:cor:agt:070:05). A type outside the closed set is HOST_OPERATION_TYPE_UNKNOWN (hrn:node:hadronmemory.com:specs:cor:agt:070:03), a missing, extra or malformed argument BAD_HOST_OPERATION_ARGS. worktree.create and worktree.remove: the worker's - holder, the worker it reports to, or whoever manages the App (else HOST_OPERATION_NOT_PERMITTED). worker.start: + holder, the worker it reports to directly (not further up the chain), or whoever manages the App (else HOST_OPERATION_NOT_PERMITTED). worker.start: whoever manages the App only, with an optional taskId, for a held worker with no live session and no start in flight, in a worktree and family the live host registers for it (WORKER_NOT_HELD, WORKER_LIVE, WORKER_START_PENDING). worker.end: whoever manages the App only (else HOST_OPERATION_NOT_PERMITTED), naming an open @@ -7310,7 +7625,7 @@ type Mutation { #1754 — make a backlog task ready to be worked (hrn:node:hadronmemory.com:specs:cor:agt:050:02): BACKLOG becomes PENDING and the task enters the queue. Whoever may assign a task may make it ready: its owner, a worker - the owner reports to, or whoever manages the App + the owner reports to, directly or up the reportsTo chain, or whoever manages the App (hrn:node:hadronmemory.com:specs:cor:agt:050:09). Refusals: TASK_NOT_FOUND, TASK_NOT_OWNER, TASK_ENDED, TASK_INVALID_TRANSITION (not backlog), SESSION_NOT_BOUND, APP_UNINSTALLED. """ @@ -7414,18 +7729,21 @@ type Mutation { mintActionTickets(input: MintActionTicketsInput!): Int! """ - #1591 — mint the unminted live nodes of a memory, at once, excluding - comment nodes and the decision role family. Each node is + #1591 — mint the live nodes of a memory in the mint scope, at once, + excluding comment nodes and the decision role family. The scope is the + unminted nodes plus (#1612) minted nodes whose content changed since their + minted revision or that have no recorded minted revision; a re-mint keeps + the first mint's time and minter. Each node is stamped with its current revision and that revision's approval hash (Node.mintStatus); a minted node's moves then record URN aliases. Refuses - SPEC_CORPUS_MINT_BLOCKED while any blocker remains among the unminted - nodes: a placeholder, an unresolved reference, a structural lint error over + SPEC_CORPUS_MINT_BLOCKED while any blocker remains among the nodes in + scope: a placeholder, an unresolved reference, a structural lint error over spec-marked or citation-shaped nodes (nodetype-info, tag-spec, serialization-leak, duplicate-loc), a stale abstract once staleAbstractsBlock is true, NOT_APPROVED (the current revision is not approved), TAMPERED (approved text changed outside the revision system), or CITES_UNMINTED (an - edge into another memory's node that is not minted). Nodes minted earlier - are not re-checked. dryRun returns the full report without minting. Any + edge into another memory's node that is not minted). Minted nodes outside + the scope are not re-checked. dryRun returns the full report without minting. Any writer of the memory. memoryRef accepts the ID or URN. A real run holds every write to the memory off while it checks and stamps, and refuses SPEC_CORPUS_BUSY (retryable) if a write is already in flight. Encrypted @@ -7456,7 +7774,7 @@ type Mutation { #1754 — park a queued task in the backlog (hrn:node:hadronmemory.com:specs:cor:agt:050:02): pending with no assignee becomes BACKLOG — unassigned, never matched, never waited on, no alarms. Whoever may assign a task may park it: its owner, a worker - the owner reports to, or whoever manages the App + the owner reports to, directly or up the reportsTo chain, or whoever manages the App (hrn:node:hadronmemory.com:specs:cor:agt:050:09). Refusals: TASK_NOT_FOUND, TASK_NOT_OWNER, TASK_ENDED, TASK_INVALID_TRANSITION (not pending, or an assignee), SESSION_NOT_BOUND, APP_UNINSTALLED. @@ -7507,7 +7825,7 @@ type Mutation { reAdoptAutomation(expectedHash: String!, triggerKind: AutomationTriggerKind!, triggerRef: String!): Boolean! """ - #1705 — move a task to another assignee: the task's owner, a worker the owner reports to, or whoever + #1705 — move a task to another assignee: the task's owner, a worker the owner reports to, directly or up the reportsTo chain, or whoever manages the App (hrn:node:hadronmemory.com:specs:cor:agt:050:09). A started task stays started; the former assignee can no longer move it. Refusals: TASK_NOT_FOUND, TASK_NOT_OWNER, TASK_ENDED, TASK_INVALID_TRANSITION (no assignee yet, or the same one), @@ -7983,8 +8301,11 @@ type Mutation { rotateAgentWebhook(id: ID!): AgentWebhookCredentials! """ - Run a task — a runnable node (isRunnable=true) — identified by a single - nodeRef (a node PK or a fully-qualified node URN, hrn:node:::), + Render or execute a node identified by a single nodeRef (a node PK or a + fully-qualified node URN, hrn:node:::). This explicit-reference + operation does not require isRunnable=true. The runnable flag governs by-name + task discovery, resolution and listing (hrn:node:hadronmemory.com:specs:cor:api:060:01). + The reference is resolved by the shared resolveNodeRef (#542). A bare loc without a memory is rejected — pass a full URN or an ID. For loose-name matching and interactive task selection use the MCP hadron_run_task tool, which @@ -8162,11 +8483,21 @@ type Mutation { #2022 (hrn:node:hadronmemory.com:specs:cor:agt:090:01, :06) — set a stop signal. Whoever manages the App: any level and scope. A host key: a HALT on its own host only. Never replaces another signal. Refusals: APP_MANAGER_REQUIRED, HOST_SCOPE_REQUIRED, STOP_SIGNAL_SCOPE_FORBIDDEN, STOP_SIGNAL_NOT_FOUND (the target), BAD_USER_INPUT, APP_NOT_FOUND. - #2173 (R95): until the stop-notice lane is in effect, setting ANY signal is refused with STOP_SIGNAL_UNAVAILABLE (a worker - would be stopped without being told); lifting and listing are unaffected, and the refusal lifts when the lane is turned on. + #2173/#2169 (R95): setting ANY signal is refused with STOP_SIGNAL_UNAVAILABLE while a worker it would cover is on a lane with + no fresh stop-consumer declaration (a worker would be stopped without being told; the extensions name the worker count and the + lanes). A consumer declares a lane by polling dueWakes with accepts: [STOP]. Lifting and listing are unaffected. """ setTeamStopSignal(input: SetTeamStopSignalInput!): StopSignal! + """ + #2298: hide (or unhide) one of the caller's own REVOKED API keys in the default + myUserApiKeys list. Hiding an active key is refused with BAD_USER_INPUT, + reason KEY_ACTIVE, and changes nothing. Idempotent: the first hiddenAt is kept. + Touches no validity, revocation time, lineage or descendant. Same authority as + revokeUserApiKey (own user only; unknown and foreign ids are NOT_FOUND). + """ + setUserApiKeyHidden(hidden: Boolean!, id: ID!): UserApiKey! + """ #1706 — set a worker's availability for work (hrn:node:hadronmemory.com:specs:cor:agt:060:08): the worker itself, acting through its session, and only to UNAVAILABLE; the person @@ -8391,6 +8722,12 @@ type Mutation { updateAgentMemoryRole(agentRef: ID!, memoryRef: ID!, role: String!): Agent! updateAgentSchedule(id: ID!, input: UpdateAgentScheduleInput!): AgentSchedule! + """ + #2024 — change a template; absent fields are unchanged. expectedRevision must be the revision you read, else + TICKER_TEMPLATE_REVISION_MISMATCH and nothing is written. Affects only future casts. + """ + updateAgentTickerTemplate(expectedRevision: Int!, id: ID!, input: AgentTickerTemplatePatch!): AgentTickerTemplate! + """ 036-ai-service-config: update a named AI config. All fields optional. @@ -9054,6 +9391,11 @@ type Node { omitting a label is free, while omitting this means the node does not run. """ isRunnable: Boolean + + """ + #2048 (hrn:node:hadronmemory.com:specs:cor:agt:040:13) — link lint for a boot-prompt source: the warnings that hold for this node as it is now, read as the caller (a target the caller cannot read is a position-only BROKEN_INCLUDE). An empty page is a real answer; null means the warnings are unavailable (a fault, or a compiled boot prompt, whose warnings read its recorded provenance). Lint never blocks a write, and nothing is stored or pushed. Pages default to 50 and clamp at 100. + """ + lintWarnings(after: String, first: Int = 50): LintWarningPage llmModel: String loc: String! @@ -9195,6 +9537,11 @@ type NodeApproval { """#1591 — a node and its approval, returned by the approve mutations.""" type NodeApprovalReceipt { approval: NodeApproval! + + """ + #2048 — the link-lint warnings for this node as they hold now; the same data as Node.lintWarnings. Approving never blocks on a warning. + """ + lintWarnings(after: String, first: Int = 50): LintWarningPage loc: String! nodeId: ID! urn: String! @@ -10416,6 +10763,12 @@ type Query { taskId: ID types: [String!] ): [AgentTeamEvent!]! + + """ + #2024 — the live ticker templates of an Agent, by name. Anyone who can read the Agent. An Agent the caller cannot read answers + as absent (AGENT_NOT_FOUND). + """ + agentTickerTemplates(agentRef: String!): [AgentTickerTemplate!]! agentWebhooks(appRef: String!, limit: Int, offset: Int): AgentWebhooksPage! """ @@ -10606,6 +10959,16 @@ type Query { """ authContext: AuthContext + """ + #2441: state, hash, plain summary and graph depth for one trigger. MANUAL takes the origin run id. Same gate as approveAutomation. + """ + automationApproval(triggerKind: AutomationTriggerKind!, triggerRef: String!): AutomationApproval! + + """ + #2441: the schedules and webhooks of an App that the caller may adopt, with their adoption state. Authorization first, then ordered by (name, kind, id) and paged: limit defaults to 20, maximum 50; after is the previous page's nextCursor. MANUAL triggers are never listed (consent is per run); omitted says so. Only an adopter sees any row. + """ + automationApprovals(after: String, appRef: String!, limit: Int = 20): AutomationApprovalPage! + """One event by id. Grant scope: calendar.read.""" calendarEvent(calendarId: String, connectionRef: ID!, eventId: ID!): CalendarEvent! @@ -10863,8 +11226,14 @@ type Query { """ #1748 — the wakes due now (hrn:node:hadronmemory.com:specs:cor:agt:080:08). Counts and seq ranges only; reads nothing it does not return. + accepts (#2169, hrn:node:hadronmemory.com:specs:cor:agt:090:04): the reason kinds this caller can deliver. Omit it and no STOP notice is + ever returned (the shipped ticker-wake). A call that lists STOP is offered the worker's newest undelivered stop notice, and + declares the lane it serves (a host key: its own registration; a person who manages the App, or a key without a host: + the unhosted lane) for STOP_CONSUMER_FRESH_SECONDS (default 600), but only when it read the lane from its start to its end in one call: + no workerRefs, no after, and no next page (ask for first: 200; a lane larger than a page cannot declare). A worker whose host is lost has no serving lane until the host returns. Setting a stop signal is refused (STOP_SIGNAL_UNAVAILABLE) + while any worker it would cover is on a lane with no fresh declaration. """ - dueWakes(after: String, appRef: String!, first: Int, workerRefs: [String!]): DueWakePage! + dueWakes(accepts: [WakeKind!], after: String, appRef: String!, first: Int, workerRefs: [String!]): DueWakePage! """ The uniform single-edge read (#473) — edges are first-class, loc-addressed @@ -11330,7 +11699,12 @@ type Query { (SC-006). Rejected with UNAUTHENTICATED for AppKey-resolved callers (no user in context). Impersonation is refused with Forbidden. """ - myUserApiKeys: [UserApiKey!]! + myUserApiKeys( + """ + #2298: include keys the owner hid with setUserApiKeyHidden. Default false. + """ + includeHidden: Boolean = false + ): [UserApiKey!]! """ The next sibling node under a parent, ordered by seq (#817) — the GraphQL @@ -11684,9 +12058,14 @@ type Query { Access is checked by kind: memory records use the memory-record read gate; nodes use the readable-memory set; organizations require membership (or platform administration); agents admit public visibility or owner/org access; Apps admit owner/org access; - workers use the shared worker-read gate. App runs require org access, and node - revisions use their captured memory (falling back to the current node's memory). - A retired worker still resolves. Dedicated queries can have different refusal shapes. + workers use the shared worker-read gate. Users resolve for themselves, a platform + administrator, or a caller who shares an organization with them. App runs require org + access, and node revisions use their captured memory (falling back to the current + node's memory); a retracted comment's revision history resolves only for memory + managers. Under impersonation only the support session's shared organization admits + organizations, Apps, non-public Agents, workers and App runs, and a user-owned App or + non-public Agent is not visible; a PUBLIC Agent still resolves. A retired worker still resolves. The kinds above are checked by kind, + not an exhaustive promise. Dedicated queries can have different refusal shapes. The type prefix is the dispatch key; an untyped root:slug is ambiguous across kinds and returns null. The optional #data fragment is supported only for nodes and App runs. @@ -14051,7 +14430,7 @@ type SpecCorpusMintReport { memoryId: ID! """ - #1591 — the locs this call minted, or would mint on a dry run, ordered by loc. + #1591 — the locs of NEW nodes this call minted, or would mint on a dry run, ordered by loc. """ mintLocs: [String!]! @@ -14063,6 +14442,14 @@ type SpecCorpusMintReport { """#1591 — how many nodes this call minted (0 on a dry run or refusal).""" mintedCount: Int! openQuestions: [SpecOpenQuestionGroup!]! + + """ + #1612 — the locs of already-minted nodes this call re-minted (edited since, or minted with no recorded revision), or would on a dry run, ordered by loc. A re-mint keeps the first mint's time and minter. + """ + remintLocs: [String!]! + + """#1612 — how many of mintedCount were re-mints.""" + remintedCount: Int! staleAbstracts: [SpecMintFinding!]! staleAbstractsBlock: Boolean! @@ -14086,7 +14473,7 @@ type SpecMintFinding { """#1448 — why the mint report lists an item.""" enum SpecMintFindingKind { """ - #1591 — an edge into another memory's node that is not minted yet (targetLoc is its URN). + #1591 — an edge, live pending edge or text citation (a prefixed node URN) into another memory's node that is not minted yet (targetLoc is its URN). """ CITES_UNMINTED @@ -14334,17 +14721,17 @@ enum StopSignalEndKind { """ enum StopSignalLevel { """ - Also interrupts each covered worker, to save its work and write a hand-off (the notice is recorded but not yet delivered: no interrupt happens yet). + Also tells each covered worker to save its work and write a hand-off now. The notice is typed as an ordinary message into a verified-empty input, with no interrupt key (Holger, option A, 2026-10-24); one that cannot be typed is logged and flagged to the coordinator. """ HALT """ - Also stops ticks and stall time. Each covered worker is told to finish or stop at its next sub-task (the notice is recorded but not yet delivered). + Also stops ticks and stall time. Each covered worker is told to finish or stop at its next sub-task, by a consumer that has declared its lane (a set is refused until one has). """ PAUSE """ - Stops assignment only: matching skips the covered workers and a manual assignment to one is refused (TASK_STOPPED). Its coordinators are told (the notice is recorded but not yet delivered). + Stops assignment only: matching skips the covered workers and a manual assignment to one is refused (TASK_STOPPED). Its coordinators are told, by a consumer that has declared their lane (a set is refused until one has). """ PAUSE_DISPATCH } @@ -15469,6 +15856,12 @@ out of scope (see contracts/graphql-mutations.md, PR-137 delta D1). """ type UserApiKey { createdAt: DateTime! + + """ + #2298: when the owner hid this revoked key from their default list; null when + visible. Display state only: it never affects validity, revocation or lineage. + """ + hiddenAt: DateTime id: ID! """ @@ -15550,7 +15943,7 @@ enum WakeKind { MENTION """ - #2022 — a stop-signal notice (hrn:node:hadronmemory.com:specs:cor:agt:090:04): ahead of everything else, past the floor, the busy delay and the budget's refusal. Not yet offered (DARK until the host consumer ships). + #2022 — a stop-signal notice (hrn:node:hadronmemory.com:specs:cor:agt:090:04): ahead of everything else, past the floor, the busy delay and the budget's refusal. Offered only to a dueWakes call that lists STOP in accepts (#2169). """ STOP From 89bf027979b33fcdf60bad2fe991272ba3c690d3 Mon Sep 17 00:00:00 2001 From: Holger Selover-Stephan Date: Sun, 25 Oct 2026 18:21:54 +0000 Subject: [PATCH 2/7] Red tests for automation ls|approval|approve|decline (#1071) Fail on main because the command group does not exist; they pin the contract in the issue: displayed hash, no retry, decline carries no hash, no/EOF/non-TTY mutate nothing, exit codes, terminal-safe bounded text, malformed projection and schema skew exit 7, legacy operations stay unbound. Worker: Jack (cli-engineer) Co-Authored-By: Claude Sonnet 5.5 --- internal/cmd/automation_cmd_test.go | 785 ++++++++++++++++++++++++++++ 1 file changed, 785 insertions(+) create mode 100644 internal/cmd/automation_cmd_test.go diff --git a/internal/cmd/automation_cmd_test.go b/internal/cmd/automation_cmd_test.go new file mode 100644 index 00000000..27860471 --- /dev/null +++ b/internal/cmd/automation_cmd_test.go @@ -0,0 +1,785 @@ +package cmd + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "os" + "strings" + "sync" + "testing" + + "github.com/hadron-memory/hadron-cli/internal/exitcode" +) + +// cli#1071: `automation ls|approval|approve|decline`. The guards that matter are +// "zero or exactly one call" — nothing is approved or declined before the +// person has seen the projection and said yes, the hash sent is the hash shown, +// and a refusal is never retried — so every test asserts per-operation counts. + +const autoApp = "hrn:app:acme.com:ops" + +type autoReply struct { + status int // 0 = 200 + body string +} + +// autoServer answers by operation name, counts calls, and records the +// variables of every call. A reply func sees the call's variables, so a paged +// list can answer by cursor. +type autoServer struct { + mu sync.Mutex + calls map[string]int + vars map[string][]map[string]any + URL string +} + +func (s *autoServer) count(op string) int { + s.mu.Lock() + defer s.mu.Unlock() + return s.calls[op] +} + +func (s *autoServer) lastVars(op string) map[string]any { + s.mu.Lock() + defer s.mu.Unlock() + v := s.vars[op] + if len(v) == 0 { + return nil + } + return v[len(v)-1] +} + +func newAutoServer(t *testing.T, replies map[string]func(vars map[string]any) autoReply) *autoServer { + t.Helper() + s := &autoServer{calls: map[string]int{}, vars: map[string][]map[string]any{}} + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet && r.URL.Path == "/capabilities" { + _, _ = w.Write([]byte(`{"capabilities":[]}`)) + return + } + var body struct { + OperationName string `json:"operationName"` + Variables map[string]any `json:"variables"` + } + _ = json.NewDecoder(r.Body).Decode(&body) + s.mu.Lock() + s.calls[body.OperationName]++ + s.vars[body.OperationName] = append(s.vars[body.OperationName], body.Variables) + s.mu.Unlock() + fn, ok := replies[body.OperationName] + if !ok { + t.Errorf("unexpected operation %q", body.OperationName) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(memberGQLError("unexpected operation", "INTERNAL_SERVER_ERROR"))) + return + } + rep := fn(body.Variables) + w.Header().Set("Content-Type", "application/json") + if rep.status != 0 { + w.WriteHeader(rep.status) + } + _, _ = w.Write([]byte(rep.body)) + })) + t.Cleanup(srv.Close) + s.URL = srv.URL + return s +} + +func fixed(body string) func(map[string]any) autoReply { + return func(map[string]any) autoReply { return autoReply{body: body} } +} + +func autoRow(kind, ref, name, state, headline string) string { + return fmt.Sprintf(`{"triggerKind":%q,"triggerRef":%q,"name":%q,"state":%q,"headline":%q}`, kind, ref, name, state, headline) +} + +func autoPage(rows []string, next string, omitted bool) string { + cur := "null" + if next != "" { + cur = fmt.Sprintf("%q", next) + } + om := `[]` + if omitted { + om = `[{"triggerKind":"MANUAL","reason":"approved from the run that needs it"}]` + } + return fmt.Sprintf(`{"data":{"automationApprovals":{"rows":[%s],"nextCursor":%s,"omitted":%s}}}`, strings.Join(rows, ","), cur, om) +} + +func autoDetail(state, hash, name string) string { + return fmt.Sprintf(`{"data":{"automationApproval":{"state":%q,"hash":%q,"summary":{"name":%q,"schedule":"every night at 02:00", + "reads":["what the App can read in this App"],"writes":["a new node runs: beside the entry"], + "tools":["hadron_find_nodes"],"limits":["set by the platform at run start"],"cannotDo":["never a private memory"], + "flows":[{"name":"main","urn":"hrn:node:acme.com:ops:flows:main","tools":["hadron_find_nodes"],"writes":[]}]}}}}`, state, hash, name) +} + +const autoHash = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" + +func oneRowServer(t *testing.T, state string, extra map[string]func(map[string]any) autoReply) *autoServer { + t.Helper() + rep := map[string]func(map[string]any) autoReply{ + "AutomationApprovals": fixed(autoPage([]string{autoRow("SCHEDULE", "sch1", "nightly", state, "Runs nightly")}, "", true)), + "AutomationApproval": fixed(autoDetail(state, autoHash, "nightly")), + "AutomationApprovalDepth": fixed(strings.Replace(autoDetail(state, autoHash, "nightly"), `"state":`, `"depth":{"nodes":[{"loc":"flows:main"}]},"state":`, 1)), + "ApproveAutomation": fixed(`{"data":{"approveAutomation":true}}`), + "DeclineAutomation": fixed(`{"data":{"declineAutomation":true}}`), + } + for k, v := range extra { + rep[k] = v + } + return newAutoServer(t, rep) +} + +// autoActiveApp, when set, is written as the active App of every factory +// runAuto builds, and runAuto then omits its default --app (each factory gets a +// fresh config dir, so the active App cannot be set from outside). +var autoActiveApp string + +type autoRun struct { + out, errOut string + err error + code int +} + +// runAuto executes the command with `answers` as stdin. tty=false is the +// non-interactive branch. +func runAuto(t *testing.T, s *autoServer, tty bool, answers string, args ...string) autoRun { + t.Helper() + args = append(append([]string{"automation"}, args...), "--server", s.URL) + if autoActiveApp == "" && !hasFlag(args, "--app") { + args = append(args, "--app", autoApp) + } + var r autoRun + if tty { + f, out, eo := testFactoryTTY(t, answers) + if autoActiveApp != "" { + setActiveApp(t, autoActiveApp) + } + root := NewRootCmd(f) + root.SetArgs(args) + r.err = root.Execute() + r.out, r.errOut = out.String(), eo.String() + } else { + f, out := testFactory(t) + if autoActiveApp != "" { + setActiveApp(t, autoActiveApp) + } + root := NewRootCmd(f) + root.SetArgs(args) + r.err = root.Execute() + r.out = out.String() + r.errOut = f.IOStreams.ErrOut.(interface{ String() string }).String() + } + r.code = exitCodeFor(r.err) + return r +} + +func hasFlag(args []string, name string) bool { + for _, a := range args { + if a == name { + return true + } + } + return false +} + +func TestAutomationLsFollowsPagesAndShowsStates(t *testing.T) { + s := newAutoServer(t, map[string]func(map[string]any) autoReply{ + "AutomationApprovals": func(v map[string]any) autoReply { + if v["after"] == nil { + return autoReply{body: autoPage([]string{ + autoRow("SCHEDULE", "s1", "alpha", "ADOPTED", "Runs nightly"), + autoRow("WEBHOOK", "w1", "beta", "UNADOPTED", "Starts when called"), + }, "cur1", true)} + } + if v["after"] != "cur1" { + t.Errorf("second page must pass the cursor it was given: %v", v["after"]) + } + return autoReply{body: autoPage([]string{autoRow("SCHEDULE", "s2", "gamma", "UNADOPTED", "Runs weekly")}, "", true)} + }, + }) + r := runAuto(t, s, false, "", "ls") + if r.err != nil || s.count("AutomationApprovals") != 2 { + t.Fatalf("want 2 page calls and success: err=%v calls=%d", r.err, s.count("AutomationApprovals")) + } + for _, want := range []string{"alpha", "beta", "gamma", "approved", "not approved", "Manual runs are approved from the run that needs them", "approval "} { + if !strings.Contains(r.out, want) { + t.Errorf("ls output missing %q:\n%s", want, r.out) + } + } + for _, bad := range []string{"up to date", "unchanged", "CHANGED"} { + if strings.Contains(r.out, bad) { + t.Errorf("ls must never present ADOPTED as %q:\n%s", bad, r.out) + } + } + if strings.Index(r.out, "alpha") > strings.Index(r.out, "beta") || strings.Index(r.out, "beta") > strings.Index(r.out, "gamma") { + t.Errorf("server order must be preserved:\n%s", r.out) + } +} + +func TestAutomationLsJSONEmptyArraysNotNull(t *testing.T) { + s := newAutoServer(t, map[string]func(map[string]any) autoReply{"AutomationApprovals": fixed(autoPage(nil, "", false))}) + r := runAuto(t, s, false, "", "ls", "--json") + if r.err != nil { + t.Fatal(r.err) + } + var got map[string]json.RawMessage + if err := json.Unmarshal([]byte(r.out), &got); err != nil { + t.Fatalf("stdout must be one JSON object: %v\n%s", err, r.out) + } + if string(got["rows"]) != "[]" || string(got["omitted"]) != "[]" { + t.Errorf("rows and omitted must be [] not null: %s", r.out) + } +} + +func TestAutomationLsJSONShape(t *testing.T) { + s := oneRowServer(t, "ADOPTED", nil) + r := runAuto(t, s, false, "", "ls", "--json") + var got struct { + App string `json:"app"` + Rows []struct { + TriggerKind string `json:"triggerKind"` + TriggerRef string `json:"triggerRef"` + Name string `json:"name"` + State string `json:"state"` + Headline string `json:"headline"` + } `json:"rows"` + Omitted []struct { + TriggerKind string `json:"triggerKind"` + Reason string `json:"reason"` + } `json:"omitted"` + } + if err := json.Unmarshal([]byte(r.out), &got); err != nil { + t.Fatalf("%v\n%s", err, r.out) + } + if got.App != autoApp || len(got.Rows) != 1 || got.Rows[0].State != "ADOPTED" || got.Rows[0].TriggerKind != "SCHEDULE" || + got.Rows[0].Name != "nightly" || len(got.Omitted) != 1 || got.Omitted[0].TriggerKind != "MANUAL" { + t.Errorf("unexpected --json: %s", r.out) + } +} + +func TestAutomationSelection(t *testing.T) { + rows := []string{ + autoRow("SCHEDULE", "s1", "nightly", "ADOPTED", "Runs nightly"), + autoRow("WEBHOOK", "w1", "nightly", "UNADOPTED", "Starts when called"), + autoRow("SCHEDULE", "s2", "weekly", "UNADOPTED", "Runs weekly"), + autoRow("WEBHOOK", "w2", "hook", "UNADOPTED", "Starts on push"), + autoRow("SCHEDULE", "s3", "twin", "UNADOPTED", "first twin"), + autoRow("SCHEDULE", "s4", "twin", "UNADOPTED", "second twin"), + } + srv := func(t *testing.T) *autoServer { + return oneRowServer(t, "UNADOPTED", map[string]func(map[string]any) autoReply{ + "AutomationApprovals": fixed(autoPage(rows, "", true)), + }) + } + t.Run("qualified name picks the kind", func(t *testing.T) { + s := srv(t) + r := runAuto(t, s, false, "", "approval", "webhook/nightly") + v := s.lastVars("AutomationApproval") + if r.err != nil || v["triggerKind"] != "WEBHOOK" || v["triggerRef"] != "w1" { + t.Fatalf("want WEBHOOK/w1: err=%v vars=%v", r.err, v) + } + }) + t.Run("bare name unique across kinds", func(t *testing.T) { + s := srv(t) + r := runAuto(t, s, false, "", "approval", "weekly") + v := s.lastVars("AutomationApproval") + if r.err != nil || v["triggerKind"] != "SCHEDULE" || v["triggerRef"] != "s2" { + t.Fatalf("want SCHEDULE/s2: err=%v vars=%v", r.err, v) + } + }) + t.Run("bare name in two kinds refuses and lists only the matches", func(t *testing.T) { + s := srv(t) + r := runAuto(t, s, false, "", "approval", "nightly") + if r.code != exitcode.Usage || s.count("AutomationApproval") != 0 { + t.Fatalf("want exit 2 and no detail call: code=%d err=%v", r.code, r.err) + } + msg := r.err.Error() + for _, want := range []string{"schedule/nightly", "webhook/nightly", "Runs nightly", "Starts when called"} { + if !strings.Contains(msg, want) { + t.Errorf("ambiguity message missing %q: %s", want, msg) + } + } + for _, bad := range []string{"weekly", "hook ", "s1", "w1", "Runs weekly"} { + if strings.Contains(msg, bad) { + t.Errorf("ambiguity message must list only the matches, found %q: %s", bad, msg) + } + } + }) + t.Run("same kind and name refuses", func(t *testing.T) { + s := srv(t) + r := runAuto(t, s, false, "", "approve", "schedule/twin", "--yes") + if r.code != exitcode.Usage || s.count("ApproveAutomation") != 0 || s.count("AutomationApproval") != 0 { + t.Fatalf("want exit 2 and nothing sent: code=%d err=%v", r.code, r.err) + } + }) + t.Run("zero matches names the next step and dumps nothing", func(t *testing.T) { + s := srv(t) + r := runAuto(t, s, false, "", "approve", "nope", "--yes") + if r.code != exitcode.NotFound || s.count("ApproveAutomation") != 0 { + t.Fatalf("want exit 4 and nothing sent: code=%d err=%v", r.code, r.err) + } + msg := r.err.Error() + if !strings.Contains(msg, `no automation named "nope"`) || !strings.Contains(msg, autoApp) || !strings.Contains(msg, "hadron automation ls") { + t.Errorf("zero-match message: %s", msg) + } + for _, bad := range []string{"nightly", "weekly", "hook"} { + if strings.Contains(msg, bad) { + t.Errorf("zero-match must not list other automations (%q): %s", bad, msg) + } + } + }) + t.Run("a name and --run are exclusive, and one is required", func(t *testing.T) { + s := srv(t) + r := runAuto(t, s, false, "", "approve", "nightly", "--run", "run1", "--yes") + if r.code != exitcode.Usage { + t.Errorf("name plus --run: want exit 2, got %d (%v)", r.code, r.err) + } + r = runAuto(t, s, false, "", "approve", "--yes") + if r.code != exitcode.Usage { + t.Errorf("neither: want exit 2, got %d (%v)", r.code, r.err) + } + if s.count("ApproveAutomation") != 0 || s.count("AutomationApprovals") != 0 { + t.Errorf("a bad selection must send nothing") + } + }) + t.Run("--run names a MANUAL origin without listing", func(t *testing.T) { + s := srv(t) + r := runAuto(t, s, false, "", "approve", "--run", "run-123", "--yes") + v := s.lastVars("AutomationApproval") + if r.err != nil || v["triggerKind"] != "MANUAL" || v["triggerRef"] != "run-123" || s.count("AutomationApprovals") != 0 { + t.Fatalf("want MANUAL/run-123 and no list call: err=%v vars=%v", r.err, v) + } + if got := s.lastVars("ApproveAutomation"); got["triggerKind"] != "MANUAL" || got["triggerRef"] != "run-123" || got["expectedHash"] != autoHash { + t.Errorf("approve vars: %v", got) + } + }) +} + +func TestAutomationApproveSendsTheDisplayedHash(t *testing.T) { + s := oneRowServer(t, "UNADOPTED", nil) + r := runAuto(t, s, false, "", "approve", "schedule/nightly", "--yes", "--json") + if r.err != nil { + t.Fatal(r.err) + } + v := s.lastVars("ApproveAutomation") + if v["expectedHash"] != autoHash || v["triggerKind"] != "SCHEDULE" || v["triggerRef"] != "sch1" || s.count("ApproveAutomation") != 1 { + t.Fatalf("approve must send the hash it displayed, once: %v", v) + } + // --yes skips the prompt, never the display: the summary is on stderr. + for _, want := range []string{"nightly", "every night at 02:00", "hadron_find_nodes", "never a private memory"} { + if !strings.Contains(r.errOut, want) { + t.Errorf("--yes must still display the summary on stderr, missing %q:\n%s", want, r.errOut) + } + } + // stdout is the --json object only. + var got map[string]any + if err := json.Unmarshal([]byte(r.out), &got); err != nil { + t.Fatalf("stdout must be exactly one JSON object: %v\n%s", err, r.out) + } + if got["status"] != "approved" || got["app"] != autoApp { + t.Errorf("approve --json: %s", r.out) + } + if strings.Contains(r.out, "every night") { + t.Errorf("the display must not reach stdout: %s", r.out) + } +} + +func TestAutomationApproveStaleHashIsNeverRetried(t *testing.T) { + s := oneRowServer(t, "UNADOPTED", map[string]func(map[string]any) autoReply{ + "ApproveAutomation": fixed(memberGQLError("Automation changed since preview; preview and re-adopt again.", "AUTOMATION_CHANGED")), + }) + r := runAuto(t, s, false, "", "approve", "nightly", "--yes") + if r.code != exitcode.Conflict { + t.Fatalf("AUTOMATION_CHANGED must exit 5, got %d (%v)", r.code, r.err) + } + if s.count("ApproveAutomation") != 1 || s.count("AutomationApproval") != 1 { + t.Fatalf("no second fetch and no second approve: approve=%d detail=%d", s.count("ApproveAutomation"), s.count("AutomationApproval")) + } + if !strings.Contains(r.err.Error(), "hadron automation approval") { + t.Errorf("the refusal must say to run approval again: %v", r.err) + } + if r.out != "" { + t.Errorf("a refusal writes nothing to stdout: %q", r.out) + } +} + +func TestAutomationApproveConfirmation(t *testing.T) { + t.Run("non-TTY without --yes sends nothing", func(t *testing.T) { + s := oneRowServer(t, "UNADOPTED", nil) + r := runAuto(t, s, false, "", "approve", "nightly") + if r.code != exitcode.Usage || s.count("ApproveAutomation") != 0 { + t.Fatalf("want exit 2, zero approve: code=%d calls=%d", r.code, s.count("ApproveAutomation")) + } + }) + t.Run("no", func(t *testing.T) { + s := oneRowServer(t, "UNADOPTED", nil) + r := runAuto(t, s, true, "n\n", "approve", "nightly") + if r.code != exitcode.Cancelled || s.count("ApproveAutomation") != 0 { + t.Fatalf("want exit 6, zero approve: code=%d", r.code) + } + }) + t.Run("EOF", func(t *testing.T) { + s := oneRowServer(t, "UNADOPTED", nil) + r := runAuto(t, s, true, "", "approve", "nightly") + if r.code != exitcode.Cancelled || s.count("ApproveAutomation") != 0 { + t.Fatalf("want exit 6, zero approve: code=%d", r.code) + } + }) + t.Run("yes shows the summary before asking", func(t *testing.T) { + s := oneRowServer(t, "UNADOPTED", nil) + r := runAuto(t, s, true, "y\n", "approve", "nightly") + if r.err != nil || s.count("ApproveAutomation") != 1 { + t.Fatalf("want success, one approve: err=%v", r.err) + } + i, j := strings.Index(r.errOut, "every night at 02:00"), strings.Index(r.errOut, "(y/N)") + if i < 0 || j < 0 || i > j { + t.Errorf("the summary must precede the prompt on stderr:\n%s", r.errOut) + } + if !strings.Contains(r.errOut, "schedule/nightly") || !strings.Contains(r.errOut, autoApp) { + t.Errorf("the prompt must name the trigger and the App:\n%s", r.errOut) + } + }) +} + +func TestAutomationDeclineSendsNoHash(t *testing.T) { + s := oneRowServer(t, "ADOPTED", nil) + r := runAuto(t, s, false, "", "decline", "schedule/nightly", "--yes", "--json") + if r.err != nil { + t.Fatal(r.err) + } + v := s.lastVars("DeclineAutomation") + if _, has := v["expectedHash"]; has || v["triggerKind"] != "SCHEDULE" || v["triggerRef"] != "sch1" || len(v) != 2 { + t.Fatalf("decline carries kind and ref only: %v", v) + } + if s.count("AutomationApproval") != 0 { + t.Errorf("decline must not need the projection (an unreadable automation must stay declinable)") + } + var got map[string]any + if err := json.Unmarshal([]byte(r.out), &got); err != nil || got["status"] != "declined" { + t.Errorf("decline --json: %v %s", err, r.out) + } +} + +func TestAutomationDeclineSaysWhatItDoes(t *testing.T) { + t.Run("adopted", func(t *testing.T) { + s := oneRowServer(t, "ADOPTED", nil) + r := runAuto(t, s, true, "n\n", "decline", "nightly") + for _, want := range []string{"next run will fail", "approved again", "newer approval"} { + if !strings.Contains(r.errOut, want) { + t.Errorf("adopted decline must say %q:\n%s", want, r.errOut) + } + } + }) + t.Run("unadopted", func(t *testing.T) { + s := oneRowServer(t, "UNADOPTED", nil) + r := runAuto(t, s, true, "n\n", "decline", "nightly") + if !strings.Contains(r.errOut, "records your decline") || strings.Contains(r.errOut, "next run will fail") { + t.Errorf("unadopted decline only records the decision:\n%s", r.errOut) + } + }) + t.Run("no, EOF and non-TTY mutate nothing", func(t *testing.T) { + for name, c := range map[string]struct { + tty bool + answers string + code int + }{"no": {true, "n\n", exitcode.Cancelled}, "eof": {true, "", exitcode.Cancelled}, "non-TTY": {false, "", exitcode.Usage}} { + s := oneRowServer(t, "ADOPTED", nil) + r := runAuto(t, s, c.tty, c.answers, "decline", "nightly") + if r.code != c.code || s.count("DeclineAutomation") != 0 { + t.Errorf("%s: want exit %d and zero declines, got %d / %d", name, c.code, r.code, s.count("DeclineAutomation")) + } + } + }) + t.Run("MANUAL origin", func(t *testing.T) { + s := oneRowServer(t, "ADOPTED", nil) + r := runAuto(t, s, false, "", "decline", "--run", "run-9", "--yes") + v := s.lastVars("DeclineAutomation") + if r.err != nil || v["triggerKind"] != "MANUAL" || v["triggerRef"] != "run-9" { + t.Fatalf("MANUAL decline: err=%v vars=%v", r.err, v) + } + }) +} + +func TestAutomationApprovalDisplay(t *testing.T) { + t.Run("summary without depth carries no hash", func(t *testing.T) { + s := oneRowServer(t, "UNADOPTED", nil) + r := runAuto(t, s, false, "", "approval", "nightly") + if r.err != nil || s.count("AutomationApprovalDepth") != 0 { + t.Fatalf("plain approval must not fetch the graph: err=%v", r.err) + } + if strings.Contains(r.out, autoHash) || !strings.Contains(r.out, "not approved") || !strings.Contains(r.out, "every night at 02:00") { + t.Errorf("approval text:\n%s", r.out) + } + }) + t.Run("--depth adds the graph and the hash", func(t *testing.T) { + s := oneRowServer(t, "ADOPTED", nil) + r := runAuto(t, s, false, "", "approval", "nightly", "--depth") + if r.err != nil || s.count("AutomationApprovalDepth") != 1 { + t.Fatalf("--depth must fetch the graph once: err=%v", r.err) + } + if !strings.Contains(r.out, autoHash) || !strings.Contains(r.out, "flows:main") { + t.Errorf("--depth output:\n%s", r.out) + } + }) + t.Run("CHANGED is shown, as the server said it", func(t *testing.T) { + s := oneRowServer(t, "CHANGED", nil) + r := runAuto(t, s, false, "", "approval", "nightly") + if !strings.Contains(r.out, "changed since it was approved") { + t.Errorf("CHANGED state:\n%s", r.out) + } + }) + t.Run("--json is the typed projection", func(t *testing.T) { + s := oneRowServer(t, "ADOPTED", nil) + r := runAuto(t, s, false, "", "approval", "nightly", "--json") + var got struct { + App string `json:"app"` + State string `json:"state"` + Hash string `json:"hash"` + Summary struct { + Name string `json:"name"` + Reads []string `json:"reads"` + Tools []string `json:"tools"` + Writes []string `json:"writes"` + Flows []struct { + Name string `json:"name"` + Writes []string `json:"writes"` + } `json:"flows"` + } `json:"summary"` + Depth json.RawMessage `json:"depth"` + } + if err := json.Unmarshal([]byte(r.out), &got); err != nil { + t.Fatalf("%v\n%s", err, r.out) + } + if got.State != "ADOPTED" || got.Hash != autoHash || got.Summary.Name != "nightly" || len(got.Summary.Flows) != 1 || + string(got.Depth) != "" && string(got.Depth) != "null" { + t.Errorf("projection: %s", r.out) + } + if !strings.Contains(r.out, `"writes": []`) { + t.Errorf("empty valid arrays must serialize as []: %s", r.out) + } + }) +} + +func TestAutomationServerTextIsTerminalSafeAndBounded(t *testing.T) { + evil := "evil\x1b[31mred\x1b]0;owned\x07\nsecond line\x00end" + long := strings.Repeat("x", 5000) + detail := fmt.Sprintf(`{"data":{"automationApproval":{"state":"UNADOPTED","hash":%q,"summary":{"name":%q,"schedule":null, + "reads":[%q],"writes":[],"tools":[],"limits":[],"cannotDo":[],"flows":[]}}}}`, autoHash, evil, long) + s := oneRowServer(t, "UNADOPTED", map[string]func(map[string]any) autoReply{ + "AutomationApproval": fixed(detail), + "AutomationApprovals": fixed(autoPage([]string{ + autoRow("SCHEDULE", "s1", evil, "UNADOPTED", evil)}, "", true)), + }) + for _, args := range [][]string{{"approval", "evil"}, {"ls"}, {"approve", "evil"}} { + r := runAuto(t, s, true, "n\n", args...) + all := r.out + r.errOut + for _, bad := range []string{"\x1b", "\x07", "\x00"} { + if strings.Contains(all, bad) { + t.Errorf("%v: control character %q reached the terminal:\n%q", args, bad, all) + } + } + if strings.Contains(all, long) { + t.Errorf("%v: a 5000-char server string must be bounded", args) + } + } + // --json passes the server's strings through unchanged. + r := runAuto(t, s, false, "", "approval", "evil", "--json") + var got struct { + Summary struct { + Name string `json:"name"` + } `json:"summary"` + } + if err := json.Unmarshal([]byte(r.out), &got); err != nil || got.Summary.Name != evil { + t.Errorf("--json must not alter server text: %v %q", err, got.Summary.Name) + } +} + +func TestAutomationMalformedProjectionIsUnavailable(t *testing.T) { + cases := map[string]string{ + "empty hash": strings.Replace(autoDetail("UNADOPTED", autoHash, "nightly"), autoHash, "", 1), + "unknown state": autoDetail("MAYBE", autoHash, "nightly"), + "null reads": strings.Replace(autoDetail("UNADOPTED", autoHash, "nightly"), `"reads":["what the App can read in this App"]`, `"reads":null`, 1), + "null summary": fmt.Sprintf(`{"data":{"automationApproval":{"state":"UNADOPTED","hash":%q,"summary":null}}}`, autoHash), + "null cannotDo": strings.Replace(autoDetail("UNADOPTED", autoHash, "nightly"), `"cannotDo":["never a private memory"]`, `"cannotDo":null`, 1), + "null flows": strings.Replace(autoDetail("UNADOPTED", autoHash, "nightly"), `"flows":[{"name":"main","urn":"hrn:node:acme.com:ops:flows:main","tools":["hadron_find_nodes"],"writes":[]}]`, `"flows":null`, 1), + "null flow writes": strings.Replace(autoDetail("UNADOPTED", autoHash, "nightly"), `"writes":[]}]`, `"writes":null}]`, 1), + "data null no err": `{"data":null}`, + "approval null": `{"data":{"automationApproval":null}}`, + "empty object body": `{"data":{}}`, + } + for name, body := range cases { + for _, verb := range []string{"approval", "approve"} { + t.Run(name+"/"+verb, func(t *testing.T) { + s := oneRowServer(t, "UNADOPTED", map[string]func(map[string]any) autoReply{"AutomationApproval": fixed(body)}) + r := runAuto(t, s, false, "", verb, "nightly", "--yes") + if r.code != exitcode.Unavailable || s.count("ApproveAutomation") != 0 || r.out != "" { + t.Fatalf("want exit 7, nothing approved, empty stdout: code=%d err=%v out=%q", r.code, r.err, r.out) + } + }) + } + } + t.Run("list rows", func(t *testing.T) { + for name, body := range map[string]string{ + "null rows": `{"data":{"automationApprovals":{"rows":null,"nextCursor":null,"omitted":[]}}}`, + "null omitted": `{"data":{"automationApprovals":{"rows":[],"nextCursor":null,"omitted":null}}}`, + "CHANGED in row": autoPage([]string{autoRow("SCHEDULE", "s1", "n", "CHANGED", "h")}, "", true), + "unknown kind": autoPage([]string{autoRow("CRON", "s1", "n", "ADOPTED", "h")}, "", true), + "empty ref": autoPage([]string{autoRow("SCHEDULE", "", "n", "ADOPTED", "h")}, "", true), + "empty name": autoPage([]string{autoRow("SCHEDULE", "s1", "", "ADOPTED", "h")}, "", true), + } { + s := newAutoServer(t, map[string]func(map[string]any) autoReply{"AutomationApprovals": fixed(body)}) + r := runAuto(t, s, false, "", "ls", "--json") + if r.code != exitcode.Unavailable || r.out != "" { + t.Errorf("%s: want exit 7 and no stdout, got %d %q", name, r.code, r.out) + } + } + }) + t.Run("a repeating cursor stops instead of looping", func(t *testing.T) { + s := newAutoServer(t, map[string]func(map[string]any) autoReply{ + "AutomationApprovals": fixed(autoPage([]string{autoRow("SCHEDULE", "s1", "n", "ADOPTED", "h")}, "same", true)), + }) + r := runAuto(t, s, false, "", "ls") + if r.code != exitcode.Unavailable || s.count("AutomationApprovals") > 3 { + t.Errorf("a cursor that repeats is a server fault: code=%d calls=%d", r.code, s.count("AutomationApprovals")) + } + }) +} + +func TestAutomationOldServerExitsUnavailableBeforeAnyPrompt(t *testing.T) { + skew := autoReply{status: 400, body: `{"errors":[{"message":"Cannot query field \"automationApprovals\" on type \"Query\".","extensions":{"code":"GRAPHQL_VALIDATION_FAILED"}}]}`} + skewFn := func(map[string]any) autoReply { return skew } + for _, args := range [][]string{{"ls"}, {"approval", "nightly"}, {"approve", "nightly"}, {"decline", "nightly"}, {"approve", "--run", "r1"}, {"decline", "--run", "r1"}, {"approval", "--run", "r1"}} { + s := newAutoServer(t, map[string]func(map[string]any) autoReply{ + "AutomationApprovals": skewFn, "AutomationApproval": skewFn, "AutomationApprovalDepth": skewFn, + "ApproveAutomation": skewFn, "DeclineAutomation": skewFn, + }) + r := runAuto(t, s, true, "y\n", args...) + if r.code != exitcode.Unavailable || !strings.Contains(r.err.Error(), "this server does not support automation approval") { + t.Errorf("%v: want exit 7 with the skew sentence, got %d: %v", args, r.code, r.err) + } + if strings.Contains(r.errOut, "(y/N)") || s.count("ApproveAutomation")+s.count("DeclineAutomation") > 1 { + t.Errorf("%v: a skewed server must never reach a prompt:\n%s", args, r.errOut) + } + } +} + +func TestAutomationRefusalExitCodes(t *testing.T) { + cases := []struct { + name, body string + code int + }{ + {"forbidden", memberGQLError("Only the run-as user may preview and re-adopt this automation", "FORBIDDEN"), exitcode.Forbidden}, + {"caller cannot read", memberGQLError("You cannot read a memory this automation uses", "FORBIDDEN"), exitcode.Forbidden}, + {"not found", memberGQLError("Automation trigger not found.", "NOT_FOUND"), exitcode.NotFound}, + {"changed", memberGQLError("Automation changed since preview", "AUTOMATION_CHANGED"), exitcode.Conflict}, + {"unadopted", memberGQLError("Automation is not adopted", "AUTOMATION_UNADOPTED"), exitcode.Conflict}, + {"bad input", memberGQLError("bad", "BAD_USER_INPUT"), exitcode.Usage}, + } + for _, c := range cases { + for _, op := range []string{"approve", "decline"} { + t.Run(c.name+"/"+op, func(t *testing.T) { + s := oneRowServer(t, "UNADOPTED", map[string]func(map[string]any) autoReply{ + "ApproveAutomation": fixed(c.body), "DeclineAutomation": fixed(c.body)}) + r := runAuto(t, s, false, "", op, "nightly", "--yes") + if r.code != c.code || r.out != "" { + t.Fatalf("want exit %d, empty stdout: got %d err=%v out=%q", c.code, r.code, r.err, r.out) + } + if got := s.count("ApproveAutomation") + s.count("DeclineAutomation"); got != 1 { + t.Errorf("a refusal is never retried: %d mutation calls", got) + } + }) + } + } + t.Run("a refusal on the detail read stops before the prompt", func(t *testing.T) { + s := oneRowServer(t, "UNADOPTED", map[string]func(map[string]any) autoReply{ + "AutomationApproval": fixed(memberGQLError("You cannot read a memory this automation uses", "FORBIDDEN"))}) + r := runAuto(t, s, true, "y\n", "approve", "nightly") + if r.code != exitcode.Forbidden || s.count("ApproveAutomation") != 0 || strings.Contains(r.errOut, "(y/N)") { + t.Fatalf("want exit 8, no prompt, no approve: %d\n%s", r.code, r.errOut) + } + }) +} + +func TestAutomationUnconfirmedMutationIsUnavailable(t *testing.T) { + for _, op := range []string{"approve", "decline"} { + t.Run(op+" false", func(t *testing.T) { + s := oneRowServer(t, "UNADOPTED", map[string]func(map[string]any) autoReply{ + "ApproveAutomation": fixed(`{"data":{"approveAutomation":false}}`), + "DeclineAutomation": fixed(`{"data":{"declineAutomation":false}}`)}) + r := runAuto(t, s, false, "", op, "nightly", "--yes") + if r.code != exitcode.Unavailable || r.out != "" { + t.Fatalf("a false answer is not a success: code=%d out=%q", r.code, r.out) + } + }) + t.Run(op+" true beside an error is unknown", func(t *testing.T) { + body := `{"data":{"approveAutomation":true,"declineAutomation":true},"errors":[{"message":"x","extensions":{"code":"FORBIDDEN"}}]}` + s := oneRowServer(t, "UNADOPTED", map[string]func(map[string]any) autoReply{ + "ApproveAutomation": fixed(body), "DeclineAutomation": fixed(body)}) + r := runAuto(t, s, false, "", op, "nightly", "--yes") + if r.code != exitcode.Unavailable || !strings.Contains(r.err.Error(), "outcome unknown") || r.out != "" { + t.Fatalf("partial data is outcome unknown: code=%d err=%v", r.code, r.err) + } + }) + } +} + +func TestAutomationAppIsResolvedLikeOtherAppCommands(t *testing.T) { + t.Run("--app is canonicalized and reaches the server", func(t *testing.T) { + s := oneRowServer(t, "UNADOPTED", nil) + r := runAuto(t, s, false, "", "ls", "--app", "acme.com:ops") + if r.err != nil || s.lastVars("AutomationApprovals")["appRef"] != autoApp { + t.Fatalf("want canonical %s: err=%v vars=%v", autoApp, r.err, s.lastVars("AutomationApprovals")) + } + }) + t.Run("a blank --app refuses instead of using the active App", func(t *testing.T) { + s := oneRowServer(t, "UNADOPTED", nil) + autoActiveApp = memberActiveApp + t.Cleanup(func() { autoActiveApp = "" }) + for _, verb := range []string{"ls", "approval nightly", "approve nightly", "decline nightly"} { + r := runAuto(t, s, false, "", append(strings.Fields(verb), "--app", "", "--yes")...) + if r.code != exitcode.Usage { + t.Errorf("%s: want exit 2, got %d (%v)", verb, r.code, r.err) + } + } + if s.count("AutomationApprovals")+s.count("ApproveAutomation")+s.count("DeclineAutomation") != 0 { + t.Errorf("a blank --app must send nothing") + } + }) + t.Run("no App at all refuses", func(t *testing.T) { + s := oneRowServer(t, "UNADOPTED", nil) + f, _ := testFactory(t) + root := NewRootCmd(f) + root.SetArgs([]string{"automation", "ls", "--server", s.URL}) + if err := root.Execute(); exitCodeFor(err) != exitcode.Usage || s.count("AutomationApprovals") != 0 { + t.Fatalf("want exit 2 and nothing sent, got %v", err) + } + }) + t.Run("the active App is used and named", func(t *testing.T) { + s := oneRowServer(t, "UNADOPTED", nil) + autoActiveApp = memberActiveApp + t.Cleanup(func() { autoActiveApp = "" }) + r := runAuto(t, s, true, "n\n", "decline", "nightly") + if s.lastVars("AutomationApprovals")["appRef"] != memberActiveApp || !strings.Contains(r.errOut, memberActiveApp) { + t.Errorf("active App must be used and named in the prompt: %v\n%s", s.lastVars("AutomationApprovals"), r.errOut) + } + }) +} + +func TestAutomationLegacyOperationsStayUnbound(t *testing.T) { + b, err := os.ReadFile("../api/unbound-ops.txt") + if err != nil { + t.Fatal(err) + } + for _, op := range []string{"previewAutomation", "reAdoptAutomation"} { + if !strings.Contains(string(b), op+" ") && !strings.Contains(string(b), op+"\n") { + t.Errorf("%s has no CLI call site and must stay in unbound-ops.txt", op) + } + } + for _, op := range []string{"automationApprovals", "automationApproval", "approveAutomation", "declineAutomation"} { + if strings.Contains(string(b), "\n"+op+" ") || strings.Contains(string(b), "\n"+op+"\n") { + t.Errorf("%s is bound by this slice and must not be listed unbound", op) + } + } +} From 0b732c8c88c11790da1f1ed25a18e29d434205b0 Mon Sep 17 00:00:00 2001 From: Holger Selover-Stephan Date: Sun, 25 Oct 2026 18:28:10 +0000 Subject: [PATCH 3/7] Add `automation list|approval|approve|decline` (#1071) An App owner reviews and approves schedules and webhooks by name, without a row id or a hash. approve shows one projection on stderr and sends the hash it showed; AUTOMATION_CHANGED exits 5 and is never retried; decline carries no hash and never reads the projection; no/EOF/non-TTY mutate nothing. New codeForExtension rows for AUTOMATION_CHANGED/UNADOPTED, a cmdutil.TargetApp shared with `app member`, agentic-usage and a design-as-built plan doc. Worker: Jack (cli-engineer) Co-Authored-By: Claude Sonnet 5.5 --- docs/plans/automation-approval-s2.md | 49 +++ internal/api/errors.go | 12 + internal/api/errors_test.go | 5 + internal/api/gen/generated.go | 388 +++++++++++++----------- internal/api/queries/automation.graphql | 66 ++-- internal/cmd/agentic/agentic-usage.md | 38 +++ internal/cmd/app/member.go | 31 +- internal/cmd/automation/approval.go | 138 +++++++++ internal/cmd/automation/automation.go | 42 +++ internal/cmd/automation/ls.go | 88 ++++++ internal/cmd/automation/mutate.go | 187 ++++++++++++ internal/cmd/automation/resolve.go | 120 ++++++++ internal/cmd/automation/server.go | 236 ++++++++++++++ internal/cmd/automation/text.go | 80 +++++ internal/cmd/automation_cmd_test.go | 62 ++-- internal/cmd/root.go | 2 + internal/cmdutil/targetapp.go | 40 +++ 17 files changed, 1323 insertions(+), 261 deletions(-) create mode 100644 docs/plans/automation-approval-s2.md create mode 100644 internal/cmd/automation/approval.go create mode 100644 internal/cmd/automation/automation.go create mode 100644 internal/cmd/automation/ls.go create mode 100644 internal/cmd/automation/mutate.go create mode 100644 internal/cmd/automation/resolve.go create mode 100644 internal/cmd/automation/server.go create mode 100644 internal/cmd/automation/text.go create mode 100644 internal/cmdutil/targetapp.go diff --git a/docs/plans/automation-approval-s2.md b/docs/plans/automation-approval-s2.md new file mode 100644 index 00000000..cced78d1 --- /dev/null +++ b/docs/plans/automation-approval-s2.md @@ -0,0 +1,49 @@ +# `hadron automation` — owner approval of schedules and webhooks (cli#1071, S2) + +Design-as-built for slice S2 of the automation-adoption plan (approved r4: +`hrn:node:hadronmemory.com:hadron-dev-team-shared:plans:automation-adoption-approval`). +Server contract: hadron-server#2441 (S1, merged `aef8e83b`) and #2434 (`36d2c9b9`). +The snapshot was refreshed from `36d2c9b98` (== origin/main). Every sentence, state and hash is the server's; +the CLI binds `automationApprovals`, `automationApproval`, `approveAutomation` +and `declineAutomation` and derives nothing. + +## Surface + +| Command | Does | +|---|---| +| `automation list` (alias `ls`) | follows `nextCursor` to the end; rows UNADOPTED/ADOPTED, printed "not approved"/"approved"; the MANUAL `omitted` entry prints "Manual runs are approved from the run that needs them" | +| `automation approval [--depth]` | the plain summary; `--depth` adds graph and hash (`AutomationApprovalDepth`, a separate operation so the graph is only fetched when asked) | +| `automation approve [--yes]` | fetch one projection, show it on stderr, confirm, send THAT hash; never retry | +| `automation decline [--yes]` | kind and ref only; no projection read | + +`` is `schedule/`, `webhook/` or a bare name one trigger has; +`--run ` names a MANUAL origin (exclusive with a name; an explicit blank +`--run` refuses). `--app` is resolved by `cmdutil.TargetApp` (moved out of +`app member`, same rule: explicit blank refuses, ambient App is named in the prompt). + +## Decisions + +- **Resolution** matches the whole list (all pages). Zero matches exit 4 with one + line; several exit 2 listing only the matches by kind and headline. The wire has + no creation time, so the issue's "creation time" is not shown. Two triggers + sharing kind and name cannot be told apart by a name: the refusal says to rename + one. No row id or ref is ever typed or printed for selection. +- **Decline needs no projection** (an unreadable automation must stay declinable). + Its wording comes from the list state: ADOPTED (which may in fact be CHANGED) is + "switches it off ... including a newer approval", UNADOPTED "only records". + A MANUAL decline has no list row, so it uses a generic MANUAL sentence and first + runs `requireSupport` (one list page, only schema skew acted on) so an old server + is refused before the prompt. +- **Exit codes**: FORBIDDEN 8 (the #2434 read refusals ride it), NOT_FOUND 4, + `AUTOMATION_CHANGED` / `AUTOMATION_UNADOPTED` 5 (new `codeForExtension` rows), + selection 2, cancelled 6, 7 for malformed or unconfirmed. Schema skew exits 7 + with its own sentence (the generic `MapError` skew path is exit 2 and is not + changed). +- **Malformed is unavailable (7)**: a null required list, empty hash, unknown + state, a `CHANGED` or MANUAL list row, a null page, a repeating cursor. A `true` + mutation result beside an error is outcome unknown (7), never retried. +- **Terminal safety**: text mode strips escapes and control characters and bounds + every server string and list; `--json` is unchanged. +- **Legacy**: `previewAutomation` and `reAdoptAutomation` have no call site and + stay in `unbound-ops.txt`. The schema refresh also lists the unrelated + operations the snapshot was behind on. diff --git a/internal/api/errors.go b/internal/api/errors.go index b304937a..9723176e 100644 --- a/internal/api/errors.go +++ b/internal/api/errors.go @@ -459,6 +459,12 @@ func IsGraphQLValidationFor(err error, name string) bool { return false } +// IsSchemaSkew reports whether err is the server rejecting a query this build +// sends (a GraphQL validation failure) — the server lacks an operation or +// field. A command that needs a more specific sentence than MapError's generic +// skew hint (and a different exit code) asks this before mapping. +func IsSchemaSkew(err error) bool { return isSchemaSkew(err) } + // isSchemaSkew reports whether err is a GraphQL validation failure — the server // rejecting a query that references a field/operation it doesn't have. func isSchemaSkew(err error) bool { @@ -775,6 +781,12 @@ func codeForExtension(code string) int { return exitcode.Usage case code == "MaxMembersExceededError" || code == "OrphanAppError": return exitcode.Conflict + // Automation approval (cli#1071, hadron-server#2441): the automation changed + // since the adopter looked, or nothing is adopted. Neither is fixed by + // retrying the same call; the adopter has to run `automation approval` + // again. The read-access refusals (#2434) carry FORBIDDEN. + case code == "AUTOMATION_CHANGED" || code == "AUTOMATION_UNADOPTED": + return exitcode.Conflict case code == "UNAUTHENTICATED": return exitcode.AuthRequired case code == "NOT_FOUND" || strings.HasSuffix(code, "_NOT_FOUND") || code == "AiServiceConfigNotFoundError": diff --git a/internal/api/errors_test.go b/internal/api/errors_test.go index 9688a2f0..fb46e29e 100644 --- a/internal/api/errors_test.go +++ b/internal/api/errors_test.go @@ -69,6 +69,11 @@ func TestMapError(t *testing.T) { {"app member invalid role", gqlErr("InvalidRoleError"), exitcode.Usage}, {"app member cap", gqlErr("MaxMembersExceededError"), exitcode.Conflict}, {"app member orphan", gqlErr("OrphanAppError"), exitcode.Conflict}, + // Automation approval (cli#1071, hadron-server#2441): both need the adopter + // to look again, so a state conflict. The read refusals #2434 added ride + // FORBIDDEN, which was already mapped. + {"automation changed", gqlErr("AUTOMATION_CHANGED"), exitcode.Conflict}, + {"automation unadopted", gqlErr("AUTOMATION_UNADOPTED"), exitcode.Conflict}, {"reports to self", gqlErr("WORKER_REPORTS_TO_SELF"), exitcode.Usage}, // Host poller and launch refusals (cli#831). {"host key required", gqlErr("HOST_KEY_REQUIRED"), exitcode.Forbidden}, diff --git a/internal/api/gen/generated.go b/internal/api/gen/generated.go index dcf091f4..dc1f982c 100644 --- a/internal/api/gen/generated.go +++ b/internal/api/gen/generated.go @@ -9046,232 +9046,256 @@ var AllAutomationAdoptionState = []AutomationAdoptionState{ // // #2441: what an adopter reviews before approving. depth carries only what previewAutomation returns to the same caller, plus the registered resources with URL userinfo and query removed. type AutomationApprovalAutomationApproval struct { - State AutomationAdoptionState `json:"state"` - Hash string `json:"hash"` - Summary *AutomationApprovalAutomationApprovalSummaryAutomationSummary `json:"summary"` + AutomationApprovalFields `json:"-"` } // GetState returns AutomationApprovalAutomationApproval.State, and is useful for accessing the field via an interface. -func (v *AutomationApprovalAutomationApproval) GetState() AutomationAdoptionState { return v.State } +func (v *AutomationApprovalAutomationApproval) GetState() AutomationAdoptionState { + return v.AutomationApprovalFields.State +} // GetHash returns AutomationApprovalAutomationApproval.Hash, and is useful for accessing the field via an interface. -func (v *AutomationApprovalAutomationApproval) GetHash() string { return v.Hash } +func (v *AutomationApprovalAutomationApproval) GetHash() string { + return v.AutomationApprovalFields.Hash +} // GetSummary returns AutomationApprovalAutomationApproval.Summary, and is useful for accessing the field via an interface. -func (v *AutomationApprovalAutomationApproval) GetSummary() *AutomationApprovalAutomationApprovalSummaryAutomationSummary { - return v.Summary +func (v *AutomationApprovalAutomationApproval) GetSummary() *AutomationApprovalFieldsSummaryAutomationSummary { + return v.AutomationApprovalFields.Summary } -// AutomationApprovalAutomationApprovalSummaryAutomationSummary includes the requested fields of the GraphQL type AutomationSummary. -// The GraphQL type's documentation follows. -// -// #2441: plain-language facts derived from the previewed automation. A fact the pin cannot show says not stated; nothing here is a maximum the approval enforces. -type AutomationApprovalAutomationApprovalSummaryAutomationSummary struct { - Name string `json:"name"` - Schedule *string `json:"schedule"` - Reads []string `json:"reads"` - Writes []string `json:"writes"` - Tools []string `json:"tools"` - Limits []string `json:"limits"` - CannotDo []string `json:"cannotDo"` - Flows []*AutomationApprovalAutomationApprovalSummaryAutomationSummaryFlowsAutomationFlowSummary `json:"flows"` -} - -// GetName returns AutomationApprovalAutomationApprovalSummaryAutomationSummary.Name, and is useful for accessing the field via an interface. -func (v *AutomationApprovalAutomationApprovalSummaryAutomationSummary) GetName() string { - return v.Name +func (v *AutomationApprovalAutomationApproval) UnmarshalJSON(b []byte) error { + + if string(b) == "null" { + return nil + } + + var firstPass struct { + *AutomationApprovalAutomationApproval + graphql.NoUnmarshalJSON + } + firstPass.AutomationApprovalAutomationApproval = v + + err := json.Unmarshal(b, &firstPass) + if err != nil { + return err + } + + err = json.Unmarshal( + b, &v.AutomationApprovalFields) + if err != nil { + return err + } + return nil } -// GetSchedule returns AutomationApprovalAutomationApprovalSummaryAutomationSummary.Schedule, and is useful for accessing the field via an interface. -func (v *AutomationApprovalAutomationApprovalSummaryAutomationSummary) GetSchedule() *string { - return v.Schedule +type __premarshalAutomationApprovalAutomationApproval struct { + State AutomationAdoptionState `json:"state"` + + Hash string `json:"hash"` + + Summary *AutomationApprovalFieldsSummaryAutomationSummary `json:"summary"` } -// GetReads returns AutomationApprovalAutomationApprovalSummaryAutomationSummary.Reads, and is useful for accessing the field via an interface. -func (v *AutomationApprovalAutomationApprovalSummaryAutomationSummary) GetReads() []string { - return v.Reads +func (v *AutomationApprovalAutomationApproval) MarshalJSON() ([]byte, error) { + premarshaled, err := v.__premarshalJSON() + if err != nil { + return nil, err + } + return json.Marshal(premarshaled) } -// GetWrites returns AutomationApprovalAutomationApprovalSummaryAutomationSummary.Writes, and is useful for accessing the field via an interface. -func (v *AutomationApprovalAutomationApprovalSummaryAutomationSummary) GetWrites() []string { - return v.Writes +func (v *AutomationApprovalAutomationApproval) __premarshalJSON() (*__premarshalAutomationApprovalAutomationApproval, error) { + var retval __premarshalAutomationApprovalAutomationApproval + + retval.State = v.AutomationApprovalFields.State + retval.Hash = v.AutomationApprovalFields.Hash + retval.Summary = v.AutomationApprovalFields.Summary + return &retval, nil } -// GetTools returns AutomationApprovalAutomationApprovalSummaryAutomationSummary.Tools, and is useful for accessing the field via an interface. -func (v *AutomationApprovalAutomationApprovalSummaryAutomationSummary) GetTools() []string { - return v.Tools +// AutomationApprovalDepthAutomationApproval includes the requested fields of the GraphQL type AutomationApproval. +// The GraphQL type's documentation follows. +// +// #2441: what an adopter reviews before approving. depth carries only what previewAutomation returns to the same caller, plus the registered resources with URL userinfo and query removed. +type AutomationApprovalDepthAutomationApproval struct { + AutomationApprovalFields `json:"-"` + Depth json.RawMessage `json:"depth"` } -// GetLimits returns AutomationApprovalAutomationApprovalSummaryAutomationSummary.Limits, and is useful for accessing the field via an interface. -func (v *AutomationApprovalAutomationApprovalSummaryAutomationSummary) GetLimits() []string { - return v.Limits +// GetDepth returns AutomationApprovalDepthAutomationApproval.Depth, and is useful for accessing the field via an interface. +func (v *AutomationApprovalDepthAutomationApproval) GetDepth() json.RawMessage { return v.Depth } + +// GetState returns AutomationApprovalDepthAutomationApproval.State, and is useful for accessing the field via an interface. +func (v *AutomationApprovalDepthAutomationApproval) GetState() AutomationAdoptionState { + return v.AutomationApprovalFields.State } -// GetCannotDo returns AutomationApprovalAutomationApprovalSummaryAutomationSummary.CannotDo, and is useful for accessing the field via an interface. -func (v *AutomationApprovalAutomationApprovalSummaryAutomationSummary) GetCannotDo() []string { - return v.CannotDo +// GetHash returns AutomationApprovalDepthAutomationApproval.Hash, and is useful for accessing the field via an interface. +func (v *AutomationApprovalDepthAutomationApproval) GetHash() string { + return v.AutomationApprovalFields.Hash } -// GetFlows returns AutomationApprovalAutomationApprovalSummaryAutomationSummary.Flows, and is useful for accessing the field via an interface. -func (v *AutomationApprovalAutomationApprovalSummaryAutomationSummary) GetFlows() []*AutomationApprovalAutomationApprovalSummaryAutomationSummaryFlowsAutomationFlowSummary { - return v.Flows +// GetSummary returns AutomationApprovalDepthAutomationApproval.Summary, and is useful for accessing the field via an interface. +func (v *AutomationApprovalDepthAutomationApproval) GetSummary() *AutomationApprovalFieldsSummaryAutomationSummary { + return v.AutomationApprovalFields.Summary } -// AutomationApprovalAutomationApprovalSummaryAutomationSummaryFlowsAutomationFlowSummary includes the requested fields of the GraphQL type AutomationFlowSummary. -// The GraphQL type's documentation follows. -// -// #2441: one flow of an automation (the entry flow or a flow it routes to). -type AutomationApprovalAutomationApprovalSummaryAutomationSummaryFlowsAutomationFlowSummary struct { - Name string `json:"name"` - Urn string `json:"urn"` - Tools []string `json:"tools"` - Writes []string `json:"writes"` +func (v *AutomationApprovalDepthAutomationApproval) UnmarshalJSON(b []byte) error { + + if string(b) == "null" { + return nil + } + + var firstPass struct { + *AutomationApprovalDepthAutomationApproval + graphql.NoUnmarshalJSON + } + firstPass.AutomationApprovalDepthAutomationApproval = v + + err := json.Unmarshal(b, &firstPass) + if err != nil { + return err + } + + err = json.Unmarshal( + b, &v.AutomationApprovalFields) + if err != nil { + return err + } + return nil } -// GetName returns AutomationApprovalAutomationApprovalSummaryAutomationSummaryFlowsAutomationFlowSummary.Name, and is useful for accessing the field via an interface. -func (v *AutomationApprovalAutomationApprovalSummaryAutomationSummaryFlowsAutomationFlowSummary) GetName() string { - return v.Name +type __premarshalAutomationApprovalDepthAutomationApproval struct { + Depth json.RawMessage `json:"depth"` + + State AutomationAdoptionState `json:"state"` + + Hash string `json:"hash"` + + Summary *AutomationApprovalFieldsSummaryAutomationSummary `json:"summary"` } -// GetUrn returns AutomationApprovalAutomationApprovalSummaryAutomationSummaryFlowsAutomationFlowSummary.Urn, and is useful for accessing the field via an interface. -func (v *AutomationApprovalAutomationApprovalSummaryAutomationSummaryFlowsAutomationFlowSummary) GetUrn() string { - return v.Urn +func (v *AutomationApprovalDepthAutomationApproval) MarshalJSON() ([]byte, error) { + premarshaled, err := v.__premarshalJSON() + if err != nil { + return nil, err + } + return json.Marshal(premarshaled) } -// GetTools returns AutomationApprovalAutomationApprovalSummaryAutomationSummaryFlowsAutomationFlowSummary.Tools, and is useful for accessing the field via an interface. -func (v *AutomationApprovalAutomationApprovalSummaryAutomationSummaryFlowsAutomationFlowSummary) GetTools() []string { - return v.Tools +func (v *AutomationApprovalDepthAutomationApproval) __premarshalJSON() (*__premarshalAutomationApprovalDepthAutomationApproval, error) { + var retval __premarshalAutomationApprovalDepthAutomationApproval + + retval.Depth = v.Depth + retval.State = v.AutomationApprovalFields.State + retval.Hash = v.AutomationApprovalFields.Hash + retval.Summary = v.AutomationApprovalFields.Summary + return &retval, nil } -// GetWrites returns AutomationApprovalAutomationApprovalSummaryAutomationSummaryFlowsAutomationFlowSummary.Writes, and is useful for accessing the field via an interface. -func (v *AutomationApprovalAutomationApprovalSummaryAutomationSummaryFlowsAutomationFlowSummary) GetWrites() []string { - return v.Writes +// AutomationApprovalDepthResponse is returned by AutomationApprovalDepth on success. +type AutomationApprovalDepthResponse struct { + // #2441: state, hash, plain summary and graph depth for one trigger. MANUAL takes the origin run id. Same gate as approveAutomation. + AutomationApproval *AutomationApprovalDepthAutomationApproval `json:"automationApproval"` } -// AutomationApprovalDepthAutomationApproval includes the requested fields of the GraphQL type AutomationApproval. -// The GraphQL type's documentation follows. -// -// #2441: what an adopter reviews before approving. depth carries only what previewAutomation returns to the same caller, plus the registered resources with URL userinfo and query removed. -type AutomationApprovalDepthAutomationApproval struct { - State AutomationAdoptionState `json:"state"` - Hash string `json:"hash"` - Depth json.RawMessage `json:"depth"` - Summary *AutomationApprovalDepthAutomationApprovalSummaryAutomationSummary `json:"summary"` +// GetAutomationApproval returns AutomationApprovalDepthResponse.AutomationApproval, and is useful for accessing the field via an interface. +func (v *AutomationApprovalDepthResponse) GetAutomationApproval() *AutomationApprovalDepthAutomationApproval { + return v.AutomationApproval } -// GetState returns AutomationApprovalDepthAutomationApproval.State, and is useful for accessing the field via an interface. -func (v *AutomationApprovalDepthAutomationApproval) GetState() AutomationAdoptionState { - return v.State +// What an adopter reviews before approving: state, the hash to approve, and the +// plain summary. +type AutomationApprovalFields struct { + State AutomationAdoptionState `json:"state"` + Hash string `json:"hash"` + Summary *AutomationApprovalFieldsSummaryAutomationSummary `json:"summary"` } -// GetHash returns AutomationApprovalDepthAutomationApproval.Hash, and is useful for accessing the field via an interface. -func (v *AutomationApprovalDepthAutomationApproval) GetHash() string { return v.Hash } +// GetState returns AutomationApprovalFields.State, and is useful for accessing the field via an interface. +func (v *AutomationApprovalFields) GetState() AutomationAdoptionState { return v.State } -// GetDepth returns AutomationApprovalDepthAutomationApproval.Depth, and is useful for accessing the field via an interface. -func (v *AutomationApprovalDepthAutomationApproval) GetDepth() json.RawMessage { return v.Depth } +// GetHash returns AutomationApprovalFields.Hash, and is useful for accessing the field via an interface. +func (v *AutomationApprovalFields) GetHash() string { return v.Hash } -// GetSummary returns AutomationApprovalDepthAutomationApproval.Summary, and is useful for accessing the field via an interface. -func (v *AutomationApprovalDepthAutomationApproval) GetSummary() *AutomationApprovalDepthAutomationApprovalSummaryAutomationSummary { +// GetSummary returns AutomationApprovalFields.Summary, and is useful for accessing the field via an interface. +func (v *AutomationApprovalFields) GetSummary() *AutomationApprovalFieldsSummaryAutomationSummary { return v.Summary } -// AutomationApprovalDepthAutomationApprovalSummaryAutomationSummary includes the requested fields of the GraphQL type AutomationSummary. +// AutomationApprovalFieldsSummaryAutomationSummary includes the requested fields of the GraphQL type AutomationSummary. // The GraphQL type's documentation follows. // // #2441: plain-language facts derived from the previewed automation. A fact the pin cannot show says not stated; nothing here is a maximum the approval enforces. -type AutomationApprovalDepthAutomationApprovalSummaryAutomationSummary struct { - Name string `json:"name"` - Schedule *string `json:"schedule"` - Reads []string `json:"reads"` - Writes []string `json:"writes"` - Tools []string `json:"tools"` - Limits []string `json:"limits"` - CannotDo []string `json:"cannotDo"` - Flows []*AutomationApprovalDepthAutomationApprovalSummaryAutomationSummaryFlowsAutomationFlowSummary `json:"flows"` -} - -// GetName returns AutomationApprovalDepthAutomationApprovalSummaryAutomationSummary.Name, and is useful for accessing the field via an interface. -func (v *AutomationApprovalDepthAutomationApprovalSummaryAutomationSummary) GetName() string { - return v.Name +type AutomationApprovalFieldsSummaryAutomationSummary struct { + Name string `json:"name"` + Schedule *string `json:"schedule"` + Reads []string `json:"reads"` + Writes []string `json:"writes"` + Tools []string `json:"tools"` + Limits []string `json:"limits"` + CannotDo []string `json:"cannotDo"` + Flows []*AutomationApprovalFieldsSummaryAutomationSummaryFlowsAutomationFlowSummary `json:"flows"` } -// GetSchedule returns AutomationApprovalDepthAutomationApprovalSummaryAutomationSummary.Schedule, and is useful for accessing the field via an interface. -func (v *AutomationApprovalDepthAutomationApprovalSummaryAutomationSummary) GetSchedule() *string { - return v.Schedule -} +// GetName returns AutomationApprovalFieldsSummaryAutomationSummary.Name, and is useful for accessing the field via an interface. +func (v *AutomationApprovalFieldsSummaryAutomationSummary) GetName() string { return v.Name } -// GetReads returns AutomationApprovalDepthAutomationApprovalSummaryAutomationSummary.Reads, and is useful for accessing the field via an interface. -func (v *AutomationApprovalDepthAutomationApprovalSummaryAutomationSummary) GetReads() []string { - return v.Reads -} +// GetSchedule returns AutomationApprovalFieldsSummaryAutomationSummary.Schedule, and is useful for accessing the field via an interface. +func (v *AutomationApprovalFieldsSummaryAutomationSummary) GetSchedule() *string { return v.Schedule } -// GetWrites returns AutomationApprovalDepthAutomationApprovalSummaryAutomationSummary.Writes, and is useful for accessing the field via an interface. -func (v *AutomationApprovalDepthAutomationApprovalSummaryAutomationSummary) GetWrites() []string { - return v.Writes -} +// GetReads returns AutomationApprovalFieldsSummaryAutomationSummary.Reads, and is useful for accessing the field via an interface. +func (v *AutomationApprovalFieldsSummaryAutomationSummary) GetReads() []string { return v.Reads } -// GetTools returns AutomationApprovalDepthAutomationApprovalSummaryAutomationSummary.Tools, and is useful for accessing the field via an interface. -func (v *AutomationApprovalDepthAutomationApprovalSummaryAutomationSummary) GetTools() []string { - return v.Tools -} +// GetWrites returns AutomationApprovalFieldsSummaryAutomationSummary.Writes, and is useful for accessing the field via an interface. +func (v *AutomationApprovalFieldsSummaryAutomationSummary) GetWrites() []string { return v.Writes } -// GetLimits returns AutomationApprovalDepthAutomationApprovalSummaryAutomationSummary.Limits, and is useful for accessing the field via an interface. -func (v *AutomationApprovalDepthAutomationApprovalSummaryAutomationSummary) GetLimits() []string { - return v.Limits -} +// GetTools returns AutomationApprovalFieldsSummaryAutomationSummary.Tools, and is useful for accessing the field via an interface. +func (v *AutomationApprovalFieldsSummaryAutomationSummary) GetTools() []string { return v.Tools } -// GetCannotDo returns AutomationApprovalDepthAutomationApprovalSummaryAutomationSummary.CannotDo, and is useful for accessing the field via an interface. -func (v *AutomationApprovalDepthAutomationApprovalSummaryAutomationSummary) GetCannotDo() []string { - return v.CannotDo -} +// GetLimits returns AutomationApprovalFieldsSummaryAutomationSummary.Limits, and is useful for accessing the field via an interface. +func (v *AutomationApprovalFieldsSummaryAutomationSummary) GetLimits() []string { return v.Limits } + +// GetCannotDo returns AutomationApprovalFieldsSummaryAutomationSummary.CannotDo, and is useful for accessing the field via an interface. +func (v *AutomationApprovalFieldsSummaryAutomationSummary) GetCannotDo() []string { return v.CannotDo } -// GetFlows returns AutomationApprovalDepthAutomationApprovalSummaryAutomationSummary.Flows, and is useful for accessing the field via an interface. -func (v *AutomationApprovalDepthAutomationApprovalSummaryAutomationSummary) GetFlows() []*AutomationApprovalDepthAutomationApprovalSummaryAutomationSummaryFlowsAutomationFlowSummary { +// GetFlows returns AutomationApprovalFieldsSummaryAutomationSummary.Flows, and is useful for accessing the field via an interface. +func (v *AutomationApprovalFieldsSummaryAutomationSummary) GetFlows() []*AutomationApprovalFieldsSummaryAutomationSummaryFlowsAutomationFlowSummary { return v.Flows } -// AutomationApprovalDepthAutomationApprovalSummaryAutomationSummaryFlowsAutomationFlowSummary includes the requested fields of the GraphQL type AutomationFlowSummary. +// AutomationApprovalFieldsSummaryAutomationSummaryFlowsAutomationFlowSummary includes the requested fields of the GraphQL type AutomationFlowSummary. // The GraphQL type's documentation follows. // // #2441: one flow of an automation (the entry flow or a flow it routes to). -type AutomationApprovalDepthAutomationApprovalSummaryAutomationSummaryFlowsAutomationFlowSummary struct { +type AutomationApprovalFieldsSummaryAutomationSummaryFlowsAutomationFlowSummary struct { Name string `json:"name"` Urn string `json:"urn"` Tools []string `json:"tools"` Writes []string `json:"writes"` } -// GetName returns AutomationApprovalDepthAutomationApprovalSummaryAutomationSummaryFlowsAutomationFlowSummary.Name, and is useful for accessing the field via an interface. -func (v *AutomationApprovalDepthAutomationApprovalSummaryAutomationSummaryFlowsAutomationFlowSummary) GetName() string { +// GetName returns AutomationApprovalFieldsSummaryAutomationSummaryFlowsAutomationFlowSummary.Name, and is useful for accessing the field via an interface. +func (v *AutomationApprovalFieldsSummaryAutomationSummaryFlowsAutomationFlowSummary) GetName() string { return v.Name } -// GetUrn returns AutomationApprovalDepthAutomationApprovalSummaryAutomationSummaryFlowsAutomationFlowSummary.Urn, and is useful for accessing the field via an interface. -func (v *AutomationApprovalDepthAutomationApprovalSummaryAutomationSummaryFlowsAutomationFlowSummary) GetUrn() string { +// GetUrn returns AutomationApprovalFieldsSummaryAutomationSummaryFlowsAutomationFlowSummary.Urn, and is useful for accessing the field via an interface. +func (v *AutomationApprovalFieldsSummaryAutomationSummaryFlowsAutomationFlowSummary) GetUrn() string { return v.Urn } -// GetTools returns AutomationApprovalDepthAutomationApprovalSummaryAutomationSummaryFlowsAutomationFlowSummary.Tools, and is useful for accessing the field via an interface. -func (v *AutomationApprovalDepthAutomationApprovalSummaryAutomationSummaryFlowsAutomationFlowSummary) GetTools() []string { +// GetTools returns AutomationApprovalFieldsSummaryAutomationSummaryFlowsAutomationFlowSummary.Tools, and is useful for accessing the field via an interface. +func (v *AutomationApprovalFieldsSummaryAutomationSummaryFlowsAutomationFlowSummary) GetTools() []string { return v.Tools } -// GetWrites returns AutomationApprovalDepthAutomationApprovalSummaryAutomationSummaryFlowsAutomationFlowSummary.Writes, and is useful for accessing the field via an interface. -func (v *AutomationApprovalDepthAutomationApprovalSummaryAutomationSummaryFlowsAutomationFlowSummary) GetWrites() []string { +// GetWrites returns AutomationApprovalFieldsSummaryAutomationSummaryFlowsAutomationFlowSummary.Writes, and is useful for accessing the field via an interface. +func (v *AutomationApprovalFieldsSummaryAutomationSummaryFlowsAutomationFlowSummary) GetWrites() []string { return v.Writes } -// AutomationApprovalDepthResponse is returned by AutomationApprovalDepth on success. -type AutomationApprovalDepthResponse struct { - // #2441: state, hash, plain summary and graph depth for one trigger. MANUAL takes the origin run id. Same gate as approveAutomation. - AutomationApproval *AutomationApprovalDepthAutomationApproval `json:"automationApproval"` -} - -// GetAutomationApproval returns AutomationApprovalDepthResponse.AutomationApproval, and is useful for accessing the field via an interface. -func (v *AutomationApprovalDepthResponse) GetAutomationApproval() *AutomationApprovalDepthAutomationApproval { - return v.AutomationApproval -} - // AutomationApprovalResponse is returned by AutomationApproval on success. type AutomationApprovalResponse struct { // #2441: state, hash, plain summary and graph depth for one trigger. MANUAL takes the origin run id. Same gate as approveAutomation. @@ -61984,7 +62008,7 @@ func (v *__AutomationApprovalInput) GetTriggerRef() string { return v.TriggerRef // __AutomationApprovalsInput is used internally by genqlient type __AutomationApprovalsInput struct { AppRef string `json:"appRef"` - After *string `json:"after"` + After *string `json:"after,omitempty"` } // GetAppRef returns __AutomationApprovalsInput.AppRef, and is useful for accessing the field via an interface. @@ -69705,29 +69729,31 @@ func AuthContext( const AutomationApproval_Operation = ` query AutomationApproval ($triggerKind: AutomationTriggerKind!, $triggerRef: String!) { automationApproval(triggerKind: $triggerKind, triggerRef: $triggerRef) { - state - hash - summary { + ... AutomationApprovalFields + } +} +fragment AutomationApprovalFields on AutomationApproval { + state + hash + summary { + name + schedule + reads + writes + tools + limits + cannotDo + flows { name - schedule - reads - writes + urn tools - limits - cannotDo - flows { - name - urn - tools - writes - } + writes } } } ` -// What an adopter reviews before approving: state, the hash to approve, and the -// plain summary. Selected WITHOUT the graph depth. +// Selected WITHOUT the graph depth. func AutomationApproval( ctx_ context.Context, client_ graphql.Client, @@ -69759,23 +69785,26 @@ func AutomationApproval( const AutomationApprovalDepth_Operation = ` query AutomationApprovalDepth ($triggerKind: AutomationTriggerKind!, $triggerRef: String!) { automationApproval(triggerKind: $triggerKind, triggerRef: $triggerRef) { - state - hash + ... AutomationApprovalFields depth - summary { + } +} +fragment AutomationApprovalFields on AutomationApproval { + state + hash + summary { + name + schedule + reads + writes + tools + limits + cannotDo + flows { name - schedule - reads - writes + urn tools - limits - cannotDo - flows { - name - urn - tools - writes - } + writes } } } @@ -69830,7 +69859,8 @@ query AutomationApprovals ($appRef: String!, $after: String) { ` // One page of the adoptable schedules and webhooks of an App. `omitted` names the -// trigger kinds the list never carries (MANUAL: consent is per run). +// trigger kinds the list never carries (MANUAL: consent is per run). The first +// page omits `after` rather than sending null. func AutomationApprovals( ctx_ context.Context, client_ graphql.Client, diff --git a/internal/api/queries/automation.graphql b/internal/api/queries/automation.graphql index f76bc12b..a287f69b 100644 --- a/internal/api/queries/automation.graphql +++ b/internal/api/queries/automation.graphql @@ -4,8 +4,13 @@ # detail projection alone. # One page of the adoptable schedules and webhooks of an App. `omitted` names the -# trigger kinds the list never carries (MANUAL: consent is per run). -query AutomationApprovals($appRef: String!, $after: String) { +# trigger kinds the list never carries (MANUAL: consent is per run). The first +# page omits `after` rather than sending null. +query AutomationApprovals( + $appRef: String! + # @genqlient(omitempty: true) + $after: String +) { automationApprovals(appRef: $appRef, after: $after) { rows { triggerKind @@ -23,50 +28,39 @@ query AutomationApprovals($appRef: String!, $after: String) { } # What an adopter reviews before approving: state, the hash to approve, and the -# plain summary. Selected WITHOUT the graph depth. -query AutomationApproval($triggerKind: AutomationTriggerKind!, $triggerRef: String!) { - automationApproval(triggerKind: $triggerKind, triggerRef: $triggerRef) { - state - hash - summary { +# plain summary. +fragment AutomationApprovalFields on AutomationApproval { + state + hash + summary { + name + schedule + reads + writes + tools + limits + cannotDo + flows { name - schedule - reads - writes + urn tools - limits - cannotDo - flows { - name - urn - tools - writes - } + writes } } } +# Selected WITHOUT the graph depth. +query AutomationApproval($triggerKind: AutomationTriggerKind!, $triggerRef: String!) { + automationApproval(triggerKind: $triggerKind, triggerRef: $triggerRef) { + ...AutomationApprovalFields + } +} + # The same projection plus the graph (`depth`), for `approval --depth`. query AutomationApprovalDepth($triggerKind: AutomationTriggerKind!, $triggerRef: String!) { automationApproval(triggerKind: $triggerKind, triggerRef: $triggerRef) { - state - hash + ...AutomationApprovalFields depth - summary { - name - schedule - reads - writes - tools - limits - cannotDo - flows { - name - urn - tools - writes - } - } } } diff --git a/internal/cmd/agentic/agentic-usage.md b/internal/cmd/agentic/agentic-usage.md index a55f1cae..89077514 100644 --- a/internal/cmd/agentic/agentic-usage.md +++ b/internal/cmd/agentic/agentic-usage.md @@ -372,6 +372,7 @@ hadron server-info hadron run trigger --app --entry [--as-self] [--arg k=v]... [--ai-config ] [--wait] | list [--app | --org ] [--status ] | get | cancel --yes hadron ticker list | get | create --name --cadence-seconds [--reasons read-team-channel,every-tick,live-check] | update [--reasons ] | delete --yes | subscribe --worker | unsubscribe --worker hadron schedule create --app --name --cron '' [--tz ] --entry [--as-self] [--policy ] [--ai-config ] [--arg k=v]... | list --app | update ... | rm --yes +hadron automation list | approval ( | --run ) [--depth] | approve ( | --run ) [--yes] | decline ( | --run ) [--yes] # cli#1071: an App owner reviews and approves schedules/webhooks without a row id or hash; name is schedule/, webhook/ or a bare that exactly one trigger has; --app on every subcommand hadron webhook create --app --name --entry [--as-self] [--policy ] [--args-schema ] [--ai-config ] | rotate --yes | list --app | rm --yes hadron ticket mint --org [--app ] --action comm.outbound --count [--note ] [--expires ] | list --org hadron billing prices [--provider

] [--model ] [--tier ] [--limit N] [--offset N] | balance | ledger [--limit N] [--offset N] # spec-046 metering READS (cli#215); is an ID or hrn:org:. Money fields (inputMicroCentsPerMTok, outputMicroCentsPerMTok, balanceMicroCents, amountMicroCents) are base-10 integer STRINGS of micro-cents (1e-8 USD), signed on the ledger and unbounded, passed through byte for byte: parse with a bigint, never a float. Null markupBasisPoints = the server's default markup. prices: any signed-in caller; balance/ledger: org ADMIN, else FORBIDDEN exit 8. No --limit/--offset lists everything. No write commands (setting rates, granting credits) yet @@ -1940,6 +1941,43 @@ Conventions: member list is unreadable by you, so an empty list means "no members visible to you", NOT "no members"; a null App is exit 4, a malformed row is an error. User names and emails are withheld by the server and not selected. +- `automation list|approval|approve|decline` (`automationApprovals` / + `automationApproval` / `approveAutomation` / `declineAutomation`, + `cor:agt:010:01`, hadron-server#2441) let an App owner review and approve the + schedules and webhooks of an App with no row id and no hash. The App is `--app`, + else the active App (an explicit blank `--app ""` refuses, exit 2). A trigger is + named `schedule/`, `webhook/`, or a bare `` that exactly one + trigger of the App has; nothing is ever picked for you: no match exits 4 (one + line, no dump of other automations), several exit 2 listing only the matches. + A MANUAL origin is `--run ` (the id the refused run printed), exclusive + with a name, never guessed or listed. **`list`** follows `nextCursor` to the end + in the server's order; rows are `UNADOPTED`/`ADOPTED` only, printed "not + approved"/"approved" — **never** "up to date": CHANGED exists on `approval` + alone. `--json` is `{app, rows: [{triggerKind, triggerRef, name, state, + headline}], omitted: [{triggerKind, reason}]}`, both arrays `[]` never null; + MANUAL is always `omitted` ("Manual runs are approved from the run that needs + them"). **`approval`** prints the server's plain summary; `--depth` adds the + graph and the hash; `--json` is `{app, trigger, state, hash, summary: {name, + schedule, reads, writes, tools, limits, cannotDo, flows}, depth?}`. **`approve`** + fetches ONE projection, shows it on stderr (also under `--yes`: `--yes` skips the + prompt, never the display), confirms, and sends the hash it just showed; + `AUTOMATION_CHANGED` exits 5 and is never retried — run `approval` again. + **`decline`** sends kind and ref only (no hash: it only removes authority) and + never reads the projection, so an unreadable automation can still be declined; + the prompt says an approved automation is switched off ("its next run will fail + until it is approved again", including a newer approval someone else just gave) + and that an unapproved one only records the decline. Display and prompt go to + stderr; stdout carries only the result (`{app, trigger, status: + "approved"|"declined", hash?}` under `--json`). A "no"/EOF exits 6 and a + non-interactive call without `--yes` exits 2, both with ZERO mutations. Server + text is stripped of terminal escapes and bounded in text mode; `--json` passes + it through unchanged. Exit codes: `FORBIDDEN` (including the read-access + refusals) 8, `NOT_FOUND` 4, `AUTOMATION_CHANGED` and `AUTOMATION_UNADOPTED` 5, + bad selection 2, cancelled 6, and 7 for a malformed projection (a null required + list, empty hash, unknown state — never an empty success), a server without these + operations ("this server does not support automation approval", before any + prompt), or a write that is not confirmed (outcome unknown: check with + `automation approval`). `previewAutomation`/`reAdoptAutomation` stay unbound. - `app agent add ` / `remove ` wrap `installAgentIntoApp` / `uninstallAgentFromApp` — the AppAgent join (`cor:dmo:050:03`), and the only way to attach an Agent to an App you diff --git a/internal/cmd/app/member.go b/internal/cmd/app/member.go index 5e95acac..da9a7ec1 100644 --- a/internal/cmd/app/member.go +++ b/internal/cmd/app/member.go @@ -80,34 +80,11 @@ themselves for remove.`, return cmd } -// memberTargetApp resolves the ONE App a member command acts on. The shared -// resolver cannot be used as-is: Factory.App treats an explicit blank `--app ""` -// exactly like no flag and falls back to the active App, which would silently -// aim a membership write at an App the caller never named. An explicit blank -// refuses; an absent flag uses the active App; no App at all refuses. -// -// It also returns which branch answered ("--app" or "the active App") so the -// prompt and receipt can say so: a membership write aimed at an ambient App -// must not look like one aimed at a named one. +// memberTargetApp resolves the ONE App a member command acts on; the rule +// lives in cmdutil.TargetApp so every command that writes consent or +// membership against an App resolves it the same way. func memberTargetApp(cmd *cobra.Command, f *cmdutil.Factory) (app, source string, err error) { - explicit := false - if fl := cmd.Flag("app"); fl != nil && fl.Changed { - explicit = true - if strings.TrimSpace(f.AppFlag) == "" { - return "", "", exitcode.Newf(exitcode.Usage, "--app was given a blank value — name the App, or omit --app to use the active App") - } - } - app, err = f.App() - if err != nil { - return "", "", err - } - if app == "" { - return "", "", exitcode.Newf(exitcode.Usage, "an App is required: pass --app or select one with `hadron app use`") - } - if explicit { - return app, "from --app", nil - } - return app, "from the active App", nil + return cmdutil.TargetApp(cmd, f) } // requireMemberUser refuses a blank --user. The value itself is passed on diff --git a/internal/cmd/automation/approval.go b/internal/cmd/automation/approval.go new file mode 100644 index 00000000..a3cbd1e6 --- /dev/null +++ b/internal/cmd/automation/approval.go @@ -0,0 +1,138 @@ +package automation + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + + "github.com/spf13/cobra" + + "github.com/hadron-memory/hadron-cli/internal/cmdutil" + "github.com/hadron-memory/hadron-cli/internal/output" +) + +func printList(w io.Writer, label string, items []string) { + if len(items) == 0 { + fmt.Fprintf(w, "%s: (none)\n", label) + return + } + fmt.Fprintf(w, "%s:\n", label) + for i, it := range items { + if i == maxItems { + fmt.Fprintf(w, " … and %d more (`--json` has them all)\n", len(items)-maxItems) + break + } + fmt.Fprintf(w, " - %s\n", safeText(it, maxLine)) + } +} + +// writeProjection renders what an adopter reviews. Every server string passes +// safeText; the hash and graph appear only with the depth. +func writeProjection(w io.Writer, display string, d *approvalDTO, withDepth bool) { + fmt.Fprintf(w, "Automation %s in %s\n", display, d.App) + fmt.Fprintf(w, "State: %s\n", stateWord(d.State)) + s := d.Summary + fmt.Fprintf(w, "Name: %s\n", safeText(s.Name, maxName)) + if s.Schedule != nil { + fmt.Fprintf(w, "Schedule: %s\n", safeText(*s.Schedule, maxLine)) + } + printList(w, "Reads", s.Reads) + printList(w, "Writes", s.Writes) + printList(w, "Tools", s.Tools) + printList(w, "Limits", s.Limits) + printList(w, "Cannot do", s.CannotDo) + if len(s.Flows) == 0 { + fmt.Fprintln(w, "Flows: (none)") + } else { + fmt.Fprintln(w, "Flows:") + for i, fl := range s.Flows { + if i == maxItems { + fmt.Fprintf(w, " … and %d more (`--json` has them all)\n", len(s.Flows)-maxItems) + break + } + fmt.Fprintf(w, " - %s (%s)\n", safeText(fl.Name, maxName), safeText(fl.URN, maxLine)) + printFlowList(w, "tools", fl.Tools) + printFlowList(w, "writes", fl.Writes) + } + } + if withDepth { + fmt.Fprintf(w, "Hash: %s\n", safeText(d.Hash, maxLine)) + var pretty bytes.Buffer + if err := json.Indent(&pretty, d.Depth, "", " "); err != nil { + pretty.Reset() + pretty.Write(d.Depth) + } + fmt.Fprintf(w, "Graph:\n%s\n", safeBlock(pretty.String(), maxDepth)) + } +} + +func printFlowList(w io.Writer, label string, items []string) { + if len(items) == 0 { + return + } + shown := items + more := "" + if len(items) > maxItems { + shown, more = items[:maxItems], fmt.Sprintf(", … and %d more", len(items)-maxItems) + } + out := make([]string, len(shown)) + for i, it := range shown { + out[i] = safeText(it, maxLine) + } + fmt.Fprintf(w, " %s: %s%s\n", label, joinComma(out), more) +} + +func joinComma(s []string) string { + var b bytes.Buffer + for i, x := range s { + if i > 0 { + b.WriteString(", ") + } + b.WriteString(x) + } + return b.String() +} + +func newCmdApproval(f *cmdutil.Factory) *cobra.Command { + var run string + var depth bool + cmd := &cobra.Command{ + Use: "approval []", + Short: "Show what approving an automation would approve", + Long: `Show the plain summary of one automation (--app, else the active App): its state, +what it reads, writes and calls, and what it cannot do. Every sentence is the +server's; a fact the server cannot show says "not stated". + +--depth adds the graph and the hash. State is one of: not approved, approved, or +changed since it was approved (its next run fails until it is approved again).`, + Example: ` hadron automation approval schedule/nightly + hadron automation approval nightly --depth --json + hadron automation approval --run `, + RunE: func(cmd *cobra.Command, args []string) error { + app, _, err := cmdutil.TargetApp(cmd, f) + if err != nil { + return err + } + client, err := f.GraphQLClient() + if err != nil { + return err + } + sel, err := resolveSelection(cmd.Context(), client, app, args, run, cmd.Flags().Changed("run")) + if err != nil { + return err + } + dto, err := fetchApproval(cmd.Context(), client, app, sel.Trigger, sel.Kind, depth) + if err != nil { + return err + } + return output.Write(f.IOStreams, f.JSON, dto, func(w io.Writer) error { + writeProjection(w, sel.Display, dto, depth) + return nil + }) + }, + } + addSelectionArgs(cmd, &run) + cmd.Flags().BoolVar(&depth, "depth", false, "also show the graph and the hash") + return cmd +} diff --git a/internal/cmd/automation/automation.go b/internal/cmd/automation/automation.go new file mode 100644 index 00000000..481fa4ba --- /dev/null +++ b/internal/cmd/automation/automation.go @@ -0,0 +1,42 @@ +// Package automation implements `hadron automation ...`: the owner-facing +// approval of an App's schedules and webhooks (cli#1071, hadron-server#2441). +// +// Everything that decides — state, hash, the plain summary — is the server's. +// The CLI binds the wire names, resolves a human name to the trigger the +// server listed, shows what the server said, and sends back exactly what it +// showed. +package automation + +import ( + "github.com/spf13/cobra" + + "github.com/hadron-memory/hadron-cli/internal/cmdutil" +) + +// NewCmdAutomation builds the `automation` command group. +func NewCmdAutomation(f *cmdutil.Factory) *cobra.Command { + cmd := &cobra.Command{ + Use: "automation ", + Aliases: []string{"automations"}, + Short: "Review and approve an App's automations", + Long: `Review, approve and decline the automations (schedules and webhooks) of an App +(cor:agt:010:01), without a row id or a hash. + +The App is --app, else the active App (` + "`hadron app use`" + `). A trigger is named +` + "`schedule/`" + ` or ` + "`webhook/`" + `; a bare works when exactly one trigger of the +App has it. A manual run's approval is named by the run: --run , the id +printed by the run that was refused. Nothing is ever picked for you: no match +and several matches both refuse. + +What an automation may do, its state and its hash are all the server's answer; +this CLI infers no permission from being able to list an App, and who may +approve is the server's call. Approve shows the summary first and sends the hash +it showed; if the automation changed meanwhile the server refuses (exit 5) and +nothing is retried.`, + } + cmd.AddCommand(newCmdLs(f)) + cmd.AddCommand(newCmdApproval(f)) + cmd.AddCommand(newCmdApprove(f)) + cmd.AddCommand(newCmdDecline(f)) + return cmd +} diff --git a/internal/cmd/automation/ls.go b/internal/cmd/automation/ls.go new file mode 100644 index 00000000..5b2c0ec8 --- /dev/null +++ b/internal/cmd/automation/ls.go @@ -0,0 +1,88 @@ +package automation + +import ( + "fmt" + "io" + + "github.com/spf13/cobra" + + "github.com/hadron-memory/hadron-cli/internal/cmdutil" + "github.com/hadron-memory/hadron-cli/internal/output" +) + +// lsDTO is the stable --json shape of `automation ls`. Rows and Omitted are +// always arrays. A row's state is UNADOPTED or ADOPTED only: CHANGED exists on +// the detail projection (`approval`) alone. +type lsDTO struct { + App string `json:"app"` + Rows []rowDTO `json:"rows"` + Omitted []omittedDTO `json:"omitted"` +} + +func stateWord(state string) string { + switch state { + case "ADOPTED": + return "approved" + case "UNADOPTED": + return "not approved" + case "CHANGED": + return "changed since it was approved" + } + return safeText(state, maxName) +} + +func newCmdLs(f *cmdutil.Factory) *cobra.Command { + return &cobra.Command{ + Use: "list", + Aliases: []string{"ls"}, + Short: "List the automations of an App that you can approve", + Long: `List the schedules and webhooks of the App (--app, else the active App) that you +may approve, with their state: "approved" or "not approved". + +"approved" means approved at some point, not that nothing has changed since: +whether an approved automation has changed shows only on ` + "`hadron automation approval `" + `. +Only an adopter sees any row, so an empty list never proves the App has no +automations. Manual runs are not listed: each is approved from the run that +needs it (--run).`, + Example: ` hadron automation ls + hadron automation ls --app hrn:app:acme.com:ops --json`, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + app, _, err := cmdutil.TargetApp(cmd, f) + if err != nil { + return err + } + client, err := f.GraphQLClient() + if err != nil { + return err + } + l, err := collectListing(cmd.Context(), client, app) + if err != nil { + return err + } + dto := lsDTO{App: app, Rows: l.Rows, Omitted: l.Omitted} + return output.Write(f.IOStreams, f.JSON, dto, func(w io.Writer) error { + if len(dto.Rows) == 0 { + fmt.Fprintf(w, "No schedules or webhooks you can approve in %s.\n", app) + } else { + t := output.NewTable(w, "AUTOMATION", "STATE", "WHAT") + for _, r := range dto.Rows { + t.Row(displayName(r), stateWord(r.State), safeText(r.Headline, maxLine)) + } + if err := t.Flush(); err != nil { + return err + } + } + for _, o := range dto.Omitted { + if o.TriggerKind == "MANUAL" { + fmt.Fprintln(w, "Manual runs are approved from the run that needs them (--run ).") + } else { + fmt.Fprintf(w, "%s triggers are not listed: %s\n", safeText(o.TriggerKind, maxName), safeText(o.Reason, maxLine)) + } + } + fmt.Fprintln(w, "Approved means approved at some point; whether it changed since shows on `hadron automation approval `.") + return nil + }) + }, + } +} diff --git a/internal/cmd/automation/mutate.go b/internal/cmd/automation/mutate.go new file mode 100644 index 00000000..3404deeb --- /dev/null +++ b/internal/cmd/automation/mutate.go @@ -0,0 +1,187 @@ +package automation + +import ( + "fmt" + "io" + + "github.com/spf13/cobra" + + "github.com/hadron-memory/hadron-cli/internal/api" + "github.com/hadron-memory/hadron-cli/internal/api/gen" + "github.com/hadron-memory/hadron-cli/internal/cmdutil" + "github.com/hadron-memory/hadron-cli/internal/exitcode" + "github.com/hadron-memory/hadron-cli/internal/output" +) + +// resultDTO is the stable --json shape of approve and decline. +type resultDTO struct { + App string `json:"app"` + Trigger triggerDTO `json:"trigger"` + Status string `json:"status"` + Hash string `json:"hash,omitempty"` +} + +// approvalHint is the copy-pasteable re-read after a refusal. +func approvalHint(sel *selection, app string) string { + target := "--run " + shellQuote(sel.Trigger.Ref) + if sel.Trigger.Name != "" { + target = shellQuote(kindWord(sel.Kind) + "/" + sel.Trigger.Name) + } + return "`hadron automation approval " + target + " --app " + shellQuote(app) + "`" +} + +// mutationError classifies a failed approve or decline. `partial` is true when +// the response carried a true result next to the error: the write may have +// committed, so it is outcome UNKNOWN (7) and never retried. The server's +// refusals keep its wording and the exit code api.MapError gives them. +func mutationError(err error, partial bool, verb string, sel *selection, app string) error { + if partial { + return exitcode.Newf(exitcode.Unavailable, + "outcome unknown: %s. The %s may or may not have been applied and was not retried — run %s to check", + api.MapError(err), verb, approvalHint(sel, app)) + } + if api.IsSchemaSkew(err) { + return exitcode.Newf(exitcode.Unavailable, "%s", skewMessage) + } + if verb == "approval" && api.HasErrorCode(err, "AUTOMATION_CHANGED") { + return exitcode.Newf(exitcode.Conflict, + "the automation changed since you looked, so nothing was approved and nothing was retried: run %s and approve again", + approvalHint(sel, app)) + } + return api.MapError(err) +} + +func notConfirmed(verb string, sel *selection, app string) error { + return exitcode.Newf(exitcode.Unavailable, + "outcome unknown: the server did not confirm the %s. Run %s to check", verb, approvalHint(sel, app)) +} + +func newCmdApprove(f *cmdutil.Factory) *cobra.Command { + var run string + var yes bool + cmd := &cobra.Command{ + Use: "approve []", + Short: "Approve an automation as shown", + Long: `Approve one automation (--app, else the active App). The summary is shown first +(on stderr), you confirm, and the hash just shown is what is sent. If the +automation changed in the meantime the server refuses it (exit 5): nothing is +retried, and the next step is ` + "`hadron automation approval `" + ` again. + +--yes skips the prompt, never the display. Without --yes a non-interactive +caller is refused before anything is sent; "no" or end-of-input exits 6. +Display and prompt go to stderr, so stdout carries only the result.`, + Example: ` hadron automation approve schedule/nightly + hadron automation approve nightly --yes --json + hadron automation approve --run --yes`, + RunE: func(cmd *cobra.Command, args []string) error { + app, source, err := cmdutil.TargetApp(cmd, f) + if err != nil { + return err + } + client, err := f.GraphQLClient() + if err != nil { + return err + } + sel, err := resolveSelection(cmd.Context(), client, app, args, run, cmd.Flags().Changed("run")) + if err != nil { + return err + } + dto, err := fetchApproval(cmd.Context(), client, app, sel.Trigger, sel.Kind, false) + if err != nil { + return err + } + writeProjection(f.IOStreams.ErrOut, sel.Display, dto, false) + prompt := fmt.Sprintf("Approve %s in %s (%s) as shown above?", sel.Display, app, source) + if dto.State == string(gen.AutomationAdoptionStateAdopted) { + prompt = fmt.Sprintf("Approve %s in %s (%s) again as shown above? (It is already approved; this records another approval.)", sel.Display, app, source) + } + if err := cmdutil.Confirm(f.IOStreams, yes, prompt); err != nil { + return err + } + resp, err := gen.ApproveAutomation(cmd.Context(), client, sel.Kind, sel.Trigger.Ref, dto.Hash) + if err != nil { + return mutationError(err, resp != nil && resp.ApproveAutomation, "approval", sel, app) + } + if resp == nil || !resp.ApproveAutomation { + return notConfirmed("approval", sel, app) + } + res := resultDTO{App: app, Trigger: sel.Trigger, Status: "approved", Hash: dto.Hash} + return output.Write(f.IOStreams, f.JSON, res, func(w io.Writer) error { + _, err := fmt.Fprintf(w, "✓ approved %s in %s\n", sel.Display, app) + return err + }) + }, + } + addSelectionArgs(cmd, &run) + cmd.Flags().BoolVarP(&yes, "yes", "y", false, "skip the confirmation prompt (the summary is still shown)") + return cmd +} + +func declineWarning(sel *selection) string { + switch { + case sel.Kind == gen.AutomationTriggerKindManual: + return "Declining withdraws the approval for this manual run: anything that later uses this run's approval is refused until it is approved again. Runs already started keep what they were approved with." + case sel.State == string(gen.AutomationAdoptionStateAdopted): + return "Declining switches this automation off: its next run will fail until it is approved again. This revokes the current approval, including a newer approval another person may have just given. Runs already started keep what they were approved with." + default: + return "This automation is not approved. Declining records your decline and changes nothing else." + } +} + +func newCmdDecline(f *cmdutil.Factory) *cobra.Command { + var run string + var yes bool + cmd := &cobra.Command{ + Use: "decline []", + Short: "Decline (or switch off) an automation", + Long: `Decline one automation (--app, else the active App). On an approved automation this +REVOKES the approval — its next run fails until it is approved again — including +a newer approval someone else has just given: decline carries no hash, because it +only ever removes authority. On an automation that is not approved it only +records the decline. The confirmation says which. + +Decline never needs the summary, so an automation you cannot read can still be +declined. --yes skips the prompt; without it a non-interactive caller is refused +before anything is sent, and "no" or end-of-input exits 6.`, + Example: ` hadron automation decline schedule/nightly + hadron automation decline --run --yes`, + RunE: func(cmd *cobra.Command, args []string) error { + app, source, err := cmdutil.TargetApp(cmd, f) + if err != nil { + return err + } + client, err := f.GraphQLClient() + if err != nil { + return err + } + sel, err := resolveSelection(cmd.Context(), client, app, args, run, cmd.Flags().Changed("run")) + if err != nil { + return err + } + if sel.Kind == gen.AutomationTriggerKindManual { + if err := requireSupport(cmd.Context(), client, app); err != nil { + return err + } + } + fmt.Fprintf(f.IOStreams.ErrOut, "Automation %s in %s\n%s\n", sel.Display, app, declineWarning(sel)) + if err := cmdutil.Confirm(f.IOStreams, yes, fmt.Sprintf("Decline %s in %s (%s)?", sel.Display, app, source)); err != nil { + return err + } + resp, err := gen.DeclineAutomation(cmd.Context(), client, sel.Kind, sel.Trigger.Ref) + if err != nil { + return mutationError(err, resp != nil && resp.DeclineAutomation, "decline", sel, app) + } + if resp == nil || !resp.DeclineAutomation { + return notConfirmed("decline", sel, app) + } + res := resultDTO{App: app, Trigger: sel.Trigger, Status: "declined"} + return output.Write(f.IOStreams, f.JSON, res, func(w io.Writer) error { + _, err := fmt.Fprintf(w, "✓ declined %s in %s\n", sel.Display, app) + return err + }) + }, + } + addSelectionArgs(cmd, &run) + cmd.Flags().BoolVarP(&yes, "yes", "y", false, "skip the confirmation prompt") + return cmd +} diff --git a/internal/cmd/automation/resolve.go b/internal/cmd/automation/resolve.go new file mode 100644 index 00000000..79259c47 --- /dev/null +++ b/internal/cmd/automation/resolve.go @@ -0,0 +1,120 @@ +package automation + +import ( + "context" + "strconv" + "strings" + + "github.com/Khan/genqlient/graphql" + "github.com/spf13/cobra" + + "github.com/hadron-memory/hadron-cli/internal/api/gen" + "github.com/hadron-memory/hadron-cli/internal/exitcode" +) + +// selection is the one trigger a command acts on. +type selection struct { + Kind gen.AutomationTriggerKind + Trigger triggerDTO + Display string // what a person reads: schedule/nightly, or "the manual run " + State string // the list's state for a listed trigger; "" for a MANUAL origin +} + +const selectHelp = "name an automation (`schedule/`, `webhook/` or a bare ) or pass --run for a manual run's approval" + +// addSelectionArgs wires the optional positional and --run. +func addSelectionArgs(cmd *cobra.Command, run *string) { + cmd.Args = cobra.MaximumNArgs(1) + cmd.Flags().StringVar(run, "run", "", "the manual run whose approval this is (the run id printed by the refused run); instead of a name") +} + +// resolveSelection turns what the person typed into one listed trigger. A +// is matched against the App's whole list; nothing is guessed: no match +// refuses (4), several refuse (2) listing only the matches. A MANUAL origin is +// the run id exactly as given — never looked up, listed or substituted. +func resolveSelection(ctx context.Context, client graphql.Client, app string, args []string, run string, runSet bool) (*selection, error) { + name := "" + if len(args) > 0 { + name = strings.TrimSpace(args[0]) + } + // An explicit blank --run is not an absent one: it would silently fall back + // to the name (or to the "no name" refusal), so it refuses on its own. + if runSet && strings.TrimSpace(run) == "" { + return nil, exitcode.Newf(exitcode.Usage, "--run was given a blank value — pass the run id, or omit --run and name an automation") + } + hasRun := strings.TrimSpace(run) != "" + switch { + case name != "" && hasRun: + return nil, exitcode.Newf(exitcode.Usage, "give an automation name or --run, not both: %s", selectHelp) + case name == "" && !hasRun: + return nil, exitcode.Newf(exitcode.Usage, "no automation named: %s", selectHelp) + } + if hasRun { + run = strings.TrimSpace(run) + return &selection{ + Kind: gen.AutomationTriggerKindManual, + Trigger: triggerDTO{Kind: string(gen.AutomationTriggerKindManual), Ref: run}, + Display: "the manual run " + safeText(run, maxName), + }, nil + } + + l, err := collectListing(ctx, client, app) + if err != nil { + return nil, err + } + kind, bare := splitQualified(name) + var matches []rowDTO + for _, r := range l.Rows { + if r.Name != bare || (kind != "" && !strings.EqualFold(r.TriggerKind, kind)) { + continue + } + matches = append(matches, r) + } + switch len(matches) { + case 0: + return nil, exitcode.Newf(exitcode.NotFound, "no automation named %s in %s; run `hadron automation ls`", strconv.Quote(safeText(name, maxName)), app) + case 1: + r := matches[0] + return &selection{ + Kind: gen.AutomationTriggerKind(r.TriggerKind), + Trigger: triggerDTO{Kind: r.TriggerKind, Ref: r.TriggerRef, Name: r.Name}, + Display: displayName(r), + State: r.State, + }, nil + } + var b strings.Builder + b.WriteString("several automations match " + strconv.Quote(safeText(name, maxName)) + " in " + app + ":\n") + sameKind := false + seen := map[string]bool{} + for _, m := range matches { + b.WriteString(" " + displayName(m) + " — " + safeText(m.Headline, maxLine) + "\n") + if seen[m.TriggerKind] { + sameKind = true + } + seen[m.TriggerKind] = true + } + if sameKind { + b.WriteString("two of them share a kind and a name, so a name cannot tell them apart: rename one, then retry") + } else { + b.WriteString("name one of them with its kind, e.g. " + displayName(matches[0])) + } + return nil, exitcode.Newf(exitcode.Usage, "%s", b.String()) +} + +// splitQualified splits `schedule/` or `webhook/` (kind +// case-insensitive). Anything else is a bare name, slashes included. +func splitQualified(name string) (kind, bare string) { + if i := strings.IndexByte(name, '/'); i > 0 { + switch strings.ToLower(name[:i]) { + case "schedule": + return string(gen.AutomationTriggerKindSchedule), name[i+1:] + case "webhook": + return string(gen.AutomationTriggerKindWebhook), name[i+1:] + } + } + return "", name +} + +func displayName(r rowDTO) string { + return strings.ToLower(r.TriggerKind) + "/" + safeText(r.Name, maxName) +} diff --git a/internal/cmd/automation/server.go b/internal/cmd/automation/server.go new file mode 100644 index 00000000..ae136876 --- /dev/null +++ b/internal/cmd/automation/server.go @@ -0,0 +1,236 @@ +package automation + +import ( + "context" + "encoding/json" + "fmt" + "strings" + + "github.com/Khan/genqlient/graphql" + + "github.com/hadron-memory/hadron-cli/internal/api" + "github.com/hadron-memory/hadron-cli/internal/api/gen" + "github.com/hadron-memory/hadron-cli/internal/exitcode" +) + +// maxPages bounds a list walk. A server that keeps handing out a next cursor is +// a server fault, not a reason to loop forever. +const maxPages = 200 + +// triggerDTO names a trigger on the wire: the kind and the ref the server +// listed (or the run id of a MANUAL origin). Name is the human name when there +// is one. +type triggerDTO struct { + Kind string `json:"kind"` + Ref string `json:"ref"` + Name string `json:"name,omitempty"` +} + +type rowDTO struct { + TriggerKind string `json:"triggerKind"` + TriggerRef string `json:"triggerRef"` + Name string `json:"name"` + State string `json:"state"` + Headline string `json:"headline"` +} + +type omittedDTO struct { + TriggerKind string `json:"triggerKind"` + Reason string `json:"reason"` +} + +type flowDTO struct { + Name string `json:"name"` + URN string `json:"urn"` + Tools []string `json:"tools"` + Writes []string `json:"writes"` +} + +type summaryDTO struct { + Name string `json:"name"` + Schedule *string `json:"schedule"` + Reads []string `json:"reads"` + Writes []string `json:"writes"` + Tools []string `json:"tools"` + Limits []string `json:"limits"` + CannotDo []string `json:"cannotDo"` + Flows []flowDTO `json:"flows"` +} + +// approvalDTO is the typed projection of one trigger. Depth is present only +// when it was asked for. +type approvalDTO struct { + App string `json:"app"` + Trigger triggerDTO `json:"trigger"` + State string `json:"state"` + Hash string `json:"hash"` + Summary summaryDTO `json:"summary"` + Depth json.RawMessage `json:"depth,omitempty"` +} + +// unavailable is the one refusal for a server answer this CLI cannot trust: it +// is never turned into an empty or successful result, and nothing was changed. +func unavailable(what string) error { + return exitcode.Newf(exitcode.Unavailable, "unavailable: the server's answer was malformed (%s); nothing was changed", what) +} + +const skewMessage = "this server does not support automation approval (hadron-server#2441): the CLI and server are out of sync — " + + "update whichever is behind, or the server if it is self-hosted" + +// readError classifies a failed READ (list or projection). Schema skew is its +// own sentence and exit 7, ahead of anything prompting; the rest is MapError's. +func readError(err error) error { + if api.IsSchemaSkew(err) { + return exitcode.Newf(exitcode.Unavailable, "%s", skewMessage) + } + return api.MapError(err) +} + +func kindWord(k gen.AutomationTriggerKind) string { return strings.ToLower(string(k)) } + +func validListKind(k gen.AutomationTriggerKind) bool { + return k == gen.AutomationTriggerKindSchedule || k == gen.AutomationTriggerKindWebhook +} + +// listing is every row of an App's list plus what the server said it leaves out. +type listing struct { + Rows []rowDTO + Omitted []omittedDTO +} + +// collectListing walks the list to exhaustion, in the server's order. Any page +// the CLI cannot validate fails the whole walk: a partial list would let a name +// resolve against rows that are not all there. +func collectListing(ctx context.Context, client graphql.Client, appRef string) (*listing, error) { + out := &listing{Rows: []rowDTO{}, Omitted: []omittedDTO{}} + seenOmitted := map[omittedDTO]bool{} + seenCursor := map[string]bool{} + var after *string + for page := 0; page < maxPages; page++ { + resp, err := gen.AutomationApprovals(ctx, client, appRef, after) + if err != nil { + return nil, readError(err) + } + if resp == nil || resp.AutomationApprovals == nil { + return nil, unavailable("no list page") + } + p := resp.AutomationApprovals + if p.Rows == nil || p.Omitted == nil { + return nil, unavailable("the page's rows or omitted list is null") + } + for _, r := range p.Rows { + if r == nil || !validListKind(r.TriggerKind) || r.TriggerRef == "" || r.Name == "" || + (r.State != gen.AutomationAdoptionStateAdopted && r.State != gen.AutomationAdoptionStateUnadopted) { + return nil, unavailable("a list row is missing its kind, ref, name or state") + } + out.Rows = append(out.Rows, rowDTO{string(r.TriggerKind), r.TriggerRef, r.Name, string(r.State), r.Headline}) + } + for _, o := range p.Omitted { + if o == nil { + return nil, unavailable("an omitted entry is null") + } + d := omittedDTO{string(o.TriggerKind), o.Reason} + if !seenOmitted[d] { + seenOmitted[d] = true + out.Omitted = append(out.Omitted, d) + } + } + if p.NextCursor == nil || *p.NextCursor == "" { + return out, nil + } + if seenCursor[*p.NextCursor] { + return nil, unavailable("the next-page cursor repeated") + } + seenCursor[*p.NextCursor] = true + after = p.NextCursor + } + return nil, unavailable("the list did not end") +} + +// validateProjection refuses a projection missing anything the CLI would show +// or send. Required lists must be present (a null list is not an empty one). +func validateProjection(f *gen.AutomationApprovalFields) error { + if f == nil || f.Summary == nil { + return unavailable("no summary") + } + if f.Hash == "" { + return unavailable("no hash") + } + switch f.State { + case gen.AutomationAdoptionStateUnadopted, gen.AutomationAdoptionStateAdopted, gen.AutomationAdoptionStateChanged: + default: + return unavailable(fmt.Sprintf("unknown state %q", string(f.State))) + } + s := f.Summary + if s.Reads == nil || s.Writes == nil || s.Tools == nil || s.Limits == nil || s.CannotDo == nil || s.Flows == nil { + return unavailable("a required list in the summary is null") + } + for _, fl := range s.Flows { + if fl == nil || fl.Tools == nil || fl.Writes == nil { + return unavailable("a flow is null or has a null list") + } + } + return nil +} + +func projectionDTO(app string, t triggerDTO, f *gen.AutomationApprovalFields) approvalDTO { + s := f.Summary + flows := make([]flowDTO, 0, len(s.Flows)) + for _, fl := range s.Flows { + flows = append(flows, flowDTO{Name: fl.Name, URN: fl.Urn, Tools: fl.Tools, Writes: fl.Writes}) + } + return approvalDTO{ + App: app, Trigger: t, State: string(f.State), Hash: f.Hash, + Summary: summaryDTO{Name: s.Name, Schedule: s.Schedule, Reads: s.Reads, Writes: s.Writes, + Tools: s.Tools, Limits: s.Limits, CannotDo: s.CannotDo, Flows: flows}, + } +} + +// fetchApproval reads the projection of one trigger, with the graph depth only +// when asked. It validates before returning, so a caller never prompts over a +// projection it cannot trust. +func fetchApproval(ctx context.Context, client graphql.Client, app string, t triggerDTO, kind gen.AutomationTriggerKind, withDepth bool) (*approvalDTO, error) { + if withDepth { + resp, err := gen.AutomationApprovalDepth(ctx, client, kind, t.Ref) + if err != nil { + return nil, readError(err) + } + if resp == nil || resp.AutomationApproval == nil { + return nil, unavailable("no projection") + } + if err := validateProjection(&resp.AutomationApproval.AutomationApprovalFields); err != nil { + return nil, err + } + depth := resp.AutomationApproval.Depth + if len(depth) == 0 || string(depth) == "null" || !json.Valid(depth) { + return nil, unavailable("no graph depth") + } + dto := projectionDTO(app, t, &resp.AutomationApproval.AutomationApprovalFields) + dto.Depth = depth + return &dto, nil + } + resp, err := gen.AutomationApproval(ctx, client, kind, t.Ref) + if err != nil { + return nil, readError(err) + } + if resp == nil || resp.AutomationApproval == nil { + return nil, unavailable("no projection") + } + if err := validateProjection(&resp.AutomationApproval.AutomationApprovalFields); err != nil { + return nil, err + } + dto := projectionDTO(app, t, &resp.AutomationApproval.AutomationApprovalFields) + return &dto, nil +} + +// requireSupport asks the server for the first list page only to learn whether +// it has the approval operations at all. Only schema skew is acted on — any +// other answer (a refusal, an empty page) says nothing about support and is +// ignored. A command that has nothing to read before its write (decline of a +// manual run) uses this so an old server is refused before the prompt. +func requireSupport(ctx context.Context, client graphql.Client, appRef string) error { + if _, err := gen.AutomationApprovals(ctx, client, appRef, nil); err != nil && api.IsSchemaSkew(err) { + return readError(err) + } + return nil +} diff --git a/internal/cmd/automation/text.go b/internal/cmd/automation/text.go new file mode 100644 index 00000000..06c01cb5 --- /dev/null +++ b/internal/cmd/automation/text.go @@ -0,0 +1,80 @@ +package automation + +import ( + "regexp" + "strings" + "unicode" +) + +// Server text — summary sentences, trigger and flow names, headlines — is +// authored by other people (graph content), so it is untrusted: a terminal +// escape in it must not run, and a huge value must not flood the screen. +// Text mode strips and bounds every server string it prints; --json passes the +// server's value through unchanged. +const ( + maxName = 120 // trigger and flow names + maxLine = 300 // one sentence or headline + maxItems = 25 // entries printed from one list before "and N more" + maxDepth = 8000 +) + +var ( + // OSC (ESC ] … BEL|ST), CSI (ESC [ … final byte) and any other ESC + one + // character. Removed whole, so no parameter bytes are left behind as text. + reOSC = regexp.MustCompile(`\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)?`) + reCSI = regexp.MustCompile(`\x1b\[[0-?]*[ -/]*[@-~]?`) + reESC = regexp.MustCompile(`\x1b.?`) +) + +// safeText returns s with terminal escape sequences removed, every remaining +// control character (newline, tab, NUL, C1…) turned into a space, and the result +// cut to max runes with an ellipsis. +func safeText(s string, max int) string { + s = reOSC.ReplaceAllString(s, "") + s = reCSI.ReplaceAllString(s, "") + s = reESC.ReplaceAllString(s, "") + var b strings.Builder + n := 0 + for _, r := range s { + if unicode.IsControl(r) { + r = ' ' + } + if n == max { + b.WriteString("…") + return b.String() + } + b.WriteRune(r) + n++ + } + return b.String() +} + +// safeBlock is safeText for a multi-line value (the pretty-printed graph): +// newlines survive, every other control character and escape does not. +func safeBlock(s string, max int) string { + s = reOSC.ReplaceAllString(s, "") + s = reCSI.ReplaceAllString(s, "") + s = reESC.ReplaceAllString(s, "") + var b strings.Builder + n := 0 + for _, r := range s { + if r != '\n' && unicode.IsControl(r) { + r = ' ' + } + if n == max { + b.WriteString("…\n(truncated — `--json` carries the whole graph)") + return b.String() + } + b.WriteRune(r) + n++ + } + return b.String() +} + +// shellQuote quotes s for a copy-pasteable command in an error hint. +func shellQuote(s string) string { + if s != "" && strings.Trim(s, "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789:._/@-") == "" { + return s + } + return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'" +} diff --git a/internal/cmd/automation_cmd_test.go b/internal/cmd/automation_cmd_test.go index 27860471..fc49df05 100644 --- a/internal/cmd/automation_cmd_test.go +++ b/internal/cmd/automation_cmd_test.go @@ -91,8 +91,14 @@ func fixed(body string) func(map[string]any) autoReply { return func(map[string]any) autoReply { return autoReply{body: body} } } +// jq quotes s as a JSON string (Go's %q is not JSON for control characters). +func jq(s string) string { + b, _ := json.Marshal(s) + return string(b) +} + func autoRow(kind, ref, name, state, headline string) string { - return fmt.Sprintf(`{"triggerKind":%q,"triggerRef":%q,"name":%q,"state":%q,"headline":%q}`, kind, ref, name, state, headline) + return fmt.Sprintf(`{"triggerKind":%s,"triggerRef":%s,"name":%s,"state":%s,"headline":%s}`, jq(kind), jq(ref), jq(name), jq(state), jq(headline)) } func autoPage(rows []string, next string, omitted bool) string { @@ -107,21 +113,26 @@ func autoPage(rows []string, next string, omitted bool) string { return fmt.Sprintf(`{"data":{"automationApprovals":{"rows":[%s],"nextCursor":%s,"omitted":%s}}}`, strings.Join(rows, ","), cur, om) } -func autoDetail(state, hash, name string) string { - return fmt.Sprintf(`{"data":{"automationApproval":{"state":%q,"hash":%q,"summary":{"name":%q,"schedule":"every night at 02:00", +func autoDetail(state string) string { + return fmt.Sprintf(`{"data":{"automationApproval":{"state":%q,"hash":%q,"summary":{"name":"nightly","schedule":"every night at 02:00", "reads":["what the App can read in this App"],"writes":["a new node runs: beside the entry"], "tools":["hadron_find_nodes"],"limits":["set by the platform at run start"],"cannotDo":["never a private memory"], - "flows":[{"name":"main","urn":"hrn:node:acme.com:ops:flows:main","tools":["hadron_find_nodes"],"writes":[]}]}}}}`, state, hash, name) + "flows":[{"name":"main","urn":"hrn:node:acme.com:ops:flows:main","tools":["hadron_find_nodes"],"writes":[]}]}}}}`, state, autoHash) } const autoHash = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" func oneRowServer(t *testing.T, state string, extra map[string]func(map[string]any) autoReply) *autoServer { t.Helper() + // The list never carries CHANGED (it exists on the detail projection only). + listState := state + if listState == "CHANGED" { + listState = "ADOPTED" + } rep := map[string]func(map[string]any) autoReply{ - "AutomationApprovals": fixed(autoPage([]string{autoRow("SCHEDULE", "sch1", "nightly", state, "Runs nightly")}, "", true)), - "AutomationApproval": fixed(autoDetail(state, autoHash, "nightly")), - "AutomationApprovalDepth": fixed(strings.Replace(autoDetail(state, autoHash, "nightly"), `"state":`, `"depth":{"nodes":[{"loc":"flows:main"}]},"state":`, 1)), + "AutomationApprovals": fixed(autoPage([]string{autoRow("SCHEDULE", "sch1", "nightly", listState, "Runs nightly")}, "", true)), + "AutomationApproval": fixed(autoDetail(state)), + "AutomationApprovalDepth": fixed(strings.Replace(autoDetail(state), `"state":`, `"depth":{"nodes":[{"loc":"flows:main"}]},"state":`, 1)), "ApproveAutomation": fixed(`{"data":{"approveAutomation":true}}`), "DeclineAutomation": fixed(`{"data":{"declineAutomation":true}}`), } @@ -344,6 +355,15 @@ func TestAutomationSelection(t *testing.T) { t.Errorf("a bad selection must send nothing") } }) + t.Run("an explicit blank --run refuses, with or without a name", func(t *testing.T) { + for _, args := range [][]string{{"approve", "--run", "", "--yes"}, {"approve", "nightly", "--run", " ", "--yes"}, {"decline", "--run=", "--yes"}, {"approval", "nightly", "--run", ""}} { + s := srv(t) + r := runAuto(t, s, false, "", args...) + if r.code != exitcode.Usage || s.count("ApproveAutomation")+s.count("DeclineAutomation")+s.count("AutomationApproval")+s.count("AutomationApprovals") != 0 { + t.Errorf("%v: want exit 2 and nothing sent, got %d (%v)", args, r.code, r.err) + } + } + }) t.Run("--run names a MANUAL origin without listing", func(t *testing.T) { s := srv(t) r := runAuto(t, s, false, "", "approve", "--run", "run-123", "--yes") @@ -565,14 +585,14 @@ func TestAutomationApprovalDisplay(t *testing.T) { func TestAutomationServerTextIsTerminalSafeAndBounded(t *testing.T) { evil := "evil\x1b[31mred\x1b]0;owned\x07\nsecond line\x00end" long := strings.Repeat("x", 5000) - detail := fmt.Sprintf(`{"data":{"automationApproval":{"state":"UNADOPTED","hash":%q,"summary":{"name":%q,"schedule":null, - "reads":[%q],"writes":[],"tools":[],"limits":[],"cannotDo":[],"flows":[]}}}}`, autoHash, evil, long) + detail := fmt.Sprintf(`{"data":{"automationApproval":{"state":"UNADOPTED","hash":%s,"summary":{"name":%s,"schedule":null, + "reads":[%s],"writes":[],"tools":[],"limits":[],"cannotDo":[],"flows":[]}}}}`, jq(autoHash), jq(evil), jq(long)) s := oneRowServer(t, "UNADOPTED", map[string]func(map[string]any) autoReply{ "AutomationApproval": fixed(detail), "AutomationApprovals": fixed(autoPage([]string{ autoRow("SCHEDULE", "s1", evil, "UNADOPTED", evil)}, "", true)), }) - for _, args := range [][]string{{"approval", "evil"}, {"ls"}, {"approve", "evil"}} { + for _, args := range [][]string{{"approval", evil}, {"ls"}, {"approve", evil}} { r := runAuto(t, s, true, "n\n", args...) all := r.out + r.errOut for _, bad := range []string{"\x1b", "\x07", "\x00"} { @@ -585,26 +605,26 @@ func TestAutomationServerTextIsTerminalSafeAndBounded(t *testing.T) { } } // --json passes the server's strings through unchanged. - r := runAuto(t, s, false, "", "approval", "evil", "--json") + r := runAuto(t, s, false, "", "approval", evil, "--json") var got struct { Summary struct { Name string `json:"name"` } `json:"summary"` } if err := json.Unmarshal([]byte(r.out), &got); err != nil || got.Summary.Name != evil { - t.Errorf("--json must not alter server text: %v %q", err, got.Summary.Name) + t.Errorf("--json must not alter server text: %v %q (cmd err %v)", err, got.Summary.Name, r.err) } } func TestAutomationMalformedProjectionIsUnavailable(t *testing.T) { cases := map[string]string{ - "empty hash": strings.Replace(autoDetail("UNADOPTED", autoHash, "nightly"), autoHash, "", 1), - "unknown state": autoDetail("MAYBE", autoHash, "nightly"), - "null reads": strings.Replace(autoDetail("UNADOPTED", autoHash, "nightly"), `"reads":["what the App can read in this App"]`, `"reads":null`, 1), + "empty hash": strings.Replace(autoDetail("UNADOPTED"), autoHash, "", 1), + "unknown state": autoDetail("MAYBE"), + "null reads": strings.Replace(autoDetail("UNADOPTED"), `"reads":["what the App can read in this App"]`, `"reads":null`, 1), "null summary": fmt.Sprintf(`{"data":{"automationApproval":{"state":"UNADOPTED","hash":%q,"summary":null}}}`, autoHash), - "null cannotDo": strings.Replace(autoDetail("UNADOPTED", autoHash, "nightly"), `"cannotDo":["never a private memory"]`, `"cannotDo":null`, 1), - "null flows": strings.Replace(autoDetail("UNADOPTED", autoHash, "nightly"), `"flows":[{"name":"main","urn":"hrn:node:acme.com:ops:flows:main","tools":["hadron_find_nodes"],"writes":[]}]`, `"flows":null`, 1), - "null flow writes": strings.Replace(autoDetail("UNADOPTED", autoHash, "nightly"), `"writes":[]}]`, `"writes":null}]`, 1), + "null cannotDo": strings.Replace(autoDetail("UNADOPTED"), `"cannotDo":["never a private memory"]`, `"cannotDo":null`, 1), + "null flows": strings.Replace(autoDetail("UNADOPTED"), `"flows":[{"name":"main","urn":"hrn:node:acme.com:ops:flows:main","tools":["hadron_find_nodes"],"writes":[]}]`, `"flows":null`, 1), + "null flow writes": strings.Replace(autoDetail("UNADOPTED"), `"writes":[]}]`, `"writes":null}]`, 1), "data null no err": `{"data":null}`, "approval null": `{"data":{"automationApproval":null}}`, "empty object body": `{"data":{}}`, @@ -613,7 +633,11 @@ func TestAutomationMalformedProjectionIsUnavailable(t *testing.T) { for _, verb := range []string{"approval", "approve"} { t.Run(name+"/"+verb, func(t *testing.T) { s := oneRowServer(t, "UNADOPTED", map[string]func(map[string]any) autoReply{"AutomationApproval": fixed(body)}) - r := runAuto(t, s, false, "", verb, "nightly", "--yes") + args := []string{verb, "nightly"} + if verb == "approve" { + args = append(args, "--yes") + } + r := runAuto(t, s, false, "", args...) if r.code != exitcode.Unavailable || s.count("ApproveAutomation") != 0 || r.out != "" { t.Fatalf("want exit 7, nothing approved, empty stdout: code=%d err=%v out=%q", r.code, r.err, r.out) } diff --git a/internal/cmd/root.go b/internal/cmd/root.go index f0e37201..e7f5cc60 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -22,6 +22,7 @@ import ( appcmd "github.com/hadron-memory/hadron-cli/internal/cmd/app" assetcmd "github.com/hadron-memory/hadron-cli/internal/cmd/asset" authcmd "github.com/hadron-memory/hadron-cli/internal/cmd/auth" + automationcmd "github.com/hadron-memory/hadron-cli/internal/cmd/automation" billingcmd "github.com/hadron-memory/hadron-cli/internal/cmd/billing" calendarcmd "github.com/hadron-memory/hadron-cli/internal/cmd/calendar" channelcmd "github.com/hadron-memory/hadron-cli/internal/cmd/channel" @@ -123,6 +124,7 @@ Supporting servers record the worker and agent alongside the authenticated user. root.AddCommand(skillcmd.NewCmdSkill(f)) root.AddCommand(codingcmd.NewCmdCoding(f)) root.AddCommand(appcmd.NewCmdApp(f)) + root.AddCommand(automationcmd.NewCmdAutomation(f)) root.AddCommand(orgcmd.NewCmdOrg(f)) root.AddCommand(scopecmd.NewCmdScope(f)) root.AddCommand(agentcmd.NewCmdAgent(f)) diff --git a/internal/cmdutil/targetapp.go b/internal/cmdutil/targetapp.go new file mode 100644 index 00000000..1d5bb2dc --- /dev/null +++ b/internal/cmdutil/targetapp.go @@ -0,0 +1,40 @@ +package cmdutil + +import ( + "strings" + + "github.com/spf13/cobra" + + "github.com/hadron-memory/hadron-cli/internal/exitcode" +) + +// TargetApp resolves the ONE App a command that writes against an App acts on +// (app member, automation approval). The shared resolver cannot be used as-is: +// Factory.App treats an explicit blank `--app ""` exactly like no flag and +// falls back to the active App, which would silently aim a write at an App the +// caller never named. An explicit blank refuses; an absent flag uses the active +// App; no App at all refuses. +// +// It also returns which branch answered ("from --app" or "from the active App") +// so the prompt and receipt can say so: a write aimed at an ambient App must +// not look like one aimed at a named one. +func TargetApp(cmd *cobra.Command, f *Factory) (app, source string, err error) { + explicit := false + if fl := cmd.Flag("app"); fl != nil && fl.Changed { + explicit = true + if strings.TrimSpace(f.AppFlag) == "" { + return "", "", exitcode.Newf(exitcode.Usage, "--app was given a blank value — name the App, or omit --app to use the active App") + } + } + app, err = f.App() + if err != nil { + return "", "", err + } + if app == "" { + return "", "", exitcode.Newf(exitcode.Usage, "an App is required: pass --app or select one with `hadron app use`") + } + if explicit { + return app, "from --app", nil + } + return app, "from the active App", nil +} From aace13789be4130660567114c88a5e4b8f768b72 Mon Sep 17 00:00:00 2001 From: Holger Selover-Stephan Date: Sun, 25 Oct 2026 18:49:17 +0000 Subject: [PATCH 4/7] Red pins for the #1076 review findings Failed consent display never mutates, an incomplete display refuses approval, recovery hints are terminal-safe, format/bidi characters are stripped, consent wording (none listed, concurrent decline, CHANGED, MANUAL prompt), and a lost write is sent once. The ones that already pass pin previously unpinned sites. Worker: Jack (cli-engineer) Co-Authored-By: Claude Sonnet 5.5 --- internal/cmd/automation_cmd_test.go | 219 ++++++++++++++++++++++++++++ 1 file changed, 219 insertions(+) diff --git a/internal/cmd/automation_cmd_test.go b/internal/cmd/automation_cmd_test.go index fc49df05..77f1d33a 100644 --- a/internal/cmd/automation_cmd_test.go +++ b/internal/cmd/automation_cmd_test.go @@ -2,6 +2,7 @@ package cmd import ( "encoding/json" + "errors" "fmt" "net/http" "net/http/httptest" @@ -23,6 +24,7 @@ const autoApp = "hrn:app:acme.com:ops" type autoReply struct { status int // 0 = 200 body string + abort bool // drop the connection without answering } // autoServer answers by operation name, counts calls, and records the @@ -76,6 +78,14 @@ func newAutoServer(t *testing.T, replies map[string]func(vars map[string]any) au return } rep := fn(body.Variables) + if rep.abort { + if hj, ok := w.(http.Hijacker); ok { + if conn, _, err := hj.Hijack(); err == nil { + _ = conn.Close() + } + } + return + } w.Header().Set("Content-Type", "application/json") if rep.status != 0 { w.WriteHeader(rep.status) @@ -807,3 +817,212 @@ func TestAutomationLegacyOperationsStayUnbound(t *testing.T) { } } } + +// --- fix-head pins (BLIND/LOCAL/UX/§10 findings at 0b732c8c) --- + +type autoFailingWriter struct{} + +func (autoFailingWriter) Write([]byte) (int, error) { + return 0, errors.New("write /dev/full: no space left on device") +} + +// A consent display nobody received is not a consent display: with stderr +// failing, no mutation may be sent, --yes or not. +func TestAutomationFailedDisplayNeverMutates(t *testing.T) { + for _, verb := range []string{"approve", "decline"} { + t.Run(verb, func(t *testing.T) { + s := oneRowServer(t, "ADOPTED", nil) + f, out := testFactory(t) + f.IOStreams.ErrOut = autoFailingWriter{} + root := NewRootCmd(f) + root.SetArgs([]string{"automation", verb, "nightly", "--yes", "--app", autoApp, "--server", s.URL}) + err := root.Execute() + if err == nil || s.count("ApproveAutomation")+s.count("DeclineAutomation") != 0 || out.String() != "" { + t.Fatalf("a failed display must stop before any mutation: err=%v out=%q", err, out.String()) + } + }) + } +} + +// Approval must never conceal part of the authority being approved: a summary +// that cannot be shown in full is refused, and --json carries all of it. +func TestAutomationApproveRefusesAnIncompleteDisplay(t *testing.T) { + many := make([]string, 250) + for i := range many { + many[i] = fmt.Sprintf("permission %d", i) + } + manyJSON, _ := json.Marshal(many) + longFact := strings.Repeat("y", 3000) + for name, reads := range map[string]string{"more than the list cap": string(manyJSON), "a fact over the bound": jq(longFact)} { + body := strings.Replace(autoDetail("UNADOPTED"), `"reads":["what the App can read in this App"]`, `"reads":[`+strings.Trim(reads, "[]")+`]`, 1) + if name == "a fact over the bound" { + body = strings.Replace(autoDetail("UNADOPTED"), `"reads":["what the App can read in this App"]`, `"reads":[`+reads+`]`, 1) + } + t.Run(name, func(t *testing.T) { + s := oneRowServer(t, "UNADOPTED", map[string]func(map[string]any) autoReply{"AutomationApproval": fixed(body)}) + r := runAuto(t, s, false, "", "approve", "nightly", "--yes", "--json") + if r.code != exitcode.Unavailable || s.count("ApproveAutomation") != 0 || r.out != "" { + t.Fatalf("an incomplete display must refuse approval: code=%d err=%v", r.code, r.err) + } + if !strings.Contains(r.err.Error(), "--json") { + t.Errorf("the refusal must say how to read all of it: %v", r.err) + } + // approval --json is complete, approval text says it was cut. + j := runAuto(t, s, false, "", "approval", "nightly", "--json") + if !strings.Contains(j.out, "permission 249") && !strings.Contains(j.out, longFact) { + t.Errorf("--json must carry everything") + } + tx := runAuto(t, s, false, "", "approval", "nightly") + if !strings.Contains(tx.out, "cut for display") { + t.Errorf("approval text must say it was cut:\n%s", tx.out) + } + }) + } +} + +func TestAutomationRecoveryHintsAreTerminalSafe(t *testing.T) { + evil := "evil\x1b[31m\nname\u202e" + rows := autoPage([]string{autoRow("SCHEDULE", "s1", evil, "UNADOPTED", "h")}, "", true) + for name, rep := range map[string]string{ + "stale": memberGQLError("changed", "AUTOMATION_CHANGED"), + "unconfirmed": `{"data":{"approveAutomation":false}}`, + "partial": `{"data":{"approveAutomation":true},"errors":[{"message":"x","extensions":{"code":"FORBIDDEN"}}]}`, + } { + t.Run(name, func(t *testing.T) { + s := oneRowServer(t, "UNADOPTED", map[string]func(map[string]any) autoReply{ + "AutomationApprovals": fixed(rows), "ApproveAutomation": fixed(rep)}) + r := runAuto(t, s, false, "", "approve", evil, "--yes") + if r.err == nil { + t.Fatal("want an error") + } + msg := r.err.Error() + for _, bad := range []string{"\x1b", "\n\n", "\u202e", "evil\x1b"} { + if strings.Contains(msg, bad) { + t.Errorf("hint carries %q:\n%q", bad, msg) + } + } + if strings.Contains(msg, "automation approval evil") { + t.Errorf("a command whose target sanitizing would change must not be offered:\n%q", msg) + } + }) + } + t.Run("a --run id with controls", func(t *testing.T) { + s := oneRowServer(t, "UNADOPTED", map[string]func(map[string]any) autoReply{ + "ApproveAutomation": fixed(`{"data":{"approveAutomation":false}}`)}) + r := runAuto(t, s, false, "", "approve", "--run", "r\x1b[2Jun", "--yes") + if r.err == nil || strings.Contains(r.err.Error(), "\x1b") { + t.Errorf("run-id hint must be terminal-safe: %v", r.err) + } + }) +} + +func TestAutomationFormatAndBidiCharactersAreStripped(t *testing.T) { + nasty := "a\u202eb\u2066c\u200bd\u2028e" + body := strings.Replace(autoDetail("UNADOPTED"), `"tools":["hadron_find_nodes"],"limits"`, `"tools":[`+jq(nasty)+`],"limits"`, 1) + s := oneRowServer(t, "UNADOPTED", map[string]func(map[string]any) autoReply{"AutomationApproval": fixed(body)}) + r := runAuto(t, s, false, "", "approval", "nightly") + for _, bad := range []string{"\u202e", "\u2066", "\u200b", "\u2028"} { + if strings.Contains(r.out, bad) { + t.Errorf("format character %U survived:\n%q", []rune(bad)[0], r.out) + } + } +} + +// Sanitizing sites that nothing pinned: the ambiguity headline, per-flow tools +// and writes, and the --depth graph. +func TestAutomationEveryServerStringSiteIsSanitizedAndBounded(t *testing.T) { + evil := "x\x1b[31m\x07y" + t.Run("ambiguity headline", func(t *testing.T) { + s := oneRowServer(t, "UNADOPTED", map[string]func(map[string]any) autoReply{"AutomationApprovals": fixed(autoPage([]string{ + autoRow("SCHEDULE", "a", "dup", "UNADOPTED", evil), autoRow("WEBHOOK", "b", "dup", "UNADOPTED", strings.Repeat("h", 2000))}, "", true))}) + r := runAuto(t, s, false, "", "approval", "dup") + if r.err == nil || strings.ContainsAny(r.err.Error(), "\x1b\x07") || strings.Contains(r.err.Error(), strings.Repeat("h", 2000)) { + t.Errorf("ambiguity headline must be sanitized and bounded: %q", r.err) + } + }) + t.Run("flow tools and writes", func(t *testing.T) { + body := strings.Replace(autoDetail("UNADOPTED"), `"tools":["hadron_find_nodes"],"writes":[]}]`, `"tools":[`+jq(evil)+`],"writes":[`+jq(evil)+`]}]`, 1) + s := oneRowServer(t, "UNADOPTED", map[string]func(map[string]any) autoReply{"AutomationApproval": fixed(body)}) + r := runAuto(t, s, false, "", "approval", "nightly") + if strings.ContainsAny(r.out, "\x1b\x07") || !strings.Contains(r.out, "x") { + t.Errorf("flow lists must be sanitized: %q", r.out) + } + }) + t.Run("depth graph is bounded", func(t *testing.T) { + big := strings.Repeat("z", 30000) + body := strings.Replace(autoDetail("ADOPTED"), `"state":`, `"depth":{"blob":`+jq(big)+`},"state":`, 1) + s := oneRowServer(t, "ADOPTED", map[string]func(map[string]any) autoReply{"AutomationApprovalDepth": fixed(body)}) + r := runAuto(t, s, false, "", "approval", "nightly", "--depth") + if strings.Contains(r.out, big) || !strings.Contains(r.out, "truncated") { + t.Errorf("--depth graph must be bounded with a notice") + } + }) +} + +func TestAutomationConsentWording(t *testing.T) { + t.Run("empty facts say none listed, never a claim", func(t *testing.T) { + body := strings.Replace(autoDetail("UNADOPTED"), `"cannotDo":["never a private memory"]`, `"cannotDo":[]`, 1) + s := oneRowServer(t, "UNADOPTED", map[string]func(map[string]any) autoReply{"AutomationApproval": fixed(body)}) + r := runAuto(t, s, false, "", "approval", "nightly") + if strings.Contains(r.out, "(none)") || !strings.Contains(r.out, "Cannot do: none listed") { + t.Errorf("empty list wording:\n%s", r.out) + } + }) + t.Run("an unapproved decline warns about a concurrent approval", func(t *testing.T) { + s := oneRowServer(t, "UNADOPTED", nil) + r := runAuto(t, s, true, "n\n", "decline", "nightly") + if !strings.Contains(r.errOut, "approves it before this runs") || !strings.Contains(r.errOut, "revoke") { + t.Errorf("unapproved decline:\n%s", r.errOut) + } + }) + t.Run("approving a CHANGED automation says so", func(t *testing.T) { + s := oneRowServer(t, "CHANGED", nil) + r := runAuto(t, s, true, "n\n", "approve", "nightly") + if !strings.Contains(r.errOut, "changed since it was approved") || !strings.Contains(r.errOut, "(y/N)") { + t.Errorf("CHANGED prompt:\n%s", r.errOut) + } + if i := strings.LastIndex(r.errOut, "Approve"); i < 0 || !strings.Contains(r.errOut[i:], "changed") { + t.Errorf("the prompt itself must say it changed:\n%s", r.errOut) + } + }) + t.Run("a decline of an approved automation says it switches it off", func(t *testing.T) { + s := oneRowServer(t, "ADOPTED", nil) + r := runAuto(t, s, true, "n\n", "decline", "nightly") + if i := strings.LastIndex(r.errOut, "Decline"); i < 0 || !strings.Contains(r.errOut[i:], "switches it off") { + t.Errorf("the decline prompt must say it switches the automation off:\n%s", r.errOut) + } + }) + t.Run("zero match says not visible too", func(t *testing.T) { + s := oneRowServer(t, "UNADOPTED", nil) + r := runAuto(t, s, false, "", "approve", "nope", "--yes") + if !strings.Contains(r.err.Error(), "not visible to you") { + t.Errorf("zero match: %v", r.err) + } + }) + t.Run("a MANUAL prompt does not claim an App it cannot verify", func(t *testing.T) { + for _, verb := range []string{"approve", "decline"} { + s := oneRowServer(t, "UNADOPTED", nil) + r := runAuto(t, s, true, "n\n", verb, "--run", "run-1") + i := strings.LastIndex(r.errOut, "(y/N)") + prompt := r.errOut[strings.LastIndex(r.errOut[:i], "\n")+1 : i] + if strings.Contains(prompt, autoApp) || !strings.Contains(prompt, "run id") { + t.Errorf("%s: a MANUAL prompt must say it is found by run id only, not name the App: %q", verb, prompt) + } + } + }) +} + +// The client never retries a write: a connection dropped mid-approve is sent +// exactly once and is outcome unknown. +func TestAutomationLostApproveIsSentOnce(t *testing.T) { + for _, verb := range []string{"approve", "decline"} { + s := oneRowServer(t, "UNADOPTED", map[string]func(map[string]any) autoReply{ + "ApproveAutomation": func(map[string]any) autoReply { return autoReply{abort: true} }, + "DeclineAutomation": func(map[string]any) autoReply { return autoReply{abort: true} }, + }) + r := runAuto(t, s, false, "", verb, "nightly", "--yes") + if r.code != exitcode.Unavailable || s.count("ApproveAutomation")+s.count("DeclineAutomation") != 1 || r.out != "" { + t.Errorf("%s: want exit 7, one mutation, empty stdout: code=%d calls=%d", verb, r.code, s.count("ApproveAutomation")+s.count("DeclineAutomation")) + } + } +} From d84fae89dd81e134cc4fd20bd42440dc4951f96e Mon Sep 17 00:00:00 2001 From: Holger Selover-Stephan Date: Sun, 25 Oct 2026 18:51:14 +0000 Subject: [PATCH 5/7] Fold the #1076 review findings into one head (#1071) Display errors stop before any mutation; approve refuses an incomplete summary (approval --json reads all of it); recovery hints are terminal-safe or point at list; Unicode format/bidi characters are stripped; empty facts print "none listed"; the unapproved-decline warning names the concurrent approval; CHANGED and MANUAL prompts say what they are; zero match says "or not visible to you". Worker: Jack (cli-engineer) Co-Authored-By: Claude Sonnet 5.5 --- docs/plans/automation-approval-s2.md | 14 +++ internal/cmd/agentic/agentic-usage.md | 11 ++- internal/cmd/automation/approval.go | 127 +++++++++++++++++--------- internal/cmd/automation/mutate.go | 82 +++++++++++++++-- internal/cmd/automation/resolve.go | 2 +- internal/cmd/automation/text.go | 41 +++++++-- internal/cmd/automation_cmd_test.go | 2 +- 7 files changed, 212 insertions(+), 67 deletions(-) diff --git a/docs/plans/automation-approval-s2.md b/docs/plans/automation-approval-s2.md index cced78d1..e7cebca0 100644 --- a/docs/plans/automation-approval-s2.md +++ b/docs/plans/automation-approval-s2.md @@ -47,3 +47,17 @@ and `declineAutomation` and derives nothing. - **Legacy**: `previewAutomation` and `reAdoptAutomation` have no call site and stay in `unbound-ops.txt`. The schema refresh also lists the unrelated operations the snapshot was behind on. + +## Review fold (PR #1076, head after 0b732c8c) + +BLIND/LOCAL/UX/§10 findings folded into one head: the display error is returned +and stops before any mutation; approve refuses when the summary was cut (the +consent projection uses the large bounds, 1000 characters per fact and 200 per +list, and `approval --json` is the way to read all of it); recovery hints use the +sanitized form or point at `list` when sanitizing would change the target; +Unicode format characters (bidi, zero-width, U+2028/9) are stripped; empty +facts print "none listed"; the unapproved-decline warning names the concurrent +approval it would revoke; a CHANGED approve prompt says so; a MANUAL prompt says +"found by run id only" because the server takes no App for a run. Left as is +(P3): redundant Name/App header lines, and "approved" in a list for an +automation that may have changed (the footer and help say it). diff --git a/internal/cmd/agentic/agentic-usage.md b/internal/cmd/agentic/agentic-usage.md index 89077514..9cad21af 100644 --- a/internal/cmd/agentic/agentic-usage.md +++ b/internal/cmd/agentic/agentic-usage.md @@ -1970,8 +1970,15 @@ Conventions: stderr; stdout carries only the result (`{app, trigger, status: "approved"|"declined", hash?}` under `--json`). A "no"/EOF exits 6 and a non-interactive call without `--yes` exits 2, both with ZERO mutations. Server - text is stripped of terminal escapes and bounded in text mode; `--json` passes - it through unchanged. Exit codes: `FORBIDDEN` (including the read-access + text is stripped of terminal escapes, control and bidi/zero-width characters and + bounded in text mode; `--json` passes it through unchanged. An empty list in the + projection prints "none listed" (what the server listed, not a guarantee). + `approve` refuses (exit 7, nothing sent) when the summary cannot be shown in + full (a fact over 1000 characters or a list over 200 entries): read all of it + with `approval --json`. A failed write of the display (stderr closed or full) + stops before any mutation, `--yes` or not. A `--run` prompt says the run is + found by run id only: the server takes no App for it, so the CLI does not claim + one. Recovery hints never carry a name that sanitizing would change. Exit codes: `FORBIDDEN` (including the read-access refusals) 8, `NOT_FOUND` 4, `AUTOMATION_CHANGED` and `AUTOMATION_UNADOPTED` 5, bad selection 2, cancelled 6, and 7 for a malformed projection (a null required list, empty hash, unknown state — never an empty success), a server without these diff --git a/internal/cmd/automation/approval.go b/internal/cmd/automation/approval.go index a3cbd1e6..0ea9e5fc 100644 --- a/internal/cmd/automation/approval.go +++ b/internal/cmd/automation/approval.go @@ -12,75 +12,113 @@ import ( "github.com/hadron-memory/hadron-cli/internal/output" ) -func printList(w io.Writer, label string, items []string) { +// projectionWriter renders the consent projection. It remembers the first write +// error and whether anything was cut for display: a caller must not ask for +// consent after either (the person has not been shown what they are approving). +type projectionWriter struct { + w io.Writer + err error + incomplete bool +} + +func (p *projectionWriter) printf(format string, a ...any) { + if p.err != nil { + return + } + _, p.err = fmt.Fprintf(p.w, format, a...) +} + +// fact is one server string, sanitized and bounded; a cut marks the display incomplete. +func (p *projectionWriter) fact(s string, max int) string { + out, cut := safeTextCut(s, max) + if cut { + p.incomplete = true + } + return out +} + +func (p *projectionWriter) list(label string, items []string) { if len(items) == 0 { - fmt.Fprintf(w, "%s: (none)\n", label) + p.printf("%s: none listed\n", label) return } - fmt.Fprintf(w, "%s:\n", label) + p.printf("%s:\n", label) for i, it := range items { - if i == maxItems { - fmt.Fprintf(w, " … and %d more (`--json` has them all)\n", len(items)-maxItems) + if i == maxFacts { + p.incomplete = true + p.printf(" … and %d more\n", len(items)-maxFacts) break } - fmt.Fprintf(w, " - %s\n", safeText(it, maxLine)) + p.printf(" - %s\n", p.fact(it, maxFact)) + } +} + +func (p *projectionWriter) flowList(label string, items []string) { + if len(items) == 0 { + return + } + shown := items + more := "" + if len(items) > maxFacts { + shown, more = items[:maxFacts], fmt.Sprintf(", … and %d more", len(items)-maxFacts) + p.incomplete = true + } + out := make([]string, len(shown)) + for i, it := range shown { + out[i] = p.fact(it, maxFact) } + p.printf(" %s: %s%s\n", label, joinComma(out), more) } // writeProjection renders what an adopter reviews. Every server string passes -// safeText; the hash and graph appear only with the depth. -func writeProjection(w io.Writer, display string, d *approvalDTO, withDepth bool) { - fmt.Fprintf(w, "Automation %s in %s\n", display, d.App) - fmt.Fprintf(w, "State: %s\n", stateWord(d.State)) +// safeText; the hash and graph appear only with the depth. It returns the first +// write error, and incomplete=true when any fact or list was cut to fit. +func writeProjection(w io.Writer, display string, d *approvalDTO, withDepth bool) (incomplete bool, err error) { + p := &projectionWriter{w: w} + p.printf("Automation %s\n", display) + p.printf("State: %s\n", stateWord(d.State)) s := d.Summary - fmt.Fprintf(w, "Name: %s\n", safeText(s.Name, maxName)) + p.printf("Name: %s\n", p.fact(s.Name, maxName)) if s.Schedule != nil { - fmt.Fprintf(w, "Schedule: %s\n", safeText(*s.Schedule, maxLine)) + p.printf("Schedule: %s\n", p.fact(*s.Schedule, maxFact)) } - printList(w, "Reads", s.Reads) - printList(w, "Writes", s.Writes) - printList(w, "Tools", s.Tools) - printList(w, "Limits", s.Limits) - printList(w, "Cannot do", s.CannotDo) + p.list("Reads", s.Reads) + p.list("Writes", s.Writes) + p.list("Tools", s.Tools) + p.list("Limits", s.Limits) + p.list("Cannot do", s.CannotDo) if len(s.Flows) == 0 { - fmt.Fprintln(w, "Flows: (none)") + p.printf("Flows: none listed\n") } else { - fmt.Fprintln(w, "Flows:") + p.printf("Flows:\n") for i, fl := range s.Flows { - if i == maxItems { - fmt.Fprintf(w, " … and %d more (`--json` has them all)\n", len(s.Flows)-maxItems) + if i == maxFacts { + p.incomplete = true + p.printf(" … and %d more\n", len(s.Flows)-maxFacts) break } - fmt.Fprintf(w, " - %s (%s)\n", safeText(fl.Name, maxName), safeText(fl.URN, maxLine)) - printFlowList(w, "tools", fl.Tools) - printFlowList(w, "writes", fl.Writes) + p.printf(" - %s (%s)\n", p.fact(fl.Name, maxName), p.fact(fl.URN, maxFact)) + p.flowList("tools", fl.Tools) + p.flowList("writes", fl.Writes) } } if withDepth { - fmt.Fprintf(w, "Hash: %s\n", safeText(d.Hash, maxLine)) + p.printf("Hash: %s\n", p.fact(d.Hash, maxFact)) var pretty bytes.Buffer if err := json.Indent(&pretty, d.Depth, "", " "); err != nil { pretty.Reset() pretty.Write(d.Depth) } - fmt.Fprintf(w, "Graph:\n%s\n", safeBlock(pretty.String(), maxDepth)) - } -} - -func printFlowList(w io.Writer, label string, items []string) { - if len(items) == 0 { - return - } - shown := items - more := "" - if len(items) > maxItems { - shown, more = items[:maxItems], fmt.Sprintf(", … and %d more", len(items)-maxItems) + block := safeBlock(pretty.String(), maxDepth) + if len([]rune(pretty.String())) > maxDepth { + p.incomplete = true + } + p.printf("Graph:\n%s\n", block) } - out := make([]string, len(shown)) - for i, it := range shown { - out[i] = safeText(it, maxLine) + if p.incomplete { + p.printf("(some of this was cut for display; `--json` has all of it)\n") } - fmt.Fprintf(w, " %s: %s%s\n", label, joinComma(out), more) + return p.incomplete, p.err } func joinComma(s []string) string { @@ -104,7 +142,8 @@ func newCmdApproval(f *cmdutil.Factory) *cobra.Command { what it reads, writes and calls, and what it cannot do. Every sentence is the server's; a fact the server cannot show says "not stated". ---depth adds the graph and the hash. State is one of: not approved, approved, or +An empty list is printed "none listed": it is what the server listed, not a +guarantee that nothing applies. --depth adds the graph and the hash. State is one of: not approved, approved, or changed since it was approved (its next run fails until it is approved again).`, Example: ` hadron automation approval schedule/nightly hadron automation approval nightly --depth --json @@ -127,8 +166,8 @@ changed since it was approved (its next run fails until it is approved again).`, return err } return output.Write(f.IOStreams, f.JSON, dto, func(w io.Writer) error { - writeProjection(w, sel.Display, dto, depth) - return nil + _, err := writeProjection(w, sel.Display+inApp(sel, app), dto, depth) + return err }) }, } diff --git a/internal/cmd/automation/mutate.go b/internal/cmd/automation/mutate.go index 3404deeb..7e6558e5 100644 --- a/internal/cmd/automation/mutate.go +++ b/internal/cmd/automation/mutate.go @@ -21,11 +21,33 @@ type resultDTO struct { Hash string `json:"hash,omitempty"` } -// approvalHint is the copy-pasteable re-read after a refusal. +// inApp is the " in " tail of a header or prompt. A MANUAL origin is found +// by its run id alone — the server's projection and mutations take no App for it +// — so the CLI must not claim the run belongs to the App it was run against. +func inApp(sel *selection, app string) string { + if sel.Kind == gen.AutomationTriggerKindManual { + return " (found by run id only; which App it belongs to is not checked here)" + } + return " in " + app +} + +// terminalSafe reports whether s survives sanitizing unchanged — the condition +// for putting it in a command a person may copy. +func terminalSafe(s string) bool { return safeText(s, 4*maxFact) == s } + +// approvalHint is the copy-pasteable re-read after a refusal. A trigger or run +// name that sanitizing would change is never put in a command (the copy would +// name a different target, or carry terminal controls): the hint then points +// at the list instead. func approvalHint(sel *selection, app string) string { target := "--run " + shellQuote(sel.Trigger.Ref) + raw := sel.Trigger.Ref if sel.Trigger.Name != "" { - target = shellQuote(kindWord(sel.Kind) + "/" + sel.Trigger.Name) + raw = kindWord(sel.Kind) + "/" + sel.Trigger.Name + target = shellQuote(raw) + } + if !terminalSafe(raw) || !terminalSafe(app) { + return "`hadron automation list` and review it again (the name contains characters that cannot be shown safely)" } return "`hadron automation approval " + target + " --app " + shellQuote(app) + "`" } @@ -51,6 +73,23 @@ func mutationError(err error, partial bool, verb string, sel *selection, app str return api.MapError(err) } +// approvalTarget is the name argument for a copy-pasteable `approval` command +// (terminal-safe only; otherwise the placeholder). +func approvalTarget(sel *selection) string { + raw := "--run " + sel.Trigger.Ref + name := kindWord(sel.Kind) + "/" + sel.Trigger.Name + if sel.Trigger.Name != "" { + raw = name + } + if !terminalSafe(raw) { + return "" + } + if sel.Trigger.Name != "" { + return shellQuote(name) + } + return "--run " + shellQuote(sel.Trigger.Ref) +} + func notConfirmed(verb string, sel *selection, app string) error { return exitcode.Newf(exitcode.Unavailable, "outcome unknown: the server did not confirm the %s. Run %s to check", verb, approvalHint(sel, app)) @@ -90,10 +129,25 @@ Display and prompt go to stderr, so stdout carries only the result.`, if err != nil { return err } - writeProjection(f.IOStreams.ErrOut, sel.Display, dto, false) - prompt := fmt.Sprintf("Approve %s in %s (%s) as shown above?", sel.Display, app, source) - if dto.State == string(gen.AutomationAdoptionStateAdopted) { - prompt = fmt.Sprintf("Approve %s in %s (%s) again as shown above? (It is already approved; this records another approval.)", sel.Display, app, source) + incomplete, err := writeProjection(f.IOStreams.ErrOut, sel.Display+inApp(sel, app), dto, false) + if err != nil { + return exitcode.Newf(exitcode.Error, "could not show the summary (%v); nothing was approved", err) + } + if incomplete { + return exitcode.Newf(exitcode.Unavailable, + "unavailable: the summary is too large to show in full, so nothing was approved (approval needs the whole summary on screen). Read all of it with `hadron automation approval %s --json`", + approvalTarget(sel)) + } + where := inApp(sel, app) + if sel.Kind != gen.AutomationTriggerKindManual { + where += " (" + source + ")" + } + prompt := fmt.Sprintf("Approve %s%s as shown above?", sel.Display, where) + switch dto.State { + case string(gen.AutomationAdoptionStateAdopted): + prompt = fmt.Sprintf("Approve %s%s again as shown above? (It is already approved; this records another approval.)", sel.Display, where) + case string(gen.AutomationAdoptionStateChanged): + prompt = fmt.Sprintf("Approve %s%s as it is now, shown above? (It changed since it was approved; its next run fails until you approve it again.)", sel.Display, where) } if err := cmdutil.Confirm(f.IOStreams, yes, prompt); err != nil { return err @@ -124,7 +178,7 @@ func declineWarning(sel *selection) string { case sel.State == string(gen.AutomationAdoptionStateAdopted): return "Declining switches this automation off: its next run will fail until it is approved again. This revokes the current approval, including a newer approval another person may have just given. Runs already started keep what they were approved with." default: - return "This automation is not approved. Declining records your decline and changes nothing else." + return "This automation is not approved. Declining records your decline and changes nothing else — unless someone approves it before this runs: decline carries no hash, so it would revoke that approval too." } } @@ -163,8 +217,18 @@ before anything is sent, and "no" or end-of-input exits 6.`, return err } } - fmt.Fprintf(f.IOStreams.ErrOut, "Automation %s in %s\n%s\n", sel.Display, app, declineWarning(sel)) - if err := cmdutil.Confirm(f.IOStreams, yes, fmt.Sprintf("Decline %s in %s (%s)?", sel.Display, app, source)); err != nil { + if _, err := fmt.Fprintf(f.IOStreams.ErrOut, "Automation %s%s\n%s\n", sel.Display, inApp(sel, app), declineWarning(sel)); err != nil { + return exitcode.Newf(exitcode.Error, "could not show the warning (%v); nothing was declined", err) + } + where := inApp(sel, app) + if sel.Kind != gen.AutomationTriggerKindManual { + where += " (" + source + ")" + } + question := fmt.Sprintf("Decline %s%s?", sel.Display, where) + if sel.State == string(gen.AutomationAdoptionStateAdopted) && sel.Kind != gen.AutomationTriggerKindManual { + question = fmt.Sprintf("Decline %s%s? This switches it off.", sel.Display, where) + } + if err := cmdutil.Confirm(f.IOStreams, yes, question); err != nil { return err } resp, err := gen.DeclineAutomation(cmd.Context(), client, sel.Kind, sel.Trigger.Ref) diff --git a/internal/cmd/automation/resolve.go b/internal/cmd/automation/resolve.go index 79259c47..b31a637c 100644 --- a/internal/cmd/automation/resolve.go +++ b/internal/cmd/automation/resolve.go @@ -72,7 +72,7 @@ func resolveSelection(ctx context.Context, client graphql.Client, app string, ar } switch len(matches) { case 0: - return nil, exitcode.Newf(exitcode.NotFound, "no automation named %s in %s; run `hadron automation ls`", strconv.Quote(safeText(name, maxName)), app) + return nil, exitcode.Newf(exitcode.NotFound, "no automation named %s in %s, or it is not visible to you; run `hadron automation list`", strconv.Quote(safeText(name, maxName)), app) case 1: r := matches[0] return &selection{ diff --git a/internal/cmd/automation/text.go b/internal/cmd/automation/text.go index 06c01cb5..ccbdf57e 100644 --- a/internal/cmd/automation/text.go +++ b/internal/cmd/automation/text.go @@ -11,11 +11,18 @@ import ( // escape in it must not run, and a huge value must not flood the screen. // Text mode strips and bounds every server string it prints; --json passes the // server's value through unchanged. +// +// Two sets of bounds. Names, headlines and table cells use the small ones. The +// CONSENT projection (approval/approve) uses the large ones, and when anything +// in it would still be cut, approve refuses instead of letting a person confirm +// a hash over authority they were not shown (BLIND P2 on cli#1076). const ( - maxName = 120 // trigger and flow names - maxLine = 300 // one sentence or headline - maxItems = 25 // entries printed from one list before "and N more" + maxName = 120 // trigger and flow names, table cells + maxLine = 300 // a headline in a list or an ambiguity refusal maxDepth = 8000 + + maxFact = 1000 // one sentence of the consent projection + maxFacts = 200 // entries of one list of the consent projection ) var ( @@ -26,27 +33,41 @@ var ( reESC = regexp.MustCompile(`\x1b.?`) ) -// safeText returns s with terminal escape sequences removed, every remaining -// control character (newline, tab, NUL, C1…) turned into a space, and the result -// cut to max runes with an ellipsis. +// hidden reports a character that must not reach a terminal as itself: a +// control (newline, tab, NUL, C1…), a Unicode format character (bidi overrides +// and isolates, zero-width), or a line/paragraph separator. Graph-authored text +// could otherwise reorder or hide what an approval display says. +func hidden(r rune) bool { + return unicode.IsControl(r) || unicode.In(r, unicode.Cf, unicode.Zl, unicode.Zp) +} + +// safeText returns s with terminal escape sequences removed, every hidden +// character turned into a space, and the result cut to max runes with an +// ellipsis. func safeText(s string, max int) string { + out, _ := safeTextCut(s, max) + return out +} + +// safeTextCut is safeText that also reports whether it cut the text. +func safeTextCut(s string, max int) (string, bool) { s = reOSC.ReplaceAllString(s, "") s = reCSI.ReplaceAllString(s, "") s = reESC.ReplaceAllString(s, "") var b strings.Builder n := 0 for _, r := range s { - if unicode.IsControl(r) { + if hidden(r) { r = ' ' } if n == max { b.WriteString("…") - return b.String() + return b.String(), true } b.WriteRune(r) n++ } - return b.String() + return b.String(), false } // safeBlock is safeText for a multi-line value (the pretty-printed graph): @@ -58,7 +79,7 @@ func safeBlock(s string, max int) string { var b strings.Builder n := 0 for _, r := range s { - if r != '\n' && unicode.IsControl(r) { + if r != '\n' && hidden(r) { r = ' ' } if n == max { diff --git a/internal/cmd/automation_cmd_test.go b/internal/cmd/automation_cmd_test.go index 77f1d33a..ddb79317 100644 --- a/internal/cmd/automation_cmd_test.go +++ b/internal/cmd/automation_cmd_test.go @@ -342,7 +342,7 @@ func TestAutomationSelection(t *testing.T) { t.Fatalf("want exit 4 and nothing sent: code=%d err=%v", r.code, r.err) } msg := r.err.Error() - if !strings.Contains(msg, `no automation named "nope"`) || !strings.Contains(msg, autoApp) || !strings.Contains(msg, "hadron automation ls") { + if !strings.Contains(msg, `no automation named "nope"`) || !strings.Contains(msg, autoApp) || !strings.Contains(msg, "hadron automation list") { t.Errorf("zero-match message: %s", msg) } for _, bad := range []string{"nightly", "weekly", "hook"} { From e03f6eb8c505ed7e122724017830464bbb34c182 Mon Sep 17 00:00:00 2001 From: Holger Selover-Stephan Date: Sun, 25 Oct 2026 19:03:18 +0000 Subject: [PATCH 6/7] Red pins: failed prompt never mutates, oversize recovery names the App (#1076) Worker: Jack (cli-engineer) Co-Authored-By: Claude Sonnet 5.5 --- internal/cmd/automation_cmd_test.go | 52 +++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/internal/cmd/automation_cmd_test.go b/internal/cmd/automation_cmd_test.go index ddb79317..33b233e4 100644 --- a/internal/cmd/automation_cmd_test.go +++ b/internal/cmd/automation_cmd_test.go @@ -4,6 +4,7 @@ import ( "encoding/json" "errors" "fmt" + "io" "net/http" "net/http/httptest" "os" @@ -1026,3 +1027,54 @@ func TestAutomationLostApproveIsSentOnce(t *testing.T) { } } } + +// failsOnPrompt fails only the write that carries the confirmation prompt. +type failsOnPrompt struct{ w io.Writer } + +func (f failsOnPrompt) Write(p []byte) (int, error) { + if strings.Contains(string(p), "(y/N)") { + return 0, errors.New("write: broken pipe") + } + return f.w.Write(p) +} + +// A prompt nobody saw is not consent: when only the prompt write fails and a +// "y" arrives on stdin anyway, nothing is sent (LOCAL delta P2). +func TestAutomationFailedPromptNeverMutates(t *testing.T) { + for _, verb := range []string{"approve", "decline"} { + t.Run(verb, func(t *testing.T) { + s := oneRowServer(t, "ADOPTED", nil) + f, out, eo := testFactoryTTY(t, "y\n") + f.IOStreams.ErrOut = failsOnPrompt{w: eo} + root := NewRootCmd(f) + root.SetArgs([]string{"automation", verb, "nightly", "--app", autoApp, "--server", s.URL}) + err := root.Execute() + if err == nil || s.count("ApproveAutomation")+s.count("DeclineAutomation") != 0 || out.String() != "" { + t.Fatalf("a failed prompt write must stop before any mutation: err=%v out=%q", err, out.String()) + } + }) + } +} + +// The oversized-summary recovery command names the App and, when the trigger name +// would not survive sanitizing, offers a placeholder instead of a copy of it. +func TestAutomationOversizeRecoveryCommand(t *testing.T) { + big := strings.Replace(autoDetail("UNADOPTED"), `"reads":["what the App can read in this App"]`, `"reads":[`+jq(strings.Repeat("y", 3000))+`]`, 1) + t.Run("names the App", func(t *testing.T) { + s := oneRowServer(t, "UNADOPTED", map[string]func(map[string]any) autoReply{"AutomationApproval": fixed(big)}) + r := runAuto(t, s, false, "", "approve", "nightly", "--yes") + if r.err == nil || !strings.Contains(r.err.Error(), "approval schedule/nightly --json --app "+autoApp) { + t.Errorf("recovery command must carry --app: %v", r.err) + } + }) + t.Run("an unsafe name becomes a placeholder", func(t *testing.T) { + evil := "ev\x1b[31mil" + s := oneRowServer(t, "UNADOPTED", map[string]func(map[string]any) autoReply{ + "AutomationApproval": fixed(big), + "AutomationApprovals": fixed(autoPage([]string{autoRow("SCHEDULE", "s1", evil, "UNADOPTED", "h")}, "", true))}) + r := runAuto(t, s, false, "", "approve", evil, "--yes") + if r.err == nil || strings.ContainsAny(r.err.Error(), "\x1b") || !strings.Contains(r.err.Error(), "approval --json") { + t.Errorf("unsafe name must be a placeholder: %q", r.err) + } + }) +} From 6a44c936ecdda5af10aebb358757c9b85c7e2481 Mon Sep 17 00:00:00 2001 From: Holger Selover-Stephan Date: Sun, 25 Oct 2026 19:03:51 +0000 Subject: [PATCH 7/7] Refuse when the confirmation prompt cannot be written; name the App in the recovery command (#1071) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cmdutil.Confirm ignores a failed prompt write, so a "y" given to an unseen question sent the mutation (LOCAL delta P2). A local confirm wrapper refuses instead, leaving the shared helper untouched. The oversized-summary recovery command now carries --app (BLIND delta P3); its unsafe-name placeholder is pinned (§10 delta P3). Worker: Jack (cli-engineer) Co-Authored-By: Claude Sonnet 5.5 --- internal/cmd/agentic/agentic-usage.md | 4 +-- internal/cmd/automation/mutate.go | 48 ++++++++++++++++++++++++--- 2 files changed, 46 insertions(+), 6 deletions(-) diff --git a/internal/cmd/agentic/agentic-usage.md b/internal/cmd/agentic/agentic-usage.md index 9cad21af..5a13417d 100644 --- a/internal/cmd/agentic/agentic-usage.md +++ b/internal/cmd/agentic/agentic-usage.md @@ -1975,8 +1975,8 @@ Conventions: projection prints "none listed" (what the server listed, not a guarantee). `approve` refuses (exit 7, nothing sent) when the summary cannot be shown in full (a fact over 1000 characters or a list over 200 entries): read all of it - with `approval --json`. A failed write of the display (stderr closed or full) - stops before any mutation, `--yes` or not. A `--run` prompt says the run is + with `approval --json`. A failed write of the display or of the confirmation + prompt (stderr closed or full) stops before any mutation, `--yes` or not. A `--run` prompt says the run is found by run id only: the server takes no App for it, so the CLI does not claim one. Recovery hints never carry a name that sanitizing would change. Exit codes: `FORBIDDEN` (including the read-access refusals) 8, `NOT_FOUND` 4, `AUTOMATION_CHANGED` and `AUTOMATION_UNADOPTED` 5, diff --git a/internal/cmd/automation/mutate.go b/internal/cmd/automation/mutate.go index 7e6558e5..79c2acad 100644 --- a/internal/cmd/automation/mutate.go +++ b/internal/cmd/automation/mutate.go @@ -90,6 +90,46 @@ func approvalTarget(sel *selection) string { return "--run " + shellQuote(sel.Trigger.Ref) } +// stickyWriter remembers the first failed write to the stream it wraps. +type stickyWriter struct { + w io.Writer + err error +} + +func (s *stickyWriter) Write(p []byte) (int, error) { + n, err := s.w.Write(p) + if err != nil && s.err == nil { + s.err = err + } + return n, err +} + +// confirm is cmdutil.Confirm that also refuses when the PROMPT could not be +// written: a "y" given to a question nobody saw is not consent, and Confirm +// itself ignores a failed prompt write (LOCAL delta P2 on cli#1076). +func confirm(streams *output.IOStreams, yes bool, prompt string) error { + orig := streams.ErrOut + sw := &stickyWriter{w: orig} + streams.ErrOut = sw + defer func() { streams.ErrOut = orig }() + if err := cmdutil.Confirm(streams, yes, prompt); err != nil { + return err + } + if sw.err != nil { + return exitcode.Newf(exitcode.Error, "could not show the confirmation prompt (%v); nothing was sent", sw.err) + } + return nil +} + +// approvalAppFlag is " --app " for a copy-pasteable command, or nothing when +// the App ref would not survive sanitizing. +func approvalAppFlag(app string) string { + if !terminalSafe(app) { + return "" + } + return " --app " + shellQuote(app) +} + func notConfirmed(verb string, sel *selection, app string) error { return exitcode.Newf(exitcode.Unavailable, "outcome unknown: the server did not confirm the %s. Run %s to check", verb, approvalHint(sel, app)) @@ -135,8 +175,8 @@ Display and prompt go to stderr, so stdout carries only the result.`, } if incomplete { return exitcode.Newf(exitcode.Unavailable, - "unavailable: the summary is too large to show in full, so nothing was approved (approval needs the whole summary on screen). Read all of it with `hadron automation approval %s --json`", - approvalTarget(sel)) + "unavailable: the summary is too large to show in full, so nothing was approved (approval needs the whole summary on screen). Read all of it with `hadron automation approval %s --json%s`", + approvalTarget(sel), approvalAppFlag(app)) } where := inApp(sel, app) if sel.Kind != gen.AutomationTriggerKindManual { @@ -149,7 +189,7 @@ Display and prompt go to stderr, so stdout carries only the result.`, case string(gen.AutomationAdoptionStateChanged): prompt = fmt.Sprintf("Approve %s%s as it is now, shown above? (It changed since it was approved; its next run fails until you approve it again.)", sel.Display, where) } - if err := cmdutil.Confirm(f.IOStreams, yes, prompt); err != nil { + if err := confirm(f.IOStreams, yes, prompt); err != nil { return err } resp, err := gen.ApproveAutomation(cmd.Context(), client, sel.Kind, sel.Trigger.Ref, dto.Hash) @@ -228,7 +268,7 @@ before anything is sent, and "no" or end-of-input exits 6.`, if sel.State == string(gen.AutomationAdoptionStateAdopted) && sel.Kind != gen.AutomationTriggerKindManual { question = fmt.Sprintf("Decline %s%s? This switches it off.", sel.Display, where) } - if err := cmdutil.Confirm(f.IOStreams, yes, question); err != nil { + if err := confirm(f.IOStreams, yes, question); err != nil { return err } resp, err := gen.DeclineAutomation(cmd.Context(), client, sel.Kind, sel.Trigger.Ref)