diff --git a/cli/internal/command/visualize_v2_integ_test.go b/cli/internal/command/visualize_v2_integ_test.go index ccfe68187..e7b6c48e2 100644 --- a/cli/internal/command/visualize_v2_integ_test.go +++ b/cli/internal/command/visualize_v2_integ_test.go @@ -28,6 +28,7 @@ func TestVisualizeV2RefundFlows(t *testing.T) { wantGroupIDs []string wantSummaryFields []string wantActionRPCNames []string + wantIndexTypes map[string]string }{ { name: "deterministic", @@ -37,15 +38,23 @@ func TestVisualizeV2RefundFlows(t *testing.T) { wantGroupIDs: []string{"intake", "evidence", "control", "resolution", "failure", "close"}, wantSummaryFields: []string{"charge-reference", "refund-amount", "recommended-action"}, wantActionRPCNames: []string{}, + wantIndexTypes: map[string]string{"case-status": "keyword"}, }, { name: "agentic", source: filepath.Join(repositoryRoot, "examples/go/products/customer-refund/agentic/workflow.go"), - wantFlowType: "AgenticCustomerRefundFlow", - wantGroupIDs: []string{"intake", "reasoning", "evidence", "control", "resolution", "failure", "close"}, - wantSummaryFields: []string{"in-charge-ref", "ev-payment-amount", "recommended-action", "guardrail-rule"}, - wantActionRPCNames: []string{"ApproveRefund", "RejectRefund"}, + wantFlowType: "AgenticCustomerRefundFlow", + wantGroupIDs: []string{"intake", "reasoning", "evidence", "control", "resolution", "failure", "close"}, + wantSummaryFields: []string{"in-charge-ref", "ev-payment-amount", "recommended-action", "guardrail-rule"}, + wantActionRPCNames: []string{ + "ApproveRefund", "RejectRefund", "ConfirmCustomerMessage", "EditCustomerMessage", + }, + wantIndexTypes: map[string]string{ + "case-status": "keyword", + "customer-email": "fulltext", + "refund-amount": "double", + }, }, } for _, test := range tests { @@ -61,9 +70,13 @@ func TestVisualizeV2RefundFlows(t *testing.T) { require.Equal(t, test.wantGroupIDs, v2GroupIDs(graph.Groups)) require.Equal(t, test.wantSummaryFields, v2ViewFieldKeys(graph.V2.Summary.Fields)) require.Equal(t, test.wantActionRPCNames, v2ActionRPCNames(graph.V2.Actions)) - require.Equal(t, "case-status", graph.V2.IndexedAttributes[0].AttributeKey) - require.Equal(t, "case-status", graph.V2.IndexedAttributes[0].IndexKey) - require.Equal(t, "keyword", graph.V2.IndexedAttributes[0].IndexType) + // Keyed rather than positional: declaring another Indexed Attribute must not move this. + indexTypes := make(map[string]string, len(graph.V2.IndexedAttributes)) + for _, attribute := range graph.V2.IndexedAttributes { + require.Equal(t, attribute.AttributeKey, attribute.IndexKey) + indexTypes[attribute.AttributeKey] = attribute.IndexType + } + require.Equal(t, test.wantIndexTypes, indexTypes) for _, node := range graph.Nodes { if node.Kind != "step" { continue diff --git a/docs/src/data/flow-definitions/customer-refund-agentic.json b/docs/src/data/flow-definitions/customer-refund-agentic.json index 4a61b6ddc..91aa9d2c2 100644 --- a/docs/src/data/flow-definitions/customer-refund-agentic.json +++ b/docs/src/data/flow-definitions/customer-refund-agentic.json @@ -9,9 +9,9 @@ "name": "AgenticCustomerRefundFlow", "startStepId": "step:agenticReceiveRequestStep", "span": { - "startLine": 146, + "startLine": 173, "startColumn": 1, - "endLine": 172, + "endLine": 201, "endColumn": 2 } }, @@ -23,9 +23,9 @@ "parentId": "step:agenticAuditIntentStep", "phase": "execute", "span": { - "startLine": 767, + "startLine": 879, "startColumn": 1, - "endLine": 779, + "endLine": 891, "endColumn": 2 } }, @@ -36,9 +36,9 @@ "parentId": "step:agenticDecisionStep", "phase": "execute", "span": { - "startLine": 407, + "startLine": 506, "startColumn": 1, - "endLine": 477, + "endLine": 576, "endColumn": 2 } }, @@ -49,9 +49,9 @@ "parentId": "step:agenticGuardrailStep", "phase": "execute", "span": { - "startLine": 636, + "startLine": 745, "startColumn": 1, - "endLine": 683, + "endLine": 792, "endColumn": 2 } }, @@ -62,9 +62,9 @@ "parentId": "step:agenticIssueRefundStep", "phase": "execute", "span": { - "startLine": 800, + "startLine": 913, "startColumn": 1, - "endLine": 823, + "endLine": 936, "endColumn": 2 } }, @@ -75,9 +75,9 @@ "parentId": "step:agenticOfferAccountCreditStep", "phase": "execute", "span": { - "startLine": 835, + "startLine": 949, "startColumn": 1, - "endLine": 854, + "endLine": 968, "endColumn": 2 } }, @@ -88,9 +88,9 @@ "parentId": "step:agenticReCheckStep", "phase": "execute", "span": { - "startLine": 694, + "startLine": 804, "startColumn": 1, - "endLine": 715, + "endLine": 825, "endColumn": 2 } }, @@ -101,9 +101,9 @@ "parentId": "step:agenticReceiveRequestStep", "phase": "execute", "span": { - "startLine": 373, + "startLine": 468, "startColumn": 1, - "endLine": 396, + "endLine": 494, "endColumn": 2 } }, @@ -114,22 +114,54 @@ "parentId": "step:agenticVerifyBillingStep", "phase": "execute", "span": { - "startLine": 866, + "startLine": 981, "startColumn": 1, - "endLine": 891, + "endLine": 1006, "endColumn": 2 } }, { - "id": "decision:rpc:ApproveRefund:334:9", + "id": "decision:rpc:ApproveRefund:381:9", "kind": "decision", "name": "rpcResult", "parentId": "rpc:ApproveRefund", "phase": "rpc", "span": { - "startLine": 334, + "startLine": 381, + "startColumn": 9, + "endLine": 381, + "endColumn": 35 + }, + "decision": { + "type": "rpcResult" + } + }, + { + "id": "decision:rpc:ConfirmCustomerMessage:428:9", + "kind": "decision", + "name": "rpcResult", + "parentId": "rpc:ConfirmCustomerMessage", + "phase": "rpc", + "span": { + "startLine": 428, + "startColumn": 9, + "endLine": 428, + "endColumn": 35 + }, + "decision": { + "type": "rpcResult" + } + }, + { + "id": "decision:rpc:EditCustomerMessage:455:9", + "kind": "decision", + "name": "rpcResult", + "parentId": "rpc:EditCustomerMessage", + "phase": "rpc", + "span": { + "startLine": 455, "startColumn": 9, - "endLine": 334, + "endLine": 455, "endColumn": 35 }, "decision": { @@ -137,15 +169,15 @@ } }, { - "id": "decision:rpc:GetDexDisplay:304:9", + "id": "decision:rpc:GetDexDisplay:349:9", "kind": "decision", "name": "rpcResult", "parentId": "rpc:GetDexDisplay", "phase": "rpc", "span": { - "startLine": 304, + "startLine": 349, "startColumn": 9, - "endLine": 318, + "endLine": 365, "endColumn": 4 }, "decision": { @@ -153,15 +185,15 @@ } }, { - "id": "decision:rpc:GetDexSummary:253:9", + "id": "decision:rpc:GetDexSummary:294:9", "kind": "decision", "name": "rpcResult", "parentId": "rpc:GetDexSummary", "phase": "rpc", "span": { - "startLine": 253, + "startLine": 294, "startColumn": 9, - "endLine": 258, + "endLine": 299, "endColumn": 4 }, "decision": { @@ -169,15 +201,15 @@ } }, { - "id": "decision:rpc:RejectRefund:361:9", + "id": "decision:rpc:RejectRefund:408:9", "kind": "decision", "name": "rpcResult", "parentId": "rpc:RejectRefund", "phase": "rpc", "span": { - "startLine": 361, + "startLine": 408, "startColumn": 9, - "endLine": 361, + "endLine": 408, "endColumn": 35 }, "decision": { @@ -185,32 +217,32 @@ } }, { - "id": "decision:step:agenticApplySubscriptionStep:917:9", + "id": "decision:step:agenticApplySubscriptionStep:1033:9", "kind": "decision", "name": "goTo", "parentId": "step:agenticApplySubscriptionStep", "phase": "execute", "span": { - "startLine": 917, + "startLine": 1033, "startColumn": 9, - "endLine": 917, - "endColumn": 63 + "endLine": 1033, + "endColumn": 64 }, "decision": { "type": "goTo" } }, { - "id": "decision:step:agenticAuditIntentStep:776:10", + "id": "decision:step:agenticAuditIntentStep:888:10", "kind": "decision", "name": "goTo", "parentId": "step:agenticAuditIntentStep", "condition": "action == actionOfferAccountCredit", "phase": "execute", "span": { - "startLine": 776, + "startLine": 888, "startColumn": 10, - "endLine": 776, + "endLine": 888, "endColumn": 63 }, "decision": { @@ -218,16 +250,16 @@ } }, { - "id": "decision:step:agenticAuditIntentStep:778:9", + "id": "decision:step:agenticAuditIntentStep:890:9", "kind": "decision", "name": "goTo", "parentId": "step:agenticAuditIntentStep", "condition": "!(action == actionOfferAccountCredit)", "phase": "execute", "span": { - "startLine": 778, + "startLine": 890, "startColumn": 9, - "endLine": 778, + "endLine": 890, "endColumn": 55 }, "decision": { @@ -235,31 +267,31 @@ } }, { - "id": "decision:step:agenticBillingFailedStep:1021:9", + "id": "decision:step:agenticBillingFailedStep:1225:9", "kind": "decision", "name": "goTo", "parentId": "step:agenticBillingFailedStep", "phase": "execute", "span": { - "startLine": 1021, + "startLine": 1225, "startColumn": 9, - "endLine": 1021, - "endColumn": 63 + "endLine": 1225, + "endColumn": 64 }, "decision": { "type": "goTo" } }, { - "id": "decision:step:agenticCheckIdentityStep:496:9", + "id": "decision:step:agenticCheckIdentityStep:596:9", "kind": "decision", "name": "goTo", "parentId": "step:agenticCheckIdentityStep", "phase": "execute", "span": { - "startLine": 496, + "startLine": 596, "startColumn": 9, - "endLine": 496, + "endLine": 596, "endColumn": 52 }, "decision": { @@ -267,15 +299,15 @@ } }, { - "id": "decision:step:agenticCheckIncidentsStep:624:9", + "id": "decision:step:agenticCheckIncidentsStep:732:9", "kind": "decision", "name": "goTo", "parentId": "step:agenticCheckIncidentsStep", "phase": "execute", "span": { - "startLine": 624, + "startLine": 732, "startColumn": 9, - "endLine": 624, + "endLine": 732, "endColumn": 52 }, "decision": { @@ -283,15 +315,15 @@ } }, { - "id": "decision:step:agenticCloseCaseStep:1081:9", + "id": "decision:step:agenticCloseCaseStep:1288:9", "kind": "decision", "name": "gracefulComplete", "parentId": "step:agenticCloseCaseStep", "phase": "execute", "span": { - "startLine": 1081, + "startLine": 1288, "startColumn": 9, - "endLine": 1081, + "endLine": 1288, "endColumn": 60 }, "decision": { @@ -299,16 +331,32 @@ } }, { - "id": "decision:step:agenticDecisionStep:416:10", + "id": "decision:step:agenticConfirmCustomerMessageStep:1123:9", + "kind": "decision", + "name": "goTo", + "parentId": "step:agenticConfirmCustomerMessageStep", + "phase": "execute", + "span": { + "startLine": 1123, + "startColumn": 9, + "endLine": 1123, + "endColumn": 63 + }, + "decision": { + "type": "goTo" + } + }, + { + "id": "decision:step:agenticDecisionStep:515:10", "kind": "decision", "name": "goTo", "parentId": "step:agenticDecisionStep", "condition": "rounds \u003e= decisionRoundsBudget", "phase": "execute", "span": { - "startLine": 416, + "startLine": 515, "startColumn": 10, - "endLine": 416, + "endLine": 515, "endColumn": 59 }, "decision": { @@ -316,16 +364,16 @@ } }, { - "id": "decision:step:agenticDecisionStep:429:10", + "id": "decision:step:agenticDecisionStep:528:10", "kind": "decision", "name": "goTo", "parentId": "step:agenticDecisionStep", "condition": "!(rounds \u003e= decisionRoundsBudget) and !hasIdentity", "phase": "execute", "span": { - "startLine": 429, + "startLine": 528, "startColumn": 10, - "endLine": 429, + "endLine": 528, "endColumn": 58 }, "decision": { @@ -333,16 +381,16 @@ } }, { - "id": "decision:step:agenticDecisionStep:436:10", + "id": "decision:step:agenticDecisionStep:535:10", "kind": "decision", "name": "goTo", "parentId": "step:agenticDecisionStep", "condition": "!(rounds \u003e= decisionRoundsBudget) and !(!hasIdentity) and !hasSubscription", "phase": "execute", "span": { - "startLine": 436, + "startLine": 535, "startColumn": 10, - "endLine": 436, + "endLine": 535, "endColumn": 60 }, "decision": { @@ -350,16 +398,16 @@ } }, { - "id": "decision:step:agenticDecisionStep:443:10", + "id": "decision:step:agenticDecisionStep:542:10", "kind": "decision", "name": "goTo", "parentId": "step:agenticDecisionStep", "condition": "!(rounds \u003e= decisionRoundsBudget) and !(!hasIdentity) and !(!hasSubscription) and !hasPayment", "phase": "execute", "span": { - "startLine": 443, + "startLine": 542, "startColumn": 10, - "endLine": 443, + "endLine": 542, "endColumn": 55 }, "decision": { @@ -367,16 +415,16 @@ } }, { - "id": "decision:step:agenticDecisionStep:450:10", + "id": "decision:step:agenticDecisionStep:549:10", "kind": "decision", "name": "goTo", "parentId": "step:agenticDecisionStep", "condition": "!(rounds \u003e= decisionRoundsBudget) and !(!hasIdentity) and !(!hasSubscription) and !(!hasPayment) and !hasUsage", "phase": "execute", "span": { - "startLine": 450, + "startLine": 549, "startColumn": 10, - "endLine": 450, + "endLine": 549, "endColumn": 53 }, "decision": { @@ -384,16 +432,16 @@ } }, { - "id": "decision:step:agenticDecisionStep:457:10", + "id": "decision:step:agenticDecisionStep:556:10", "kind": "decision", "name": "goTo", "parentId": "step:agenticDecisionStep", "condition": "!(rounds \u003e= decisionRoundsBudget) and !(!hasIdentity) and !(!hasSubscription) and !(!hasPayment) and !(!hasUsage) and !hasHistory", "phase": "execute", "span": { - "startLine": 457, + "startLine": 556, "startColumn": 10, - "endLine": 457, + "endLine": 556, "endColumn": 62 }, "decision": { @@ -401,16 +449,16 @@ } }, { - "id": "decision:step:agenticDecisionStep:464:10", + "id": "decision:step:agenticDecisionStep:563:10", "kind": "decision", "name": "goTo", "parentId": "step:agenticDecisionStep", "condition": "!(rounds \u003e= decisionRoundsBudget) and !(!hasIdentity) and !(!hasSubscription) and !(!hasPayment) and !(!hasUsage) and !(!hasHistory) and !hasIncidents", "phase": "execute", "span": { - "startLine": 464, + "startLine": 563, "startColumn": 10, - "endLine": 464, + "endLine": 563, "endColumn": 59 }, "decision": { @@ -418,16 +466,16 @@ } }, { - "id": "decision:step:agenticDecisionStep:476:9", + "id": "decision:step:agenticDecisionStep:575:9", "kind": "decision", "name": "goTo", "parentId": "step:agenticDecisionStep", "condition": "!(rounds \u003e= decisionRoundsBudget) and !(!hasIdentity) and !(!hasSubscription) and !(!hasPayment) and !(!hasUsage) and !(!hasHistory) and !(!hasIncidents)", "phase": "execute", "span": { - "startLine": 476, + "startLine": 575, "startColumn": 9, - "endLine": 476, + "endLine": 575, "endColumn": 53 }, "decision": { @@ -435,15 +483,31 @@ } }, { - "id": "decision:step:agenticEmailFailedStep:1065:9", + "id": "decision:step:agenticDraftCustomerMessageStep:1084:9", + "kind": "decision", + "name": "goTo", + "parentId": "step:agenticDraftCustomerMessageStep", + "phase": "execute", + "span": { + "startLine": 1084, + "startColumn": 9, + "endLine": 1084, + "endColumn": 66 + }, + "decision": { + "type": "goTo" + } + }, + { + "id": "decision:step:agenticEmailFailedStep:1271:9", "kind": "decision", "name": "goTo", "parentId": "step:agenticEmailFailedStep", "phase": "execute", "span": { - "startLine": 1065, + "startLine": 1271, "startColumn": 9, - "endLine": 1065, + "endLine": 1271, "endColumn": 53 }, "decision": { @@ -451,15 +515,15 @@ } }, { - "id": "decision:step:agenticGetPaymentStep:543:9", + "id": "decision:step:agenticGetPaymentStep:648:9", "kind": "decision", "name": "goTo", "parentId": "step:agenticGetPaymentStep", "phase": "execute", "span": { - "startLine": 543, + "startLine": 648, "startColumn": 9, - "endLine": 543, + "endLine": 648, "endColumn": 52 }, "decision": { @@ -467,15 +531,15 @@ } }, { - "id": "decision:step:agenticGetSubscriptionStep:516:9", + "id": "decision:step:agenticGetSubscriptionStep:617:9", "kind": "decision", "name": "goTo", "parentId": "step:agenticGetSubscriptionStep", "phase": "execute", "span": { - "startLine": 516, + "startLine": 617, "startColumn": 9, - "endLine": 516, + "endLine": 617, "endColumn": 52 }, "decision": { @@ -483,15 +547,15 @@ } }, { - "id": "decision:step:agenticGetSupportHistoryStep:597:9", + "id": "decision:step:agenticGetSupportHistoryStep:704:9", "kind": "decision", "name": "goTo", "parentId": "step:agenticGetSupportHistoryStep", "phase": "execute", "span": { - "startLine": 597, + "startLine": 704, "startColumn": 9, - "endLine": 597, + "endLine": 704, "endColumn": 52 }, "decision": { @@ -499,15 +563,15 @@ } }, { - "id": "decision:step:agenticGetUsageStep:567:9", + "id": "decision:step:agenticGetUsageStep:673:9", "kind": "decision", "name": "goTo", "parentId": "step:agenticGetUsageStep", "phase": "execute", "span": { - "startLine": 567, + "startLine": 673, "startColumn": 9, - "endLine": 567, + "endLine": 673, "endColumn": 52 }, "decision": { @@ -515,16 +579,16 @@ } }, { - "id": "decision:step:agenticGuardrailStep:677:10", + "id": "decision:step:agenticGuardrailStep:786:10", "kind": "decision", "name": "goTo", "parentId": "step:agenticGuardrailStep", "condition": "verdict != \"auto\"", "phase": "execute", "span": { - "startLine": 677, + "startLine": 786, "startColumn": 10, - "endLine": 677, + "endLine": 786, "endColumn": 65 }, "decision": { @@ -532,16 +596,16 @@ } }, { - "id": "decision:step:agenticGuardrailStep:682:9", + "id": "decision:step:agenticGuardrailStep:791:9", "kind": "decision", "name": "goTo", "parentId": "step:agenticGuardrailStep", "condition": "!(verdict != \"auto\")", "phase": "execute", "span": { - "startLine": 682, + "startLine": 791, "startColumn": 9, - "endLine": 682, + "endLine": 791, "endColumn": 55 }, "decision": { @@ -549,16 +613,16 @@ } }, { - "id": "decision:step:agenticIssueRefundStep:817:10", + "id": "decision:step:agenticIssueRefundStep:930:10", "kind": "decision", "name": "goTo", "parentId": "step:agenticIssueRefundStep", "condition": "outcome == refundmodel.BillingConfirmed", "phase": "execute", "span": { - "startLine": 817, + "startLine": 930, "startColumn": 10, - "endLine": 817, + "endLine": 930, "endColumn": 62 }, "decision": { @@ -566,16 +630,16 @@ } }, { - "id": "decision:step:agenticIssueRefundStep:819:10", + "id": "decision:step:agenticIssueRefundStep:932:10", "kind": "decision", "name": "goTo", "parentId": "step:agenticIssueRefundStep", "condition": "outcome == refundmodel.BillingUnknown", "phase": "execute", "span": { - "startLine": 819, + "startLine": 932, "startColumn": 10, - "endLine": 819, + "endLine": 932, "endColumn": 58 }, "decision": { @@ -583,16 +647,16 @@ } }, { - "id": "decision:step:agenticIssueRefundStep:821:10", + "id": "decision:step:agenticIssueRefundStep:934:10", "kind": "decision", "name": "goTo", "parentId": "step:agenticIssueRefundStep", "condition": "default", "phase": "execute", "span": { - "startLine": 821, + "startLine": 934, "startColumn": 10, - "endLine": 821, + "endLine": 934, "endColumn": 58 }, "decision": { @@ -600,15 +664,15 @@ } }, { - "id": "decision:step:agenticNonConvergenceStep:983:9", + "id": "decision:step:agenticNonConvergenceStep:1185:9", "kind": "decision", "name": "goTo", "parentId": "step:agenticNonConvergenceStep", "phase": "execute", "span": { - "startLine": 983, + "startLine": 1185, "startColumn": 9, - "endLine": 983, + "endLine": 1185, "endColumn": 53 }, "decision": { @@ -616,15 +680,15 @@ } }, { - "id": "decision:step:agenticNotARefundStep:999:9", + "id": "decision:step:agenticNotARefundStep:1202:9", "kind": "decision", "name": "goTo", "parentId": "step:agenticNotARefundStep", "phase": "execute", "span": { - "startLine": 999, + "startLine": 1202, "startColumn": 9, - "endLine": 999, + "endLine": 1202, "endColumn": 53 }, "decision": { @@ -632,16 +696,16 @@ } }, { - "id": "decision:step:agenticOfferAccountCreditStep:848:10", + "id": "decision:step:agenticOfferAccountCreditStep:962:10", "kind": "decision", "name": "goTo", "parentId": "step:agenticOfferAccountCreditStep", "condition": "outcome != refundmodel.BillingConfirmed", "phase": "execute", "span": { - "startLine": 848, + "startLine": 962, "startColumn": 10, - "endLine": 848, + "endLine": 962, "endColumn": 58 }, "decision": { @@ -649,16 +713,16 @@ } }, { - "id": "decision:step:agenticOfferAccountCreditStep:853:9", + "id": "decision:step:agenticOfferAccountCreditStep:967:9", "kind": "decision", "name": "goTo", "parentId": "step:agenticOfferAccountCreditStep", "condition": "!(outcome != refundmodel.BillingConfirmed)", "phase": "execute", "span": { - "startLine": 853, + "startLine": 967, "startColumn": 9, - "endLine": 853, + "endLine": 967, "endColumn": 61 }, "decision": { @@ -666,33 +730,33 @@ } }, { - "id": "decision:step:agenticReCheckStep:706:10", + "id": "decision:step:agenticReCheckStep:816:10", "kind": "decision", "name": "goTo", "parentId": "step:agenticReCheckStep", "condition": "verdict != \"approve\"", "phase": "execute", "span": { - "startLine": 706, + "startLine": 816, "startColumn": 10, - "endLine": 706, - "endColumn": 64 + "endLine": 816, + "endColumn": 65 }, "decision": { "type": "goTo" } }, { - "id": "decision:step:agenticReCheckStep:714:9", + "id": "decision:step:agenticReCheckStep:824:9", "kind": "decision", "name": "goTo", "parentId": "step:agenticReCheckStep", "condition": "!(verdict != \"approve\")", "phase": "execute", "span": { - "startLine": 714, + "startLine": 824, "startColumn": 9, - "endLine": 714, + "endLine": 824, "endColumn": 55 }, "decision": { @@ -700,16 +764,16 @@ } }, { - "id": "decision:step:agenticReceiveRequestStep:390:10", + "id": "decision:step:agenticReceiveRequestStep:488:10", "kind": "decision", "name": "goTo", "parentId": "step:agenticReceiveRequestStep", "condition": "refundCase.CaseID == \"\"", "phase": "execute", "span": { - "startLine": 390, + "startLine": 488, "startColumn": 10, - "endLine": 390, + "endLine": 488, "endColumn": 55 }, "decision": { @@ -717,16 +781,16 @@ } }, { - "id": "decision:step:agenticReceiveRequestStep:395:9", + "id": "decision:step:agenticReceiveRequestStep:493:9", "kind": "decision", "name": "goTo", "parentId": "step:agenticReceiveRequestStep", "condition": "!(refundCase.CaseID == \"\")", "phase": "execute", "span": { - "startLine": 395, + "startLine": 493, "startColumn": 9, - "endLine": 395, + "endLine": 493, "endColumn": 52 }, "decision": { @@ -734,15 +798,15 @@ } }, { - "id": "decision:step:agenticRequestHumanApprovalStep:755:9", + "id": "decision:step:agenticRequestHumanApprovalStep:866:9", "kind": "decision", "name": "goTo", "parentId": "step:agenticRequestHumanApprovalStep", "phase": "execute", "span": { - "startLine": 755, + "startLine": 866, "startColumn": 9, - "endLine": 755, + "endLine": 866, "endColumn": 51 }, "decision": { @@ -750,15 +814,15 @@ } }, { - "id": "decision:step:agenticSendCustomerMessageStep:964:9", + "id": "decision:step:agenticSendCustomerMessageStep:1165:9", "kind": "decision", "name": "goTo", "parentId": "step:agenticSendCustomerMessageStep", "phase": "execute", "span": { - "startLine": 964, + "startLine": 1165, "startColumn": 9, - "endLine": 964, + "endLine": 1165, "endColumn": 53 }, "decision": { @@ -766,67 +830,67 @@ } }, { - "id": "decision:step:agenticSubscriptionFailedStep:1043:9", + "id": "decision:step:agenticSubscriptionFailedStep:1248:9", "kind": "decision", "name": "goTo", "parentId": "step:agenticSubscriptionFailedStep", "phase": "execute", "span": { - "startLine": 1043, + "startLine": 1248, "startColumn": 9, - "endLine": 1043, - "endColumn": 63 + "endLine": 1248, + "endColumn": 64 }, "decision": { "type": "goTo" } }, { - "id": "decision:step:agenticVerifyBillingStep:882:10", + "id": "decision:step:agenticVerifyBillingStep:1003:10", "kind": "decision", "name": "goTo", "parentId": "step:agenticVerifyBillingStep", - "condition": "outcome == refundmodel.BillingConfirmed", + "condition": "!(outcome == refundmodel.BillingConfirmed) and outcome == refundmodel.BillingUnknown", "phase": "execute", "span": { - "startLine": 882, + "startLine": 1003, "startColumn": 10, - "endLine": 882, - "endColumn": 62 + "endLine": 1003, + "endColumn": 54 }, "decision": { "type": "goTo" } }, { - "id": "decision:step:agenticVerifyBillingStep:888:10", + "id": "decision:step:agenticVerifyBillingStep:1005:9", "kind": "decision", "name": "goTo", "parentId": "step:agenticVerifyBillingStep", - "condition": "!(outcome == refundmodel.BillingConfirmed) and outcome == refundmodel.BillingUnknown", + "condition": "!(outcome == refundmodel.BillingConfirmed) and !(outcome == refundmodel.BillingUnknown)", "phase": "execute", "span": { - "startLine": 888, - "startColumn": 10, - "endLine": 888, - "endColumn": 54 + "startLine": 1005, + "startColumn": 9, + "endLine": 1005, + "endColumn": 57 }, "decision": { "type": "goTo" } }, { - "id": "decision:step:agenticVerifyBillingStep:890:9", + "id": "decision:step:agenticVerifyBillingStep:997:10", "kind": "decision", "name": "goTo", "parentId": "step:agenticVerifyBillingStep", - "condition": "!(outcome == refundmodel.BillingConfirmed) and !(outcome == refundmodel.BillingUnknown)", + "condition": "outcome == refundmodel.BillingConfirmed", "phase": "execute", "span": { - "startLine": 890, - "startColumn": 9, - "endLine": 890, - "endColumn": 57 + "startLine": 997, + "startColumn": 10, + "endLine": 997, + "endColumn": 62 }, "decision": { "type": "goTo" @@ -837,9 +901,9 @@ "kind": "attribute", "name": "billing-key", "span": { - "startLine": 107, + "startLine": 114, "startColumn": 5, - "endLine": 107, + "endLine": 114, "endColumn": 67 }, "resource": { @@ -851,9 +915,9 @@ "kind": "attribute", "name": "billing-outcome", "span": { - "startLine": 109, + "startLine": 116, "startColumn": 5, - "endLine": 109, + "endLine": 116, "endColumn": 75 }, "resource": { @@ -865,9 +929,9 @@ "kind": "attribute", "name": "bound-action", "span": { - "startLine": 97, + "startLine": 104, "startColumn": 5, - "endLine": 97, + "endLine": 104, "endColumn": 69 }, "resource": { @@ -879,9 +943,9 @@ "kind": "attribute", "name": "ev-history-cancel-requested", "span": { - "startLine": 79, + "startLine": 86, "startColumn": 5, - "endLine": 79, + "endLine": 86, "endColumn": 90 }, "resource": { @@ -893,9 +957,9 @@ "kind": "attribute", "name": "case-status", "span": { - "startLine": 118, + "startLine": 134, "startColumn": 5, - "endLine": 121, + "endLine": 137, "endColumn": 2 }, "resource": { @@ -907,23 +971,51 @@ "kind": "attribute", "name": "in-charge-ref", "span": { - "startLine": 57, + "startLine": 64, "startColumn": 5, - "endLine": 57, + "endLine": 64, "endColumn": 74 }, "resource": { "valueType": "string" } }, + { + "id": "resource:attribute:agenticCustomerEmail", + "kind": "attribute", + "name": "customer-email", + "span": { + "startLine": 59, + "startColumn": 5, + "endLine": 62, + "endColumn": 2 + }, + "resource": { + "valueType": "string" + } + }, + { + "id": "resource:attribute:agenticCustomerMessageDraft", + "kind": "attribute", + "name": "customer-message-draft", + "span": { + "startLine": 131, + "startColumn": 5, + "endLine": 131, + "endColumn": 88 + }, + "resource": { + "valueType": "string" + } + }, { "id": "resource:attribute:agenticDecisionRounds", "kind": "attribute", "name": "decision-rounds", "span": { - "startLine": 91, + "startLine": 98, "startColumn": 5, - "endLine": 91, + "endLine": 98, "endColumn": 74 }, "resource": { @@ -935,9 +1027,9 @@ "kind": "attribute", "name": "email-sent", "span": { - "startLine": 113, + "startLine": 120, "startColumn": 5, - "endLine": 113, + "endLine": 120, "endColumn": 65 }, "resource": { @@ -949,9 +1041,9 @@ "kind": "attribute", "name": "evidence-state", "span": { - "startLine": 85, + "startLine": 92, "startColumn": 5, - "endLine": 85, + "endLine": 92, "endColumn": 73 }, "resource": { @@ -963,9 +1055,9 @@ "kind": "attribute", "name": "gate-entries", "span": { - "startLine": 105, + "startLine": 112, "startColumn": 5, - "endLine": 105, + "endLine": 112, "endColumn": 68 }, "resource": { @@ -977,9 +1069,9 @@ "kind": "attribute", "name": "gate-request-key", "span": { - "startLine": 103, + "startLine": 110, "startColumn": 5, - "endLine": 103, + "endLine": 110, "endColumn": 76 }, "resource": { @@ -991,9 +1083,9 @@ "kind": "attribute", "name": "guardrail-rule", "span": { - "startLine": 95, + "startLine": 102, "startColumn": 5, - "endLine": 95, + "endLine": 102, "endColumn": 73 }, "resource": { @@ -1005,9 +1097,9 @@ "kind": "attribute", "name": "guardrail-verdict", "span": { - "startLine": 93, + "startLine": 100, "startColumn": 5, - "endLine": 93, + "endLine": 100, "endColumn": 79 }, "resource": { @@ -1019,9 +1111,9 @@ "kind": "attribute", "name": "ev-history-status", "span": { - "startLine": 73, + "startLine": 80, "startColumn": 5, - "endLine": 73, + "endLine": 80, "endColumn": 76 }, "resource": { @@ -1033,9 +1125,9 @@ "kind": "attribute", "name": "ev-identity-status", "span": { - "startLine": 61, + "startLine": 68, "startColumn": 5, - "endLine": 61, + "endLine": 68, "endColumn": 78 }, "resource": { @@ -1047,9 +1139,9 @@ "kind": "attribute", "name": "ev-incident-days", "span": { - "startLine": 83, + "startLine": 90, "startColumn": 5, - "endLine": 83, + "endLine": 90, "endColumn": 73 }, "resource": { @@ -1061,9 +1153,9 @@ "kind": "attribute", "name": "ev-incident-status", "span": { - "startLine": 81, + "startLine": 88, "startColumn": 5, - "endLine": 81, + "endLine": 88, "endColumn": 78 }, "resource": { @@ -1075,9 +1167,9 @@ "kind": "attribute", "name": "in-email", "span": { - "startLine": 55, + "startLine": 56, "startColumn": 5, - "endLine": 55, + "endLine": 56, "endColumn": 64 }, "resource": { @@ -1089,9 +1181,9 @@ "kind": "attribute", "name": "manager-rejection-reason", "span": { - "startLine": 101, + "startLine": 108, "startColumn": 5, - "endLine": 101, + "endLine": 108, "endColumn": 92 }, "resource": { @@ -1103,9 +1195,9 @@ "kind": "attribute", "name": "manager-verdict", "span": { - "startLine": 99, + "startLine": 106, "startColumn": 5, - "endLine": 99, + "endLine": 106, "endColumn": 75 }, "resource": { @@ -1117,9 +1209,9 @@ "kind": "attribute", "name": "operator-note", "span": { - "startLine": 115, + "startLine": 122, "startColumn": 5, - "endLine": 115, + "endLine": 122, "endColumn": 71 }, "resource": { @@ -1131,9 +1223,9 @@ "kind": "attribute", "name": "ev-payment-age-days", "span": { - "startLine": 67, + "startLine": 74, "startColumn": 5, - "endLine": 67, + "endLine": 74, "endColumn": 78 }, "resource": { @@ -1145,9 +1237,9 @@ "kind": "attribute", "name": "ev-payment-amount", "span": { - "startLine": 59, + "startLine": 66, "startColumn": 5, - "endLine": 59, + "endLine": 66, "endColumn": 76 }, "resource": { @@ -1159,9 +1251,9 @@ "kind": "attribute", "name": "ev-payment-status", "span": { - "startLine": 65, + "startLine": 72, "startColumn": 5, - "endLine": 65, + "endLine": 72, "endColumn": 76 }, "resource": { @@ -1173,9 +1265,9 @@ "kind": "attribute", "name": "ev-history-prior-refunds", "span": { - "startLine": 77, + "startLine": 84, "startColumn": 5, - "endLine": 77, + "endLine": 84, "endColumn": 81 }, "resource": { @@ -1187,9 +1279,9 @@ "kind": "attribute", "name": "recommendation-rationale", "span": { - "startLine": 89, + "startLine": 96, "startColumn": 5, - "endLine": 89, + "endLine": 96, "endColumn": 79 }, "resource": { @@ -1201,23 +1293,37 @@ "kind": "attribute", "name": "recommended-action", "span": { - "startLine": 87, + "startLine": 94, "startColumn": 5, - "endLine": 87, + "endLine": 94, "endColumn": 81 }, "resource": { "valueType": "string" } }, + { + "id": "resource:attribute:agenticRefundAmount", + "kind": "attribute", + "name": "refund-amount", + "span": { + "startLine": 125, + "startColumn": 5, + "endLine": 128, + "endColumn": 2 + }, + "resource": { + "valueType": "float64" + } + }, { "id": "resource:attribute:agenticSubscriptionApplied", "kind": "attribute", "name": "subscription-applied", "span": { - "startLine": 111, + "startLine": 118, "startColumn": 5, - "endLine": 111, + "endLine": 118, "endColumn": 85 }, "resource": { @@ -1229,9 +1335,9 @@ "kind": "attribute", "name": "ev-subscription-status", "span": { - "startLine": 63, + "startLine": 70, "startColumn": 5, - "endLine": 63, + "endLine": 70, "endColumn": 86 }, "resource": { @@ -1243,9 +1349,9 @@ "kind": "attribute", "name": "ev-history-tenure-years", "span": { - "startLine": 75, + "startLine": 82, "startColumn": 5, - "endLine": 75, + "endLine": 82, "endColumn": 79 }, "resource": { @@ -1257,9 +1363,9 @@ "kind": "attribute", "name": "ev-usage-pct-of-allowance", "span": { - "startLine": 71, + "startLine": 78, "startColumn": 5, - "endLine": 71, + "endLine": 78, "endColumn": 82 }, "resource": { @@ -1271,9 +1377,9 @@ "kind": "attribute", "name": "ev-usage-status", "span": { - "startLine": 69, + "startLine": 76, "startColumn": 5, - "endLine": 69, + "endLine": 76, "endColumn": 72 }, "resource": { @@ -1285,9 +1391,24 @@ "kind": "channel", "name": "manager-approval", "span": { - "startLine": 123, + "startLine": 139, "startColumn": 5, - "endLine": 123, + "endLine": 139, + "endColumn": 78 + }, + "resource": { + "valueType": "string", + "map": true + } + }, + { + "id": "resource:channel:agenticMessageApproval", + "kind": "channel", + "name": "message-approval", + "span": { + "startLine": 141, + "startColumn": 5, + "endLine": 141, "endColumn": 78 }, "resource": { @@ -1300,9 +1421,31 @@ "kind": "rpc", "name": "ApproveRefund", "span": { - "startLine": 323, + "startLine": 370, + "startColumn": 1, + "endLine": 382, + "endColumn": 2 + } + }, + { + "id": "rpc:ConfirmCustomerMessage", + "kind": "rpc", + "name": "ConfirmCustomerMessage", + "span": { + "startLine": 414, + "startColumn": 1, + "endLine": 429, + "endColumn": 2 + } + }, + { + "id": "rpc:EditCustomerMessage", + "kind": "rpc", + "name": "EditCustomerMessage", + "span": { + "startLine": 435, "startColumn": 1, - "endLine": 335, + "endLine": 456, "endColumn": 2 } }, @@ -1311,9 +1454,9 @@ "kind": "rpc", "name": "GetDexDisplay", "span": { - "startLine": 274, + "startLine": 317, "startColumn": 1, - "endLine": 319, + "endLine": 366, "endColumn": 2 } }, @@ -1322,9 +1465,9 @@ "kind": "rpc", "name": "GetDexSummary", "span": { - "startLine": 233, + "startLine": 274, "startColumn": 1, - "endLine": 259, + "endLine": 300, "endColumn": 2 } }, @@ -1333,9 +1476,9 @@ "kind": "rpc", "name": "RejectRefund", "span": { - "startLine": 341, + "startLine": 388, "startColumn": 1, - "endLine": 362, + "endLine": 409, "endColumn": 2 } }, @@ -1345,10 +1488,13 @@ "name": "ApplySubscriptionStep", "phase": "execute", "span": { - "startLine": 163, + "startLine": 190, "startColumn": 3, - "endLine": 163, + "endLine": 190, "endColumn": 70 + }, + "metadata": { + "explanation": "Apply any subscription change required by the resolution." } }, { @@ -1357,10 +1503,13 @@ "name": "AuditIntentStep", "phase": "execute", "span": { - "startLine": 159, + "startLine": 186, "startColumn": 3, - "endLine": 159, + "endLine": 186, "endColumn": 43 + }, + "metadata": { + "explanation": "Record the approved refund intent before billing changes." } }, { @@ -1369,10 +1518,13 @@ "name": "BillingFailedStep", "phase": "execute", "span": { - "startLine": 167, + "startLine": 196, "startColumn": 3, - "endLine": 167, + "endLine": 196, "endColumn": 45 + }, + "metadata": { + "explanation": "Handle a billing failure during refund or credit." } }, { @@ -1381,10 +1533,13 @@ "name": "CheckIdentityStep", "phase": "execute", "span": { - "startLine": 150, + "startLine": 177, "startColumn": 3, - "endLine": 150, + "endLine": 177, "endColumn": 66 + }, + "metadata": { + "explanation": "Verify the customer identity before collecting further evidence." } }, { @@ -1393,10 +1548,13 @@ "name": "CheckIncidentsStep", "phase": "execute", "span": { - "startLine": 155, + "startLine": 182, "startColumn": 3, - "endLine": 155, + "endLine": 182, "endColumn": 67 + }, + "metadata": { + "explanation": "Check for active incidents that affect refund policy." } }, { @@ -1405,10 +1563,28 @@ "name": "CloseCaseStep", "phase": "execute", "span": { - "startLine": 170, + "startLine": 199, "startColumn": 3, - "endLine": 170, + "endLine": 199, "endColumn": 41 + }, + "metadata": { + "explanation": "Close the refund case once resolution is final." + } + }, + { + "id": "step:agenticConfirmCustomerMessageStep", + "kind": "step", + "name": "ConfirmCustomerMessageStep", + "phase": "wait_for+execute", + "span": { + "startLine": 192, + "startColumn": 3, + "endLine": 192, + "endColumn": 54 + }, + "metadata": { + "explanation": "Wait for a person to confirm or rewrite the customer message." } }, { @@ -1417,10 +1593,28 @@ "name": "AgentDecisionStep", "phase": "execute", "span": { - "startLine": 149, + "startLine": 176, "startColumn": 3, - "endLine": 149, + "endLine": 176, "endColumn": 40 + }, + "metadata": { + "explanation": "Choose the next capability from gathered evidence and guardrails." + } + }, + { + "id": "step:agenticDraftCustomerMessageStep", + "kind": "step", + "name": "DraftCustomerMessageStep", + "phase": "execute", + "span": { + "startLine": 191, + "startColumn": 3, + "endLine": 191, + "endColumn": 52 + }, + "metadata": { + "explanation": "Draft the customer message and open it for a person to confirm." } }, { @@ -1429,10 +1623,13 @@ "name": "EmailFailedStep", "phase": "execute", "span": { - "startLine": 169, + "startLine": 198, "startColumn": 3, - "endLine": 169, + "endLine": 198, "endColumn": 43 + }, + "metadata": { + "explanation": "Handle a failure sending the customer resolution message." } }, { @@ -1441,10 +1638,13 @@ "name": "GetPaymentStep", "phase": "execute", "span": { - "startLine": 152, + "startLine": 179, "startColumn": 3, - "endLine": 152, + "endLine": 179, "endColumn": 63 + }, + "metadata": { + "explanation": "Load the payment and charge evidence for the refund." } }, { @@ -1453,10 +1653,13 @@ "name": "GetSubscriptionStep", "phase": "execute", "span": { - "startLine": 151, + "startLine": 178, "startColumn": 3, - "endLine": 151, + "endLine": 178, "endColumn": 68 + }, + "metadata": { + "explanation": "Load the customer's subscription details for the case." } }, { @@ -1465,10 +1668,13 @@ "name": "GetSupportHistoryStep", "phase": "execute", "span": { - "startLine": 154, + "startLine": 181, "startColumn": 3, - "endLine": 154, + "endLine": 181, "endColumn": 70 + }, + "metadata": { + "explanation": "Load prior support history for this customer." } }, { @@ -1477,10 +1683,13 @@ "name": "GetUsageStep", "phase": "execute", "span": { - "startLine": 153, + "startLine": 180, "startColumn": 3, - "endLine": 153, + "endLine": 180, "endColumn": 61 + }, + "metadata": { + "explanation": "Load usage signals that may support or deny a refund." } }, { @@ -1489,10 +1698,13 @@ "name": "GuardrailStep", "phase": "execute", "span": { - "startLine": 156, + "startLine": 183, "startColumn": 3, - "endLine": 156, + "endLine": 183, "endColumn": 41 + }, + "metadata": { + "explanation": "Apply refund guardrails and set the recommended action." } }, { @@ -1501,10 +1713,13 @@ "name": "IssueRefundStep", "phase": "execute", "span": { - "startLine": 160, + "startLine": 187, "startColumn": 3, - "endLine": 160, + "endLine": 187, "endColumn": 64 + }, + "metadata": { + "explanation": "Issue the refund through billing." } }, { @@ -1513,10 +1728,13 @@ "name": "NonConvergenceStep", "phase": "execute", "span": { - "startLine": 165, + "startLine": 194, "startColumn": 3, - "endLine": 165, + "endLine": 194, "endColumn": 46 + }, + "metadata": { + "explanation": "Close the loop when the agent cannot converge on an action." } }, { @@ -1525,10 +1743,13 @@ "name": "NotARefundStep", "phase": "execute", "span": { - "startLine": 166, + "startLine": 195, "startColumn": 3, - "endLine": 166, + "endLine": 195, "endColumn": 42 + }, + "metadata": { + "explanation": "Mark the case as not a refund request." } }, { @@ -1537,10 +1758,13 @@ "name": "OfferAccountCreditStep", "phase": "execute", "span": { - "startLine": 161, + "startLine": 188, "startColumn": 3, - "endLine": 161, + "endLine": 188, "endColumn": 71 + }, + "metadata": { + "explanation": "Offer account credit instead of a cash refund." } }, { @@ -1549,10 +1773,13 @@ "name": "ReCheckStep", "phase": "execute", "span": { - "startLine": 157, + "startLine": 184, "startColumn": 3, - "endLine": 157, + "endLine": 184, "endColumn": 39 + }, + "metadata": { + "explanation": "Re-check evidence after a capability returns to the decision loop." } }, { @@ -1562,10 +1789,13 @@ "phase": "execute", "start": true, "span": { - "startLine": 148, + "startLine": 175, "startColumn": 3, - "endLine": 148, + "endLine": 175, "endColumn": 51 + }, + "metadata": { + "explanation": "Store the inbound refund request and open or reject the case." } }, { @@ -1574,10 +1804,13 @@ "name": "RequestHumanApprovalStep", "phase": "wait_for+execute", "span": { - "startLine": 158, + "startLine": 185, "startColumn": 3, - "endLine": 158, + "endLine": 185, "endColumn": 52 + }, + "metadata": { + "explanation": "Ask a human to approve or reject the recommended refund action." } }, { @@ -1586,10 +1819,13 @@ "name": "SendCustomerMessageStep", "phase": "execute", "span": { - "startLine": 164, + "startLine": 193, "startColumn": 3, - "endLine": 164, + "endLine": 193, "endColumn": 72 + }, + "metadata": { + "explanation": "Send the customer the message a person confirmed." } }, { @@ -1598,10 +1834,13 @@ "name": "SubscriptionFailedStep", "phase": "execute", "span": { - "startLine": 168, + "startLine": 197, "startColumn": 3, - "endLine": 168, + "endLine": 197, "endColumn": 50 + }, + "metadata": { + "explanation": "Handle a subscription update failure after a resolution." } }, { @@ -1610,22 +1849,55 @@ "name": "VerifyBillingStep", "phase": "execute", "span": { - "startLine": 162, + "startLine": 189, "startColumn": 3, - "endLine": 162, + "endLine": 189, "endColumn": 66 + }, + "metadata": { + "explanation": "Verify the billing outcome after a refund or credit." + } + }, + { + "id": "wait:agenticConfirmCustomerMessageStep:1105:9", + "kind": "wait", + "name": "until", + "parentId": "step:agenticConfirmCustomerMessageStep", + "phase": "wait_for", + "span": { + "startLine": 1105, + "startColumn": 9, + "endLine": 1105, + "endColumn": 65 + }, + "wait": { + "type": "until", + "conditions": [ + { + "kind": "channel", + "label": "message-approval[gateRequestKey].for 1", + "resourceId": "resource:channel:agenticMessageApproval", + "expression": "gateRequestKey", + "span": { + "startLine": 1105, + "startColumn": 19, + "endLine": 1105, + "endColumn": 64 + } + } + ] } }, { - "id": "wait:agenticRequestHumanApprovalStep:734:9", + "id": "wait:agenticRequestHumanApprovalStep:845:9", "kind": "wait", "name": "until", "parentId": "step:agenticRequestHumanApprovalStep", "phase": "wait_for", "span": { - "startLine": 734, + "startLine": 845, "startColumn": 9, - "endLine": 734, + "endLine": 845, "endColumn": 65 }, "wait": { @@ -1637,9 +1909,9 @@ "resourceId": "resource:channel:agenticManagerApproval", "expression": "gateRequestKey", "span": { - "startLine": 734, + "startLine": 845, "startColumn": 19, - "endLine": 734, + "endLine": 845, "endColumn": 64 } } @@ -1651,507 +1923,533 @@ { "id": "edge:0001", "kind": "transition", - "from": "decision:step:agenticApplySubscriptionStep:917:9", - "to": "step:agenticSendCustomerMessageStep", + "from": "decision:step:agenticApplySubscriptionStep:1033:9", + "to": "step:agenticDraftCustomerMessageStep", "label": "GoTo", "span": { - "startLine": 917, + "startLine": 1033, "startColumn": 9, - "endLine": 917, - "endColumn": 63 + "endLine": 1033, + "endColumn": 64 } }, { "id": "edge:0002", "kind": "transition", - "from": "decision:step:agenticAuditIntentStep:776:10", + "from": "decision:step:agenticAuditIntentStep:888:10", "to": "step:agenticOfferAccountCreditStep", "label": "GoTo", "span": { - "startLine": 776, + "startLine": 888, "startColumn": 10, - "endLine": 776, + "endLine": 888, "endColumn": 63 } }, { "id": "edge:0003", "kind": "transition", - "from": "decision:step:agenticAuditIntentStep:778:9", + "from": "decision:step:agenticAuditIntentStep:890:9", "to": "step:agenticIssueRefundStep", "label": "GoTo", "span": { - "startLine": 778, + "startLine": 890, "startColumn": 9, - "endLine": 778, + "endLine": 890, "endColumn": 55 } }, { "id": "edge:0004", "kind": "transition", - "from": "decision:step:agenticBillingFailedStep:1021:9", - "to": "step:agenticSendCustomerMessageStep", + "from": "decision:step:agenticBillingFailedStep:1225:9", + "to": "step:agenticDraftCustomerMessageStep", "label": "GoTo", "span": { - "startLine": 1021, + "startLine": 1225, "startColumn": 9, - "endLine": 1021, - "endColumn": 63 + "endLine": 1225, + "endColumn": 64 } }, { "id": "edge:0005", "kind": "transition", - "from": "decision:step:agenticCheckIdentityStep:496:9", + "from": "decision:step:agenticCheckIdentityStep:596:9", "to": "step:agenticDecisionStep", "label": "GoTo", "span": { - "startLine": 496, + "startLine": 596, "startColumn": 9, - "endLine": 496, + "endLine": 596, "endColumn": 52 } }, { "id": "edge:0006", "kind": "transition", - "from": "decision:step:agenticCheckIncidentsStep:624:9", + "from": "decision:step:agenticCheckIncidentsStep:732:9", "to": "step:agenticDecisionStep", "label": "GoTo", "span": { - "startLine": 624, + "startLine": 732, "startColumn": 9, - "endLine": 624, + "endLine": 732, "endColumn": 52 } }, { "id": "edge:0007", "kind": "transition", - "from": "decision:step:agenticDecisionStep:416:10", - "to": "step:agenticNonConvergenceStep", + "from": "decision:step:agenticConfirmCustomerMessageStep:1123:9", + "to": "step:agenticSendCustomerMessageStep", "label": "GoTo", "span": { - "startLine": 416, + "startLine": 1123, + "startColumn": 9, + "endLine": 1123, + "endColumn": 63 + } + }, + { + "id": "edge:0008", + "kind": "transition", + "from": "decision:step:agenticDecisionStep:515:10", + "to": "step:agenticNonConvergenceStep", + "label": "GoTo", + "span": { + "startLine": 515, "startColumn": 10, - "endLine": 416, + "endLine": 515, "endColumn": 59 } }, { - "id": "edge:0008", + "id": "edge:0009", "kind": "transition", - "from": "decision:step:agenticDecisionStep:429:10", + "from": "decision:step:agenticDecisionStep:528:10", "to": "step:agenticCheckIdentityStep", "label": "GoTo", "span": { - "startLine": 429, + "startLine": 528, "startColumn": 10, - "endLine": 429, + "endLine": 528, "endColumn": 58 } }, { - "id": "edge:0009", + "id": "edge:0010", "kind": "transition", - "from": "decision:step:agenticDecisionStep:436:10", + "from": "decision:step:agenticDecisionStep:535:10", "to": "step:agenticGetSubscriptionStep", "label": "GoTo", "span": { - "startLine": 436, + "startLine": 535, "startColumn": 10, - "endLine": 436, + "endLine": 535, "endColumn": 60 } }, { - "id": "edge:0010", + "id": "edge:0011", "kind": "transition", - "from": "decision:step:agenticDecisionStep:443:10", + "from": "decision:step:agenticDecisionStep:542:10", "to": "step:agenticGetPaymentStep", "label": "GoTo", "span": { - "startLine": 443, + "startLine": 542, "startColumn": 10, - "endLine": 443, + "endLine": 542, "endColumn": 55 } }, { - "id": "edge:0011", + "id": "edge:0012", "kind": "transition", - "from": "decision:step:agenticDecisionStep:450:10", + "from": "decision:step:agenticDecisionStep:549:10", "to": "step:agenticGetUsageStep", "label": "GoTo", "span": { - "startLine": 450, + "startLine": 549, "startColumn": 10, - "endLine": 450, + "endLine": 549, "endColumn": 53 } }, { - "id": "edge:0012", + "id": "edge:0013", "kind": "transition", - "from": "decision:step:agenticDecisionStep:457:10", + "from": "decision:step:agenticDecisionStep:556:10", "to": "step:agenticGetSupportHistoryStep", "label": "GoTo", "span": { - "startLine": 457, + "startLine": 556, "startColumn": 10, - "endLine": 457, + "endLine": 556, "endColumn": 62 } }, { - "id": "edge:0013", + "id": "edge:0014", "kind": "transition", - "from": "decision:step:agenticDecisionStep:464:10", + "from": "decision:step:agenticDecisionStep:563:10", "to": "step:agenticCheckIncidentsStep", "label": "GoTo", "span": { - "startLine": 464, + "startLine": 563, "startColumn": 10, - "endLine": 464, + "endLine": 563, "endColumn": 59 } }, { - "id": "edge:0014", + "id": "edge:0015", "kind": "transition", - "from": "decision:step:agenticDecisionStep:476:9", + "from": "decision:step:agenticDecisionStep:575:9", "to": "step:agenticGuardrailStep", "label": "GoTo", "span": { - "startLine": 476, + "startLine": 575, "startColumn": 9, - "endLine": 476, + "endLine": 575, "endColumn": 53 } }, { - "id": "edge:0015", + "id": "edge:0016", "kind": "transition", - "from": "decision:step:agenticEmailFailedStep:1065:9", + "from": "decision:step:agenticDraftCustomerMessageStep:1084:9", + "to": "step:agenticConfirmCustomerMessageStep", + "label": "GoTo", + "span": { + "startLine": 1084, + "startColumn": 9, + "endLine": 1084, + "endColumn": 66 + } + }, + { + "id": "edge:0017", + "kind": "transition", + "from": "decision:step:agenticEmailFailedStep:1271:9", "to": "step:agenticCloseCaseStep", "label": "GoTo", "span": { - "startLine": 1065, + "startLine": 1271, "startColumn": 9, - "endLine": 1065, + "endLine": 1271, "endColumn": 53 } }, { - "id": "edge:0016", + "id": "edge:0018", "kind": "transition", - "from": "decision:step:agenticGetPaymentStep:543:9", + "from": "decision:step:agenticGetPaymentStep:648:9", "to": "step:agenticDecisionStep", "label": "GoTo", "span": { - "startLine": 543, + "startLine": 648, "startColumn": 9, - "endLine": 543, + "endLine": 648, "endColumn": 52 } }, { - "id": "edge:0017", + "id": "edge:0019", "kind": "transition", - "from": "decision:step:agenticGetSubscriptionStep:516:9", + "from": "decision:step:agenticGetSubscriptionStep:617:9", "to": "step:agenticDecisionStep", "label": "GoTo", "span": { - "startLine": 516, + "startLine": 617, "startColumn": 9, - "endLine": 516, + "endLine": 617, "endColumn": 52 } }, { - "id": "edge:0018", + "id": "edge:0020", "kind": "transition", - "from": "decision:step:agenticGetSupportHistoryStep:597:9", + "from": "decision:step:agenticGetSupportHistoryStep:704:9", "to": "step:agenticDecisionStep", "label": "GoTo", "span": { - "startLine": 597, + "startLine": 704, "startColumn": 9, - "endLine": 597, + "endLine": 704, "endColumn": 52 } }, { - "id": "edge:0019", + "id": "edge:0021", "kind": "transition", - "from": "decision:step:agenticGetUsageStep:567:9", + "from": "decision:step:agenticGetUsageStep:673:9", "to": "step:agenticDecisionStep", "label": "GoTo", "span": { - "startLine": 567, + "startLine": 673, "startColumn": 9, - "endLine": 567, + "endLine": 673, "endColumn": 52 } }, { - "id": "edge:0020", + "id": "edge:0022", "kind": "transition", - "from": "decision:step:agenticGuardrailStep:677:10", + "from": "decision:step:agenticGuardrailStep:786:10", "to": "step:agenticRequestHumanApprovalStep", "label": "GoTo", "span": { - "startLine": 677, + "startLine": 786, "startColumn": 10, - "endLine": 677, + "endLine": 786, "endColumn": 65 } }, { - "id": "edge:0021", + "id": "edge:0023", "kind": "transition", - "from": "decision:step:agenticGuardrailStep:682:9", + "from": "decision:step:agenticGuardrailStep:791:9", "to": "step:agenticAuditIntentStep", "label": "GoTo", "span": { - "startLine": 682, + "startLine": 791, "startColumn": 9, - "endLine": 682, + "endLine": 791, "endColumn": 55 } }, { - "id": "edge:0022", + "id": "edge:0024", "kind": "transition", - "from": "decision:step:agenticIssueRefundStep:817:10", + "from": "decision:step:agenticIssueRefundStep:930:10", "to": "step:agenticApplySubscriptionStep", "label": "GoTo", "span": { - "startLine": 817, + "startLine": 930, "startColumn": 10, - "endLine": 817, + "endLine": 930, "endColumn": 62 } }, { - "id": "edge:0023", + "id": "edge:0025", "kind": "transition", - "from": "decision:step:agenticIssueRefundStep:819:10", + "from": "decision:step:agenticIssueRefundStep:932:10", "to": "step:agenticVerifyBillingStep", "label": "GoTo", "span": { - "startLine": 819, + "startLine": 932, "startColumn": 10, - "endLine": 819, + "endLine": 932, "endColumn": 58 } }, { - "id": "edge:0024", + "id": "edge:0026", "kind": "transition", - "from": "decision:step:agenticIssueRefundStep:821:10", + "from": "decision:step:agenticIssueRefundStep:934:10", "to": "step:agenticBillingFailedStep", "label": "GoTo", "span": { - "startLine": 821, + "startLine": 934, "startColumn": 10, - "endLine": 821, + "endLine": 934, "endColumn": 58 } }, { - "id": "edge:0025", + "id": "edge:0027", "kind": "transition", - "from": "decision:step:agenticNonConvergenceStep:983:9", + "from": "decision:step:agenticNonConvergenceStep:1185:9", "to": "step:agenticCloseCaseStep", "label": "GoTo", "span": { - "startLine": 983, + "startLine": 1185, "startColumn": 9, - "endLine": 983, + "endLine": 1185, "endColumn": 53 } }, { - "id": "edge:0026", + "id": "edge:0028", "kind": "transition", - "from": "decision:step:agenticNotARefundStep:999:9", + "from": "decision:step:agenticNotARefundStep:1202:9", "to": "step:agenticCloseCaseStep", "label": "GoTo", "span": { - "startLine": 999, + "startLine": 1202, "startColumn": 9, - "endLine": 999, + "endLine": 1202, "endColumn": 53 } }, { - "id": "edge:0027", + "id": "edge:0029", "kind": "transition", - "from": "decision:step:agenticOfferAccountCreditStep:848:10", + "from": "decision:step:agenticOfferAccountCreditStep:962:10", "to": "step:agenticBillingFailedStep", "label": "GoTo", "span": { - "startLine": 848, + "startLine": 962, "startColumn": 10, - "endLine": 848, + "endLine": 962, "endColumn": 58 } }, { - "id": "edge:0028", + "id": "edge:0030", "kind": "transition", - "from": "decision:step:agenticOfferAccountCreditStep:853:9", + "from": "decision:step:agenticOfferAccountCreditStep:967:9", "to": "step:agenticApplySubscriptionStep", "label": "GoTo", "span": { - "startLine": 853, + "startLine": 967, "startColumn": 9, - "endLine": 853, + "endLine": 967, "endColumn": 61 } }, { - "id": "edge:0029", + "id": "edge:0031", "kind": "transition", - "from": "decision:step:agenticReCheckStep:706:10", - "to": "step:agenticSendCustomerMessageStep", + "from": "decision:step:agenticReCheckStep:816:10", + "to": "step:agenticDraftCustomerMessageStep", "label": "GoTo", "span": { - "startLine": 706, + "startLine": 816, "startColumn": 10, - "endLine": 706, - "endColumn": 64 + "endLine": 816, + "endColumn": 65 } }, { - "id": "edge:0030", + "id": "edge:0032", "kind": "transition", - "from": "decision:step:agenticReCheckStep:714:9", + "from": "decision:step:agenticReCheckStep:824:9", "to": "step:agenticAuditIntentStep", "label": "GoTo", "span": { - "startLine": 714, + "startLine": 824, "startColumn": 9, - "endLine": 714, + "endLine": 824, "endColumn": 55 } }, { - "id": "edge:0031", + "id": "edge:0033", "kind": "transition", - "from": "decision:step:agenticReceiveRequestStep:390:10", + "from": "decision:step:agenticReceiveRequestStep:488:10", "to": "step:agenticNotARefundStep", "label": "GoTo", "span": { - "startLine": 390, + "startLine": 488, "startColumn": 10, - "endLine": 390, + "endLine": 488, "endColumn": 55 } }, { - "id": "edge:0032", + "id": "edge:0034", "kind": "transition", - "from": "decision:step:agenticReceiveRequestStep:395:9", + "from": "decision:step:agenticReceiveRequestStep:493:9", "to": "step:agenticDecisionStep", "label": "GoTo", "span": { - "startLine": 395, + "startLine": 493, "startColumn": 9, - "endLine": 395, + "endLine": 493, "endColumn": 52 } }, { - "id": "edge:0033", + "id": "edge:0035", "kind": "transition", - "from": "decision:step:agenticRequestHumanApprovalStep:755:9", + "from": "decision:step:agenticRequestHumanApprovalStep:866:9", "to": "step:agenticReCheckStep", "label": "GoTo", "span": { - "startLine": 755, + "startLine": 866, "startColumn": 9, - "endLine": 755, + "endLine": 866, "endColumn": 51 } }, { - "id": "edge:0034", + "id": "edge:0036", "kind": "transition", - "from": "decision:step:agenticSendCustomerMessageStep:964:9", + "from": "decision:step:agenticSendCustomerMessageStep:1165:9", "to": "step:agenticCloseCaseStep", "label": "GoTo", "span": { - "startLine": 964, + "startLine": 1165, "startColumn": 9, - "endLine": 964, + "endLine": 1165, "endColumn": 53 } }, { - "id": "edge:0035", + "id": "edge:0037", "kind": "transition", - "from": "decision:step:agenticSubscriptionFailedStep:1043:9", - "to": "step:agenticSendCustomerMessageStep", + "from": "decision:step:agenticSubscriptionFailedStep:1248:9", + "to": "step:agenticDraftCustomerMessageStep", "label": "GoTo", "span": { - "startLine": 1043, + "startLine": 1248, "startColumn": 9, - "endLine": 1043, - "endColumn": 63 + "endLine": 1248, + "endColumn": 64 } }, { - "id": "edge:0036", + "id": "edge:0038", "kind": "transition", - "from": "decision:step:agenticVerifyBillingStep:882:10", - "to": "step:agenticApplySubscriptionStep", + "from": "decision:step:agenticVerifyBillingStep:1003:10", + "to": "step:agenticCloseCaseStep", "label": "GoTo", "span": { - "startLine": 882, + "startLine": 1003, "startColumn": 10, - "endLine": 882, - "endColumn": 62 + "endLine": 1003, + "endColumn": 54 } }, { - "id": "edge:0037", + "id": "edge:0039", "kind": "transition", - "from": "decision:step:agenticVerifyBillingStep:888:10", - "to": "step:agenticCloseCaseStep", + "from": "decision:step:agenticVerifyBillingStep:1005:9", + "to": "step:agenticBillingFailedStep", "label": "GoTo", "span": { - "startLine": 888, - "startColumn": 10, - "endLine": 888, - "endColumn": 54 + "startLine": 1005, + "startColumn": 9, + "endLine": 1005, + "endColumn": 57 } }, { - "id": "edge:0038", + "id": "edge:0040", "kind": "transition", - "from": "decision:step:agenticVerifyBillingStep:890:9", - "to": "step:agenticBillingFailedStep", + "from": "decision:step:agenticVerifyBillingStep:997:10", + "to": "step:agenticApplySubscriptionStep", "label": "GoTo", "span": { - "startLine": 890, - "startColumn": 9, - "endLine": 890, - "endColumn": 57 + "startLine": 997, + "startColumn": 10, + "endLine": 997, + "endColumn": 62 } }, { - "id": "edge:0039", + "id": "edge:0041", "kind": "resource_read", "from": "resource:attribute:agenticBillingKey", "to": "step:agenticVerifyBillingStep", "label": "Get", "span": { - "startLine": 870, + "startLine": 985, "startColumn": 14, - "endLine": 870, + "endLine": 985, "endColumn": 40 }, "metadata": { @@ -2159,15 +2457,15 @@ } }, { - "id": "edge:0040", + "id": "edge:0042", "kind": "resource_read", "from": "resource:attribute:agenticBoundAction", "to": "step:agenticAuditIntentStep", "label": "Get", "span": { - "startLine": 771, + "startLine": 883, "startColumn": 17, - "endLine": 771, + "endLine": 883, "endColumn": 44 }, "metadata": { @@ -2175,15 +2473,31 @@ } }, { - "id": "edge:0041", + "id": "edge:0043", + "kind": "resource_read", + "from": "resource:attribute:agenticCaseStatus", + "to": "step:agenticDraftCustomerMessageStep", + "label": "Get", + "span": { + "startLine": 1050, + "startColumn": 17, + "endLine": 1050, + "endColumn": 43 + }, + "metadata": { + "phase": "execute" + } + }, + { + "id": "edge:0044", "kind": "resource_read", "from": "resource:attribute:agenticCaseStatus", "to": "step:agenticSendCustomerMessageStep", "label": "Get", "span": { - "startLine": 938, + "startLine": 1145, "startColumn": 17, - "endLine": 938, + "endLine": 1145, "endColumn": 43 }, "metadata": { @@ -2191,15 +2505,63 @@ } }, { - "id": "edge:0042", + "id": "edge:0045", + "kind": "resource_read", + "from": "resource:attribute:agenticCustomerMessageDraft", + "to": "step:agenticSendCustomerMessageStep", + "label": "Get", + "span": { + "startLine": 1150, + "startColumn": 18, + "endLine": 1150, + "endColumn": 54 + }, + "metadata": { + "phase": "execute" + } + }, + { + "id": "edge:0046", + "kind": "resource_read", + "from": "resource:attribute:agenticGateRequestKey", + "to": "step:agenticConfirmCustomerMessageStep", + "label": "Get", + "span": { + "startLine": 1112, + "startColumn": 25, + "endLine": 1112, + "endColumn": 55 + }, + "metadata": { + "phase": "execute" + } + }, + { + "id": "edge:0047", + "kind": "resource_read", + "from": "resource:attribute:agenticGateRequestKey", + "to": "step:agenticConfirmCustomerMessageStep", + "label": "Get", + "span": { + "startLine": 1101, + "startColumn": 25, + "endLine": 1101, + "endColumn": 55 + }, + "metadata": { + "phase": "wait_for" + } + }, + { + "id": "edge:0048", "kind": "resource_read", "from": "resource:attribute:agenticGateRequestKey", "to": "step:agenticRequestHumanApprovalStep", "label": "Get", "span": { - "startLine": 741, + "startLine": 852, "startColumn": 25, - "endLine": 741, + "endLine": 852, "endColumn": 55 }, "metadata": { @@ -2207,15 +2569,15 @@ } }, { - "id": "edge:0043", + "id": "edge:0049", "kind": "resource_read", "from": "resource:attribute:agenticGateRequestKey", "to": "step:agenticRequestHumanApprovalStep", "label": "Get", "span": { - "startLine": 730, + "startLine": 841, "startColumn": 25, - "endLine": 730, + "endLine": 841, "endColumn": 55 }, "metadata": { @@ -2223,15 +2585,15 @@ } }, { - "id": "edge:0044", + "id": "edge:0050", "kind": "resource_read", "from": "resource:attribute:agenticManagerVerdict", "to": "step:agenticReCheckStep", "label": "Get", "span": { - "startLine": 698, + "startLine": 808, "startColumn": 18, - "endLine": 698, + "endLine": 808, "endColumn": 48 }, "metadata": { @@ -2239,15 +2601,15 @@ } }, { - "id": "edge:0045", + "id": "edge:0051", "kind": "resource_read", "from": "resource:attribute:agenticRecommendedAction", "to": "step:agenticGuardrailStep", "label": "Get", "span": { - "startLine": 640, + "startLine": 749, "startColumn": 17, - "endLine": 640, + "endLine": 749, "endColumn": 50 }, "metadata": { @@ -2255,15 +2617,15 @@ } }, { - "id": "edge:0046", + "id": "edge:0052", "kind": "resource_read", "from": "resource:channel:agenticManagerApproval", "to": "step:agenticRequestHumanApprovalStep", "label": "GetConditionResults", "span": { - "startLine": 745, + "startLine": 856, "startColumn": 19, - "endLine": 745, + "endLine": 856, "endColumn": 82 }, "metadata": { @@ -2271,28 +2633,57 @@ } }, { - "id": "edge:0047", + "id": "edge:0053", "kind": "wait_condition", "from": "resource:channel:agenticManagerApproval", - "to": "wait:agenticRequestHumanApprovalStep:734:9", + "to": "wait:agenticRequestHumanApprovalStep:845:9", "label": "manager-approval[gateRequestKey].for 1", "span": { - "startLine": 734, + "startLine": 845, "startColumn": 19, - "endLine": 734, + "endLine": 845, "endColumn": 64 } }, { - "id": "edge:0048", + "id": "edge:0054", + "kind": "resource_read", + "from": "resource:channel:agenticMessageApproval", + "to": "step:agenticConfirmCustomerMessageStep", + "label": "GetConditionResults", + "span": { + "startLine": 1116, + "startColumn": 19, + "endLine": 1116, + "endColumn": 82 + }, + "metadata": { + "phase": "execute" + } + }, + { + "id": "edge:0055", + "kind": "wait_condition", + "from": "resource:channel:agenticMessageApproval", + "to": "wait:agenticConfirmCustomerMessageStep:1105:9", + "label": "message-approval[gateRequestKey].for 1", + "span": { + "startLine": 1105, + "startColumn": 19, + "endLine": 1105, + "endColumn": 64 + } + }, + { + "id": "edge:0056", "kind": "resource_publish", "from": "rpc:ApproveRefund", "to": "resource:channel:agenticManagerApproval", "label": "Publish", "span": { - "startLine": 331, + "startLine": 378, "startColumn": 12, - "endLine": 331, + "endLine": 378, "endColumn": 74 }, "metadata": { @@ -2300,15 +2691,63 @@ } }, { - "id": "edge:0049", + "id": "edge:0057", + "kind": "resource_publish", + "from": "rpc:ConfirmCustomerMessage", + "to": "resource:channel:agenticMessageApproval", + "label": "Publish", + "span": { + "startLine": 425, + "startColumn": 12, + "endLine": 425, + "endColumn": 74 + }, + "metadata": { + "phase": "rpc" + } + }, + { + "id": "edge:0058", + "kind": "resource_write", + "from": "rpc:EditCustomerMessage", + "to": "resource:attribute:agenticCustomerMessageDraft", + "label": "Set", + "span": { + "startLine": 449, + "startColumn": 12, + "endLine": 449, + "endColumn": 63 + }, + "metadata": { + "phase": "rpc" + } + }, + { + "id": "edge:0059", + "kind": "resource_publish", + "from": "rpc:EditCustomerMessage", + "to": "resource:channel:agenticMessageApproval", + "label": "Publish", + "span": { + "startLine": 452, + "startColumn": 12, + "endLine": 452, + "endColumn": 73 + }, + "metadata": { + "phase": "rpc" + } + }, + { + "id": "edge:0060", "kind": "resource_write", "from": "rpc:RejectRefund", "to": "resource:attribute:agenticManagerRejectionReason", "label": "Set", "span": { - "startLine": 355, + "startLine": 402, "startColumn": 12, - "endLine": 355, + "endLine": 402, "endColumn": 64 }, "metadata": { @@ -2316,15 +2755,15 @@ } }, { - "id": "edge:0050", + "id": "edge:0061", "kind": "resource_publish", "from": "rpc:RejectRefund", "to": "resource:channel:agenticManagerApproval", "label": "Publish", "span": { - "startLine": 358, + "startLine": 405, "startColumn": 12, - "endLine": 358, + "endLine": 405, "endColumn": 73 }, "metadata": { @@ -2332,15 +2771,15 @@ } }, { - "id": "edge:0051", + "id": "edge:0062", "kind": "resource_write", "from": "step:agenticApplySubscriptionStep", "to": "resource:attribute:agenticSubscriptionApplied", "label": "Set", "span": { - "startLine": 914, + "startLine": 1030, "startColumn": 12, - "endLine": 914, + "endLine": 1030, "endColumn": 54 }, "metadata": { @@ -2348,15 +2787,15 @@ } }, { - "id": "edge:0052", + "id": "edge:0063", "kind": "failure_transition", "from": "step:agenticApplySubscriptionStep", "to": "step:agenticSubscriptionFailedStep", "label": "Execute failure", "span": { - "startLine": 904, + "startLine": 1020, "startColumn": 42, - "endLine": 904, + "endLine": 1020, "endColumn": 109 }, "metadata": { @@ -2364,15 +2803,15 @@ } }, { - "id": "edge:0053", + "id": "edge:0064", "kind": "resource_write", "from": "step:agenticBillingFailedStep", "to": "resource:attribute:agenticBillingOutcome", "label": "Set", "span": { - "startLine": 1015, + "startLine": 1219, "startColumn": 12, - "endLine": 1015, + "endLine": 1219, "endColumn": 71 }, "metadata": { @@ -2380,15 +2819,15 @@ } }, { - "id": "edge:0054", + "id": "edge:0065", "kind": "resource_write", "from": "step:agenticBillingFailedStep", "to": "resource:attribute:agenticCaseStatus", "label": "Set", "span": { - "startLine": 1018, + "startLine": 1222, "startColumn": 12, - "endLine": 1018, + "endLine": 1222, "endColumn": 61 }, "metadata": { @@ -2396,15 +2835,15 @@ } }, { - "id": "edge:0055", + "id": "edge:0066", "kind": "resource_write", "from": "step:agenticCheckIdentityStep", "to": "resource:attribute:agenticIdentityStatus", "label": "Set", "span": { - "startLine": 493, + "startLine": 593, "startColumn": 12, - "endLine": 493, + "endLine": 593, "endColumn": 98 }, "metadata": { @@ -2412,15 +2851,15 @@ } }, { - "id": "edge:0056", + "id": "edge:0067", "kind": "resource_write", "from": "step:agenticCheckIncidentsStep", "to": "resource:attribute:agenticEvidenceState", "label": "Set", "span": { - "startLine": 621, + "startLine": 729, "startColumn": 12, - "endLine": 621, + "endLine": 729, "endColumn": 53 }, "metadata": { @@ -2428,15 +2867,15 @@ } }, { - "id": "edge:0057", + "id": "edge:0068", "kind": "resource_write", "from": "step:agenticCheckIncidentsStep", "to": "resource:attribute:agenticIncidentDays", "label": "Set", "span": { - "startLine": 618, + "startLine": 726, "startColumn": 12, - "endLine": 618, + "endLine": 726, "endColumn": 63 }, "metadata": { @@ -2444,15 +2883,15 @@ } }, { - "id": "edge:0058", + "id": "edge:0069", "kind": "resource_write", "from": "step:agenticCheckIncidentsStep", "to": "resource:attribute:agenticIncidentStatus", "label": "Set", "span": { - "startLine": 615, + "startLine": 723, "startColumn": 12, - "endLine": 615, + "endLine": 723, "endColumn": 67 }, "metadata": { @@ -2460,15 +2899,15 @@ } }, { - "id": "edge:0059", + "id": "edge:0070", "kind": "resource_write", "from": "step:agenticDecisionStep", "to": "resource:attribute:agenticCaseStatus", "label": "Set", "span": { - "startLine": 421, + "startLine": 520, "startColumn": 12, - "endLine": 421, + "endLine": 520, "endColumn": 55 }, "metadata": { @@ -2476,15 +2915,15 @@ } }, { - "id": "edge:0060", + "id": "edge:0071", "kind": "resource_write", "from": "step:agenticDecisionStep", "to": "resource:attribute:agenticDecisionRounds", "label": "Set", "span": { - "startLine": 418, + "startLine": 517, "startColumn": 12, - "endLine": 418, + "endLine": 517, "endColumn": 52 }, "metadata": { @@ -2492,15 +2931,15 @@ } }, { - "id": "edge:0061", + "id": "edge:0072", "kind": "resource_write", "from": "step:agenticDecisionStep", "to": "resource:attribute:agenticRationale", "label": "Set", "span": { - "startLine": 473, + "startLine": 572, "startColumn": 12, - "endLine": 473, + "endLine": 572, "endColumn": 48 }, "metadata": { @@ -2508,15 +2947,15 @@ } }, { - "id": "edge:0062", + "id": "edge:0073", "kind": "resource_write", "from": "step:agenticDecisionStep", "to": "resource:attribute:agenticRecommendedAction", "label": "Set", "span": { - "startLine": 470, + "startLine": 569, "startColumn": 12, - "endLine": 470, + "endLine": 569, "endColumn": 53 }, "metadata": { @@ -2524,15 +2963,79 @@ } }, { - "id": "edge:0063", + "id": "edge:0074", + "kind": "resource_write", + "from": "step:agenticDraftCustomerMessageStep", + "to": "resource:attribute:agenticCaseStatus", + "label": "Set", + "span": { + "startLine": 1081, + "startColumn": 12, + "endLine": 1081, + "endColumn": 63 + }, + "metadata": { + "phase": "execute" + } + }, + { + "id": "edge:0075", + "kind": "resource_write", + "from": "step:agenticDraftCustomerMessageStep", + "to": "resource:attribute:agenticCustomerMessageDraft", + "label": "Set", + "span": { + "startLine": 1065, + "startColumn": 12, + "endLine": 1065, + "endColumn": 57 + }, + "metadata": { + "phase": "execute" + } + }, + { + "id": "edge:0076", + "kind": "resource_write", + "from": "step:agenticDraftCustomerMessageStep", + "to": "resource:attribute:agenticGateEntries", + "label": "Set", + "span": { + "startLine": 1075, + "startColumn": 12, + "endLine": 1075, + "endColumn": 52 + }, + "metadata": { + "phase": "execute" + } + }, + { + "id": "edge:0077", + "kind": "resource_write", + "from": "step:agenticDraftCustomerMessageStep", + "to": "resource:attribute:agenticGateRequestKey", + "label": "Set", + "span": { + "startLine": 1078, + "startColumn": 12, + "endLine": 1078, + "endColumn": 101 + }, + "metadata": { + "phase": "execute" + } + }, + { + "id": "edge:0078", "kind": "resource_write", "from": "step:agenticEmailFailedStep", "to": "resource:attribute:agenticCaseStatus", "label": "Set", "span": { - "startLine": 1062, + "startLine": 1268, "startColumn": 12, - "endLine": 1062, + "endLine": 1268, "endColumn": 64 }, "metadata": { @@ -2540,15 +3043,15 @@ } }, { - "id": "edge:0064", + "id": "edge:0079", "kind": "resource_write", "from": "step:agenticEmailFailedStep", "to": "resource:attribute:agenticEmailSent", "label": "Set", "span": { - "startLine": 1059, + "startLine": 1265, "startColumn": 12, - "endLine": 1059, + "endLine": 1265, "endColumn": 43 }, "metadata": { @@ -2556,15 +3059,15 @@ } }, { - "id": "edge:0065", + "id": "edge:0080", "kind": "resource_write", "from": "step:agenticGetPaymentStep", "to": "resource:attribute:agenticPaymentAgeDays", "label": "Set", "span": { - "startLine": 537, + "startLine": 639, "startColumn": 12, - "endLine": 537, + "endLine": 639, "endColumn": 67 }, "metadata": { @@ -2572,15 +3075,15 @@ } }, { - "id": "edge:0066", + "id": "edge:0081", "kind": "resource_write", "from": "step:agenticGetPaymentStep", "to": "resource:attribute:agenticPaymentAmount", "label": "Set", "span": { - "startLine": 540, + "startLine": 645, "startColumn": 12, - "endLine": 540, + "endLine": 645, "endColumn": 99 }, "metadata": { @@ -2588,15 +3091,15 @@ } }, { - "id": "edge:0067", + "id": "edge:0082", "kind": "resource_write", "from": "step:agenticGetPaymentStep", "to": "resource:attribute:agenticPaymentStatus", "label": "Set", "span": { - "startLine": 534, + "startLine": 636, "startColumn": 12, - "endLine": 534, + "endLine": 636, "endColumn": 65 }, "metadata": { @@ -2604,15 +3107,31 @@ } }, { - "id": "edge:0068", + "id": "edge:0083", + "kind": "resource_write", + "from": "step:agenticGetPaymentStep", + "to": "resource:attribute:agenticRefundAmount", + "label": "Set", + "span": { + "startLine": 642, + "startColumn": 12, + "endLine": 642, + "endColumn": 77 + }, + "metadata": { + "phase": "execute" + } + }, + { + "id": "edge:0084", "kind": "resource_write", "from": "step:agenticGetSubscriptionStep", "to": "resource:attribute:agenticSubscriptionStatus", "label": "Set", "span": { - "startLine": 513, + "startLine": 614, "startColumn": 12, - "endLine": 513, + "endLine": 614, "endColumn": 106 }, "metadata": { @@ -2620,15 +3139,15 @@ } }, { - "id": "edge:0069", + "id": "edge:0085", "kind": "resource_write", "from": "step:agenticGetSupportHistoryStep", "to": "resource:attribute:agenticCancellationPending", "label": "Set", "span": { - "startLine": 594, + "startLine": 701, "startColumn": 12, - "endLine": 594, + "endLine": 701, "endColumn": 77 }, "metadata": { @@ -2636,15 +3155,15 @@ } }, { - "id": "edge:0070", + "id": "edge:0086", "kind": "resource_write", "from": "step:agenticGetSupportHistoryStep", "to": "resource:attribute:agenticHistoryStatus", "label": "Set", "span": { - "startLine": 585, + "startLine": 692, "startColumn": 12, - "endLine": 585, + "endLine": 692, "endColumn": 65 }, "metadata": { @@ -2652,15 +3171,15 @@ } }, { - "id": "edge:0071", + "id": "edge:0087", "kind": "resource_write", "from": "step:agenticGetSupportHistoryStep", "to": "resource:attribute:agenticPriorRefunds", "label": "Set", "span": { - "startLine": 591, + "startLine": 698, "startColumn": 12, - "endLine": 591, + "endLine": 698, "endColumn": 63 }, "metadata": { @@ -2668,15 +3187,15 @@ } }, { - "id": "edge:0072", + "id": "edge:0088", "kind": "resource_write", "from": "step:agenticGetSupportHistoryStep", "to": "resource:attribute:agenticTenureYears", "label": "Set", "span": { - "startLine": 588, + "startLine": 695, "startColumn": 12, - "endLine": 588, + "endLine": 695, "endColumn": 61 }, "metadata": { @@ -2684,15 +3203,15 @@ } }, { - "id": "edge:0073", + "id": "edge:0089", "kind": "resource_write", "from": "step:agenticGetUsageStep", "to": "resource:attribute:agenticUsagePercent", "label": "Set", "span": { - "startLine": 564, + "startLine": 670, "startColumn": 12, - "endLine": 564, + "endLine": 670, "endColumn": 63 }, "metadata": { @@ -2700,15 +3219,15 @@ } }, { - "id": "edge:0074", + "id": "edge:0090", "kind": "resource_write", "from": "step:agenticGetUsageStep", "to": "resource:attribute:agenticUsageStatus", "label": "Set", "span": { - "startLine": 561, + "startLine": 667, "startColumn": 12, - "endLine": 561, + "endLine": 667, "endColumn": 61 }, "metadata": { @@ -2716,15 +3235,15 @@ } }, { - "id": "edge:0075", + "id": "edge:0091", "kind": "resource_write", "from": "step:agenticGuardrailStep", "to": "resource:attribute:agenticBoundAction", "label": "Set", "span": { - "startLine": 679, + "startLine": 788, "startColumn": 12, - "endLine": 679, + "endLine": 788, "endColumn": 47 }, "metadata": { @@ -2732,15 +3251,15 @@ } }, { - "id": "edge:0076", + "id": "edge:0092", "kind": "resource_write", "from": "step:agenticGuardrailStep", "to": "resource:attribute:agenticCaseStatus", "label": "Set", "span": { - "startLine": 662, + "startLine": 771, "startColumn": 12, - "endLine": 662, + "endLine": 771, "endColumn": 46 }, "metadata": { @@ -2748,15 +3267,15 @@ } }, { - "id": "edge:0077", + "id": "edge:0093", "kind": "resource_write", "from": "step:agenticGuardrailStep", "to": "resource:attribute:agenticGateEntries", "label": "Set", "span": { - "startLine": 671, + "startLine": 780, "startColumn": 13, - "endLine": 671, + "endLine": 780, "endColumn": 53 }, "metadata": { @@ -2764,15 +3283,15 @@ } }, { - "id": "edge:0078", + "id": "edge:0094", "kind": "resource_write", "from": "step:agenticGuardrailStep", "to": "resource:attribute:agenticGateRequestKey", "label": "Set", "span": { - "startLine": 674, + "startLine": 783, "startColumn": 13, - "endLine": 674, + "endLine": 783, "endColumn": 102 }, "metadata": { @@ -2780,15 +3299,15 @@ } }, { - "id": "edge:0079", + "id": "edge:0095", "kind": "resource_write", "from": "step:agenticGuardrailStep", "to": "resource:attribute:agenticGuardrailRule", "label": "Set", "span": { - "startLine": 659, + "startLine": 768, "startColumn": 12, - "endLine": 659, + "endLine": 768, "endColumn": 47 }, "metadata": { @@ -2796,15 +3315,15 @@ } }, { - "id": "edge:0080", + "id": "edge:0096", "kind": "resource_write", "from": "step:agenticGuardrailStep", "to": "resource:attribute:agenticGuardrailVerdict", "label": "Set", "span": { - "startLine": 656, + "startLine": 765, "startColumn": 12, - "endLine": 656, + "endLine": 765, "endColumn": 53 }, "metadata": { @@ -2812,15 +3331,15 @@ } }, { - "id": "edge:0081", + "id": "edge:0097", "kind": "resource_write", "from": "step:agenticIssueRefundStep", "to": "resource:attribute:agenticBillingKey", "label": "Set", "span": { - "startLine": 805, + "startLine": 918, "startColumn": 12, - "endLine": 805, + "endLine": 918, "endColumn": 43 }, "metadata": { @@ -2828,15 +3347,15 @@ } }, { - "id": "edge:0082", + "id": "edge:0098", "kind": "resource_write", "from": "step:agenticIssueRefundStep", "to": "resource:attribute:agenticBillingOutcome", "label": "Set", "span": { - "startLine": 809, + "startLine": 922, "startColumn": 12, - "endLine": 809, + "endLine": 922, "endColumn": 51 }, "metadata": { @@ -2844,15 +3363,15 @@ } }, { - "id": "edge:0083", + "id": "edge:0099", "kind": "resource_write", "from": "step:agenticIssueRefundStep", "to": "resource:attribute:agenticCaseStatus", "label": "Set", "span": { - "startLine": 814, + "startLine": 927, "startColumn": 13, - "endLine": 814, + "endLine": 927, "endColumn": 55 }, "metadata": { @@ -2860,15 +3379,15 @@ } }, { - "id": "edge:0084", + "id": "edge:0100", "kind": "failure_transition", "from": "step:agenticIssueRefundStep", "to": "step:agenticBillingFailedStep", "label": "Execute failure", "span": { - "startLine": 796, + "startLine": 909, "startColumn": 19, - "endLine": 796, + "endLine": 909, "endColumn": 81 }, "metadata": { @@ -2876,15 +3395,15 @@ } }, { - "id": "edge:0085", + "id": "edge:0101", "kind": "resource_write", "from": "step:agenticNonConvergenceStep", "to": "resource:attribute:agenticCaseStatus", "label": "Set", "span": { - "startLine": 980, + "startLine": 1182, "startColumn": 12, - "endLine": 980, + "endLine": 1182, "endColumn": 60 }, "metadata": { @@ -2892,15 +3411,15 @@ } }, { - "id": "edge:0086", + "id": "edge:0102", "kind": "resource_write", "from": "step:agenticOfferAccountCreditStep", "to": "resource:attribute:agenticBillingKey", "label": "Set", "span": { - "startLine": 840, + "startLine": 954, "startColumn": 12, - "endLine": 840, + "endLine": 954, "endColumn": 43 }, "metadata": { @@ -2908,15 +3427,15 @@ } }, { - "id": "edge:0087", + "id": "edge:0103", "kind": "resource_write", "from": "step:agenticOfferAccountCreditStep", "to": "resource:attribute:agenticBillingOutcome", "label": "Set", "span": { - "startLine": 844, + "startLine": 958, "startColumn": 12, - "endLine": 844, + "endLine": 958, "endColumn": 51 }, "metadata": { @@ -2924,15 +3443,15 @@ } }, { - "id": "edge:0088", + "id": "edge:0104", "kind": "resource_write", "from": "step:agenticOfferAccountCreditStep", "to": "resource:attribute:agenticCaseStatus", "label": "Set", "span": { - "startLine": 850, + "startLine": 964, "startColumn": 12, - "endLine": 850, + "endLine": 964, "endColumn": 54 }, "metadata": { @@ -2940,15 +3459,15 @@ } }, { - "id": "edge:0089", + "id": "edge:0105", "kind": "resource_write", "from": "step:agenticReCheckStep", "to": "resource:attribute:agenticBoundAction", "label": "Set", "span": { - "startLine": 708, + "startLine": 818, "startColumn": 12, - "endLine": 708, + "endLine": 818, "endColumn": 58 }, "metadata": { @@ -2956,15 +3475,15 @@ } }, { - "id": "edge:0090", + "id": "edge:0106", "kind": "resource_write", "from": "step:agenticReCheckStep", "to": "resource:attribute:agenticCaseStatus", "label": "Set", "span": { - "startLine": 703, + "startLine": 813, "startColumn": 13, - "endLine": 703, + "endLine": 813, "endColumn": 53 }, "metadata": { @@ -2972,15 +3491,15 @@ } }, { - "id": "edge:0091", + "id": "edge:0107", "kind": "resource_write", "from": "step:agenticReceiveRequestStep", "to": "resource:attribute:agenticCaseStatus", "label": "Set", "span": { - "startLine": 387, + "startLine": 485, "startColumn": 13, - "endLine": 387, + "endLine": 485, "endColumn": 57 }, "metadata": { @@ -2988,15 +3507,15 @@ } }, { - "id": "edge:0092", + "id": "edge:0108", "kind": "resource_write", "from": "step:agenticReceiveRequestStep", "to": "resource:attribute:agenticChargeReference", "label": "Set", "span": { - "startLine": 380, + "startLine": 478, "startColumn": 12, - "endLine": 380, + "endLine": 478, "endColumn": 62 }, "metadata": { @@ -3004,15 +3523,31 @@ } }, { - "id": "edge:0093", + "id": "edge:0109", + "kind": "resource_write", + "from": "step:agenticReceiveRequestStep", + "to": "resource:attribute:agenticCustomerEmail", + "label": "Set", + "span": { + "startLine": 472, + "startColumn": 12, + "endLine": 472, + "endColumn": 67 + }, + "metadata": { + "phase": "execute" + } + }, + { + "id": "edge:0110", "kind": "resource_write", "from": "step:agenticReceiveRequestStep", "to": "resource:attribute:agenticInputEmail", "label": "Set", "span": { - "startLine": 377, + "startLine": 475, "startColumn": 12, - "endLine": 377, + "endLine": 475, "endColumn": 63 }, "metadata": { @@ -3020,15 +3555,15 @@ } }, { - "id": "edge:0094", + "id": "edge:0111", "kind": "resource_write", "from": "step:agenticReceiveRequestStep", "to": "resource:attribute:agenticOperatorNote", "label": "Set", "span": { - "startLine": 383, + "startLine": 481, "startColumn": 12, - "endLine": 383, + "endLine": 481, "endColumn": 44 }, "metadata": { @@ -3036,15 +3571,15 @@ } }, { - "id": "edge:0095", + "id": "edge:0112", "kind": "resource_write", "from": "step:agenticRequestHumanApprovalStep", "to": "resource:attribute:agenticManagerVerdict", "label": "Set", "span": { - "startLine": 752, + "startLine": 863, "startColumn": 12, - "endLine": 752, + "endLine": 863, "endColumn": 55 }, "metadata": { @@ -3052,15 +3587,15 @@ } }, { - "id": "edge:0096", + "id": "edge:0113", "kind": "resource_write", "from": "step:agenticSendCustomerMessageStep", "to": "resource:attribute:agenticCaseStatus", "label": "Set", "span": { - "startLine": 960, + "startLine": 1161, "startColumn": 13, - "endLine": 960, + "endLine": 1161, "endColumn": 55 }, "metadata": { @@ -3068,15 +3603,15 @@ } }, { - "id": "edge:0097", + "id": "edge:0114", "kind": "resource_write", "from": "step:agenticSendCustomerMessageStep", "to": "resource:attribute:agenticEmailSent", "label": "Set", "span": { - "startLine": 956, + "startLine": 1157, "startColumn": 12, - "endLine": 956, + "endLine": 1157, "endColumn": 44 }, "metadata": { @@ -3084,15 +3619,15 @@ } }, { - "id": "edge:0098", + "id": "edge:0115", "kind": "failure_transition", "from": "step:agenticSendCustomerMessageStep", "to": "step:agenticEmailFailedStep", "label": "Execute failure", "span": { - "startLine": 931, + "startLine": 1138, "startColumn": 42, - "endLine": 931, + "endLine": 1138, "endColumn": 102 }, "metadata": { @@ -3100,15 +3635,15 @@ } }, { - "id": "edge:0099", + "id": "edge:0116", "kind": "resource_write", "from": "step:agenticSubscriptionFailedStep", "to": "resource:attribute:agenticCaseStatus", "label": "Set", "span": { - "startLine": 1040, + "startLine": 1245, "startColumn": 12, - "endLine": 1040, + "endLine": 1245, "endColumn": 66 }, "metadata": { @@ -3116,15 +3651,15 @@ } }, { - "id": "edge:0100", + "id": "edge:0117", "kind": "resource_write", "from": "step:agenticSubscriptionFailedStep", "to": "resource:attribute:agenticSubscriptionApplied", "label": "Set", "span": { - "startLine": 1037, + "startLine": 1242, "startColumn": 12, - "endLine": 1037, + "endLine": 1242, "endColumn": 53 }, "metadata": { @@ -3132,15 +3667,15 @@ } }, { - "id": "edge:0101", + "id": "edge:0118", "kind": "resource_write", "from": "step:agenticVerifyBillingStep", "to": "resource:attribute:agenticBillingOutcome", "label": "Set", "span": { - "startLine": 875, + "startLine": 990, "startColumn": 12, - "endLine": 875, + "endLine": 990, "endColumn": 51 }, "metadata": { @@ -3148,15 +3683,15 @@ } }, { - "id": "edge:0102", + "id": "edge:0119", "kind": "resource_write", "from": "step:agenticVerifyBillingStep", "to": "resource:attribute:agenticCaseStatus", "label": "Set", "span": { - "startLine": 879, + "startLine": 994, "startColumn": 13, - "endLine": 879, + "endLine": 994, "endColumn": 55 }, "metadata": { @@ -3210,6 +3745,8 @@ "step:agenticOfferAccountCreditStep", "step:agenticVerifyBillingStep", "step:agenticApplySubscriptionStep", + "step:agenticDraftCustomerMessageStep", + "step:agenticConfirmCustomerMessageStep", "step:agenticSendCustomerMessageStep" ] }, @@ -3234,6 +3771,20 @@ ], "v2": { "indexedAttributes": [ + { + "attributeKey": "customer-email", + "indexKey": "customer-email", + "indexType": "fulltext", + "valueType": "string", + "description": "Customer email address" + }, + { + "attributeKey": "refund-amount", + "indexKey": "refund-amount", + "indexType": "double", + "valueType": "double", + "description": "Refund amount in dollars" + }, { "attributeKey": "case-status", "indexKey": "case-status", @@ -3274,6 +3825,12 @@ "display": { "rpcName": "GetDexDisplay", "fields": [ + { + "attributeKey": "customer-email", + "valueType": "string", + "editable": false, + "description": "Customer email" + }, { "attributeKey": "in-email", "valueType": "string", @@ -3346,6 +3903,12 @@ "editable": false, "description": "Customer message effect" }, + { + "attributeKey": "customer-message-draft", + "valueType": "string", + "editable": true, + "description": "Customer message" + }, { "attributeKey": "operator-note", "valueType": "string", @@ -3401,6 +3964,61 @@ } ] } + }, + { + "rpcName": "ConfirmCustomerMessage", + "label": "Send as written", + "condition": { + "attributeKey": "case-status", + "operator": "in", + "values": [ + "awaiting-message-approval" + ] + }, + "input": { + "kind": "object", + "fields": [ + { + "fieldName": "gateRequestKey", + "valueType": "string", + "source": "attribute", + "attributeKey": "gate-request-key", + "required": true, + "description": "Message gate" + } + ] + } + }, + { + "rpcName": "EditCustomerMessage", + "label": "Rewrite and send", + "condition": { + "attributeKey": "case-status", + "operator": "in", + "values": [ + "awaiting-message-approval" + ] + }, + "input": { + "kind": "object", + "fields": [ + { + "fieldName": "message", + "valueType": "string", + "source": "user", + "required": true, + "description": "Message to send" + }, + { + "fieldName": "gateRequestKey", + "valueType": "string", + "source": "attribute", + "attributeKey": "gate-request-key", + "required": true, + "description": "Message gate" + } + ] + } } ] } diff --git a/docs/src/data/flow-definitions/customer-refund.json b/docs/src/data/flow-definitions/customer-refund.json index bd8d553f3..059432a2a 100644 --- a/docs/src/data/flow-definitions/customer-refund.json +++ b/docs/src/data/flow-definitions/customer-refund.json @@ -23,9 +23,9 @@ "parentId": "step:deterministicCheckPolicyStep", "phase": "execute", "span": { - "startLine": 258, + "startLine": 261, "startColumn": 1, - "endLine": 289, + "endLine": 292, "endColumn": 2 } }, @@ -62,15 +62,15 @@ } }, { - "id": "decision:step:deterministicCheckOrderStep:246:9", + "id": "decision:step:deterministicCheckOrderStep:248:9", "kind": "decision", "name": "goTo", "parentId": "step:deterministicCheckOrderStep", "phase": "execute", "span": { - "startLine": 246, + "startLine": 248, "startColumn": 9, - "endLine": 246, + "endLine": 248, "endColumn": 61 }, "decision": { @@ -78,16 +78,16 @@ } }, { - "id": "decision:step:deterministicCheckPolicyStep:273:10", + "id": "decision:step:deterministicCheckPolicyStep:276:10", "kind": "decision", "name": "goTo", "parentId": "step:deterministicCheckPolicyStep", "condition": "lookup != \"found\"", "phase": "execute", "span": { - "startLine": 273, + "startLine": 276, "startColumn": 10, - "endLine": 273, + "endLine": 276, "endColumn": 65 }, "decision": { @@ -95,16 +95,16 @@ } }, { - "id": "decision:step:deterministicCheckPolicyStep:283:10", + "id": "decision:step:deterministicCheckPolicyStep:286:10", "kind": "decision", "name": "goTo", "parentId": "step:deterministicCheckPolicyStep", "condition": "!(lookup != \"found\") and orderAgeDays \u003c= standardWindowDays", "phase": "execute", "span": { - "startLine": 283, + "startLine": 286, "startColumn": 10, - "endLine": 283, + "endLine": 286, "endColumn": 62 }, "decision": { @@ -112,16 +112,16 @@ } }, { - "id": "decision:step:deterministicCheckPolicyStep:288:9", + "id": "decision:step:deterministicCheckPolicyStep:291:9", "kind": "decision", "name": "goTo", "parentId": "step:deterministicCheckPolicyStep", "condition": "!(lookup != \"found\") and !(orderAgeDays \u003c= standardWindowDays)", "phase": "execute", "span": { - "startLine": 288, + "startLine": 291, "startColumn": 9, - "endLine": 288, + "endLine": 291, "endColumn": 60 }, "decision": { @@ -129,15 +129,15 @@ } }, { - "id": "decision:step:deterministicCloseCaseStep:407:9", + "id": "decision:step:deterministicCloseCaseStep:414:9", "kind": "decision", "name": "gracefulComplete", "parentId": "step:deterministicCloseCaseStep", "phase": "execute", "span": { - "startLine": 407, + "startLine": 414, "startColumn": 9, - "endLine": 407, + "endLine": 414, "endColumn": 60 }, "decision": { @@ -145,15 +145,15 @@ } }, { - "id": "decision:step:deterministicDenyRefundStep:347:9", + "id": "decision:step:deterministicDenyRefundStep:352:9", "kind": "decision", "name": "goTo", "parentId": "step:deterministicDenyRefundStep", "phase": "execute", "span": { - "startLine": 347, + "startLine": 352, "startColumn": 9, - "endLine": 347, + "endLine": 352, "endColumn": 64 }, "decision": { @@ -161,15 +161,15 @@ } }, { - "id": "decision:step:deterministicIssueRefundStep:328:9", + "id": "decision:step:deterministicIssueRefundStep:332:9", "kind": "decision", "name": "goTo", "parentId": "step:deterministicIssueRefundStep", "phase": "execute", "span": { - "startLine": 328, + "startLine": 332, "startColumn": 9, - "endLine": 328, + "endLine": 332, "endColumn": 64 }, "decision": { @@ -177,15 +177,15 @@ } }, { - "id": "decision:step:deterministicNotifyCustomerStep:382:9", + "id": "decision:step:deterministicNotifyCustomerStep:388:9", "kind": "decision", "name": "goTo", "parentId": "step:deterministicNotifyCustomerStep", "phase": "execute", "span": { - "startLine": 382, + "startLine": 388, "startColumn": 9, - "endLine": 382, + "endLine": 388, "endColumn": 59 }, "decision": { @@ -193,15 +193,15 @@ } }, { - "id": "decision:step:deterministicReceiveRequestStep:218:9", + "id": "decision:step:deterministicReceiveRequestStep:219:9", "kind": "decision", "name": "goTo", "parentId": "step:deterministicReceiveRequestStep", "phase": "execute", "span": { - "startLine": 218, + "startLine": 219, "startColumn": 9, - "endLine": 218, + "endLine": 219, "endColumn": 60 }, "decision": { @@ -366,6 +366,9 @@ "startColumn": 3, "endLine": 85, "endColumn": 48 + }, + "metadata": { + "explanation": "Check the order and charge evidence for the refund." } }, { @@ -378,6 +381,9 @@ "startColumn": 3, "endLine": 86, "endColumn": 49 + }, + "metadata": { + "explanation": "Evaluate refund policy and choose approve or deny." } }, { @@ -390,6 +396,9 @@ "startColumn": 3, "endLine": 90, "endColumn": 47 + }, + "metadata": { + "explanation": "Close the refund case after notification." } }, { @@ -402,6 +411,9 @@ "startColumn": 3, "endLine": 88, "endColumn": 48 + }, + "metadata": { + "explanation": "Record a policy denial for the refund request." } }, { @@ -414,6 +426,9 @@ "startColumn": 3, "endLine": 87, "endColumn": 70 + }, + "metadata": { + "explanation": "Issue the refund through billing." } }, { @@ -426,6 +441,9 @@ "startColumn": 3, "endLine": 89, "endColumn": 73 + }, + "metadata": { + "explanation": "Notify the customer of the refund decision." } }, { @@ -439,6 +457,9 @@ "startColumn": 3, "endLine": 84, "endColumn": 57 + }, + "metadata": { + "explanation": "Store the inbound refund request and start the case." } } ], @@ -446,104 +467,104 @@ { "id": "edge:0001", "kind": "transition", - "from": "decision:step:deterministicCheckOrderStep:246:9", + "from": "decision:step:deterministicCheckOrderStep:248:9", "to": "step:deterministicCheckPolicyStep", "label": "GoTo", "span": { - "startLine": 246, + "startLine": 248, "startColumn": 9, - "endLine": 246, + "endLine": 248, "endColumn": 61 } }, { "id": "edge:0002", "kind": "transition", - "from": "decision:step:deterministicCheckPolicyStep:273:10", + "from": "decision:step:deterministicCheckPolicyStep:276:10", "to": "step:deterministicNotifyCustomerStep", "label": "GoTo", "span": { - "startLine": 273, + "startLine": 276, "startColumn": 10, - "endLine": 273, + "endLine": 276, "endColumn": 65 } }, { "id": "edge:0003", "kind": "transition", - "from": "decision:step:deterministicCheckPolicyStep:283:10", + "from": "decision:step:deterministicCheckPolicyStep:286:10", "to": "step:deterministicIssueRefundStep", "label": "GoTo", "span": { - "startLine": 283, + "startLine": 286, "startColumn": 10, - "endLine": 283, + "endLine": 286, "endColumn": 62 } }, { "id": "edge:0004", "kind": "transition", - "from": "decision:step:deterministicCheckPolicyStep:288:9", + "from": "decision:step:deterministicCheckPolicyStep:291:9", "to": "step:deterministicDenyRefundStep", "label": "GoTo", "span": { - "startLine": 288, + "startLine": 291, "startColumn": 9, - "endLine": 288, + "endLine": 291, "endColumn": 60 } }, { "id": "edge:0005", "kind": "transition", - "from": "decision:step:deterministicDenyRefundStep:347:9", + "from": "decision:step:deterministicDenyRefundStep:352:9", "to": "step:deterministicNotifyCustomerStep", "label": "GoTo", "span": { - "startLine": 347, + "startLine": 352, "startColumn": 9, - "endLine": 347, + "endLine": 352, "endColumn": 64 } }, { "id": "edge:0006", "kind": "transition", - "from": "decision:step:deterministicIssueRefundStep:328:9", + "from": "decision:step:deterministicIssueRefundStep:332:9", "to": "step:deterministicNotifyCustomerStep", "label": "GoTo", "span": { - "startLine": 328, + "startLine": 332, "startColumn": 9, - "endLine": 328, + "endLine": 332, "endColumn": 64 } }, { "id": "edge:0007", "kind": "transition", - "from": "decision:step:deterministicNotifyCustomerStep:382:9", + "from": "decision:step:deterministicNotifyCustomerStep:388:9", "to": "step:deterministicCloseCaseStep", "label": "GoTo", "span": { - "startLine": 382, + "startLine": 388, "startColumn": 9, - "endLine": 382, + "endLine": 388, "endColumn": 59 } }, { "id": "edge:0008", "kind": "transition", - "from": "decision:step:deterministicReceiveRequestStep:218:9", + "from": "decision:step:deterministicReceiveRequestStep:219:9", "to": "step:deterministicCheckOrderStep", "label": "GoTo", "span": { - "startLine": 218, + "startLine": 219, "startColumn": 9, - "endLine": 218, + "endLine": 219, "endColumn": 60 } }, @@ -554,9 +575,9 @@ "to": "step:deterministicCloseCaseStep", "label": "Get", "span": { - "startLine": 398, + "startLine": 405, "startColumn": 17, - "endLine": 398, + "endLine": 405, "endColumn": 49 }, "metadata": { @@ -570,9 +591,9 @@ "to": "step:deterministicNotifyCustomerStep", "label": "Get", "span": { - "startLine": 364, + "startLine": 370, "startColumn": 17, - "endLine": 364, + "endLine": 370, "endColumn": 49 }, "metadata": { @@ -586,9 +607,9 @@ "to": "step:deterministicCheckPolicyStep", "label": "Get", "span": { - "startLine": 275, + "startLine": 278, "startColumn": 23, - "endLine": 275, + "endLine": 278, "endColumn": 57 }, "metadata": { @@ -602,9 +623,9 @@ "to": "step:deterministicCheckPolicyStep", "label": "Get", "span": { - "startLine": 262, + "startLine": 265, "startColumn": 17, - "endLine": 262, + "endLine": 265, "endColumn": 50 }, "metadata": { @@ -618,9 +639,9 @@ "to": "resource:attribute:deterministicCaseStatus", "label": "Set", "span": { - "startLine": 243, + "startLine": 245, "startColumn": 12, - "endLine": 243, + "endLine": 245, "endColumn": 64 }, "metadata": { @@ -634,9 +655,9 @@ "to": "resource:attribute:deterministicOrderAgeDays", "label": "Set", "span": { - "startLine": 237, + "startLine": 239, "startColumn": 19, - "endLine": 237, + "endLine": 239, "endColumn": 78 }, "metadata": { @@ -650,9 +671,9 @@ "to": "resource:attribute:deterministicOrderLookup", "label": "Set", "span": { - "startLine": 240, + "startLine": 242, "startColumn": 12, - "endLine": 240, + "endLine": 242, "endColumn": 53 }, "metadata": { @@ -666,9 +687,9 @@ "to": "resource:attribute:deterministicCaseStatus", "label": "Set", "span": { - "startLine": 270, + "startLine": 273, "startColumn": 13, - "endLine": 270, + "endLine": 273, "endColumn": 66 }, "metadata": { @@ -682,9 +703,9 @@ "to": "resource:attribute:deterministicRecommendation", "label": "Set", "span": { - "startLine": 267, + "startLine": 270, "startColumn": 13, - "endLine": 267, + "endLine": 270, "endColumn": 75 }, "metadata": { @@ -698,9 +719,9 @@ "to": "resource:attribute:deterministicCaseStatus", "label": "Set", "span": { - "startLine": 403, + "startLine": 410, "startColumn": 13, - "endLine": 403, + "endLine": 410, "endColumn": 61 }, "metadata": { @@ -714,9 +735,9 @@ "to": "resource:attribute:deterministicCaseStatus", "label": "Set", "span": { - "startLine": 344, + "startLine": 349, "startColumn": 12, - "endLine": 344, + "endLine": 349, "endColumn": 58 }, "metadata": { @@ -730,9 +751,9 @@ "to": "resource:attribute:deterministicBillingOutcome", "label": "Set", "span": { - "startLine": 316, + "startLine": 320, "startColumn": 12, - "endLine": 316, + "endLine": 320, "endColumn": 57 }, "metadata": { @@ -746,9 +767,9 @@ "to": "resource:attribute:deterministicCaseStatus", "label": "Set", "span": { - "startLine": 325, + "startLine": 329, "startColumn": 12, - "endLine": 325, + "endLine": 329, "endColumn": 52 }, "metadata": { @@ -762,9 +783,9 @@ "to": "resource:attribute:deterministicRefundKey", "label": "Set", "span": { - "startLine": 312, + "startLine": 316, "startColumn": 12, - "endLine": 312, + "endLine": 316, "endColumn": 48 }, "metadata": { diff --git a/examples/go/README.md b/examples/go/README.md index 50babc90d..2050361de 100644 --- a/examples/go/README.md +++ b/examples/go/README.md @@ -41,6 +41,24 @@ make bins The Worker synchronizes all registered Indexed Attributes with Dex before it opens its listener; no backend CLI registration is required. +Because that sync happens first, changing a Flow type's Indexed Attributes while +a store already holds runs of that Flow type can stop the Worker before it binds. +The symptom is silence: the process stays alive, logs nothing after Gin's startup +banner, and never opens `DEX_EXAMPLES_HTTP_ADDRESS`, so every request fails to +connect rather than returning an error. Adding, removing or retyping a +`dex.Indexed` Attribute counts as such a change. + +Point Dex at an empty store to confirm it, since a fresh store has nothing to +reconcile: + +```bash +dexcli dev -dex-port 8901 -web-port 8902 # a new port means a new store directory +``` + +If the Worker binds there and not against the original store, the schema change +is the cause and not the code. Existing runs stay in the old store, so keep the +old port if you need them. + The defaults connect to Dex at `localhost:8801`. These environment variables override the local addresses: - `DEX_FLOW_SERVICE_ADDRESS`: Dex gRPC target. diff --git a/examples/go/integ/customer_refund_test.go b/examples/go/integ/customer_refund_test.go index cf2c2d3f2..372066d70 100644 --- a/examples/go/integ/customer_refund_test.go +++ b/examples/go/integ/customer_refund_test.go @@ -76,8 +76,10 @@ func TestAgenticCustomerRefundAutomaticAndUnknownOutcomes(t *testing.T) { name string caseID string wantBillingOutcome string + // An unknown billing effect gives up at NonConvergenceStep, so it never drafts a message. + messagesCustomer bool }{ - {name: "automatic refund", wantBillingOutcome: "confirmed"}, + {name: "automatic refund", wantBillingOutcome: "confirmed", messagesCustomer: true}, {name: "unknown effect", caseID: "unproven", wantBillingOutcome: "unknown"}, } for _, test := range tests { @@ -93,6 +95,9 @@ func TestAgenticCustomerRefundAutomaticAndUnknownOutcomes(t *testing.T) { OrderAgeDays: 8, }, dex.StartFlowOptions{}) require.NoError(t, err) + if test.messagesCustomer { + confirmAgenticMessage(t, ctx, flowID, "") + } require.Equal(t, dex.FlowCompleted, waitForFlow(t, flowID).Status) var display map[string]any require.NoError(t, integClient.InvokeRPC( @@ -108,11 +113,12 @@ func TestAgenticCustomerRefundApproveAndRejectActions(t *testing.T) { ctx := integrationContext(t) flowID := newFlowID(t, "agentic-approve") startAgenticEscalation(t, ctx, flowID, flowID, 1_400_000) - waitForAgenticGate(t, ctx, flowID) + approvalGateKey := waitForAgenticGate(t, ctx, flowID) require.NoError(t, integClient.InvokeRPC( ctx, flowID, registry.AgenticRefund.ApproveRefund, nil, nil, )) + confirmAgenticMessage(t, ctx, flowID, approvalGateKey) require.Equal(t, dex.FlowCompleted, waitForFlow(t, flowID).Status) }) @@ -130,6 +136,7 @@ func TestAgenticCustomerRefundApproveAndRejectActions(t *testing.T) { ctx, flowID, registry.AgenticRefund.RejectRefund, agentic.RejectRefundInput{Reason: "duplicate request", GateRequestKey: gateRequestKey}, nil, )) + confirmAgenticMessage(t, ctx, flowID, gateRequestKey) require.Equal(t, dex.FlowCompleted, waitForFlow(t, flowID).Status) }) } @@ -138,10 +145,11 @@ func TestAgenticCustomerRefundEscalatesUnavailableEvidence(t *testing.T) { ctx := integrationContext(t) flowID := newFlowID(t, "agentic-evidence") startAgenticEscalation(t, ctx, flowID, "agent-uncertain", 4200) - waitForAgenticGate(t, ctx, flowID) + approvalGateKey := waitForAgenticGate(t, ctx, flowID) require.NoError(t, integClient.InvokeRPC( ctx, flowID, registry.AgenticRefund.ApproveRefund, nil, nil, )) + confirmAgenticMessage(t, ctx, flowID, approvalGateKey) require.Equal(t, dex.FlowCompleted, waitForFlow(t, flowID).Status) } @@ -160,6 +168,35 @@ func startAgenticEscalation( require.NoError(t, err) } +// Answer the customer-message gate, which every path that writes to the customer stops at. +// +// Both gates publish through the same gate-request-key and the drafting Step mints a fresh one, so +// the message gate is the first key that is not the approval key just answered. Pass "" when the run +// never opened an approval gate. +func confirmAgenticMessage(t *testing.T, ctx context.Context, flowID string, answeredKey string) { + t.Helper() + gateRequestKey := "" + var invokeErr error + require.Eventually(t, func() bool { + var display map[string]any + invokeErr = integClient.InvokeRPC(ctx, flowID, registry.AgenticRefund.GetDexDisplay, nil, &display) + if invokeErr != nil { + return false + } + key, _ := display["gate-request-key"].(string) + if key == "" || key == answeredKey { + return false + } + gateRequestKey = key + return true + }, 30*time.Second, 200*time.Millisecond, "GetDexDisplay failed: %v", invokeErr) + + require.NoError(t, integClient.InvokeRPC( + ctx, flowID, registry.AgenticRefund.ConfirmCustomerMessage, + agentic.ConfirmCustomerMessageInput{GateRequestKey: gateRequestKey}, nil, + )) +} + func waitForAgenticGate(t *testing.T, ctx context.Context, flowID string) string { t.Helper() gateRequestKey := "" diff --git a/examples/go/products/customer-refund/README.md b/examples/go/products/customer-refund/README.md index c3851bbaf..57d2deec2 100644 --- a/examples/go/products/customer-refund/README.md +++ b/examples/go/products/customer-refund/README.md @@ -3,7 +3,7 @@ This product contains two Go Flows for the same customer-refund problem. - `deterministic/workflow.go` is a fixed seven-Step policy. It verifies the 30-day window, uses a persisted provider idempotency key, and preserves declined or unknown outcomes. -- `agentic/workflow.go` loops through durable evidence, records a recommendation, applies a guardrail, opens a keyed approval gate, and separates intent from idempotent effects. +- `agentic/workflow.go` loops through durable evidence, records a recommendation, applies a guardrail, opens a keyed approval gate, and separates intent from idempotent effects. It then opens a **second** keyed gate so a person confirms or rewrites the customer message before it is sent. Both files are self-contained FDG 2.0 sources. Every Step declares `dex:group` and a one-sentence `dex:explanation`. Generate their definitions from the @@ -19,11 +19,47 @@ Start the example server and create runs with: ```bash curl -X POST http://127.0.0.1:8080/products/customer-refund/deterministic/start \ -H 'content-type: application/json' \ - -d '{"case":{"caseId":"order-42","customer":"customer-1","customerNote":"Please refund this charge","amountCents":4200,"orderAgeDays":12}}' + -d '{"case":{"caseId":"order-42","customer":"customer-1","customerEmail":"customer-1@example.com","customerNote":"Please refund this charge","amountCents":4200,"orderAgeDays":12}}' curl -X POST http://127.0.0.1:8080/products/customer-refund/agentic/start \ -H 'content-type: application/json' \ - -d '{"case":{"caseId":"rule-escalation","customer":"customer-2","customerNote":"Please review this charge","amountCents":1400000,"orderAgeDays":8}}' + -d '{"case":{"caseId":"rule-escalation","customer":"customer-2","customerEmail":"customer-2@example.com","customerNote":"Please review this charge","amountCents":1400000,"orderAgeDays":8}}' ``` -The agentic Flow exposes `ApproveRefund` as a no-input Action. `RejectRefund` asks for a reason and binds the hidden `gate-request-key` snapshot supplied by Dex Web. Both RPCs re-check the current status before publishing a verdict. +## Two human gates + +The agentic Flow stops for a person twice, and a run can only ever be at one of +them: + +| status | Actions | Channel | +|---|---|---| +| `awaiting-manager-rule` / `awaiting-manager-agent` | `ApproveRefund`, `RejectRefund` | `manager-approval` | +| `awaiting-message-approval` | `ConfirmCustomerMessage`, `EditCustomerMessage` | `message-approval` | + +`ApproveRefund` takes no input. The other three bind the hidden +`gate-request-key` snapshot supplied by Dex Web, and every one re-checks the +current status before publishing, so a tab left open across a decision is turned +away rather than answering a question that has moved on. + +Both gates share one counter and one `gate-request-key`, which is why the keys +read `case:gate:1` and `case:gate:2`. They cannot consume each other's answer +because each waits on its own Channel. + +`DraftCustomerMessageStep` composes the message and mints the gate; the gate Step +only reads the key. Nothing writes inside a `wait_for` phase — a Step that does +so without declaring its loads in `StepOptions` registers an empty wait condition +and parks forever. + +## Searching for a run + +The agentic Flow declares three Indexed Attributes, which are the only fields +Dex Web can search on: + +| Attribute | index | queries it allows | +|---|---|---| +| `customer-email` | fulltext | whole address, or just the domain | +| `refund-amount` | double | ranges, in dollars rather than cents | +| `case-status` | keyword | exact, or one of several | + +Read [the Go examples README](../../README.md#run-locally) before changing any of +them: the Worker reconciles Indexed Attributes against the store at startup. diff --git a/examples/go/products/customer-refund/agentic/workflow.go b/examples/go/products/customer-refund/agentic/workflow.go index ca730e7c2..7f8efafd3 100644 --- a/examples/go/products/customer-refund/agentic/workflow.go +++ b/examples/go/products/customer-refund/agentic/workflow.go @@ -35,6 +35,7 @@ const ( statusExecuting = "executing" statusAwaitingManagerRule = "awaiting-manager-rule" statusAwaitingManagerAgent = "awaiting-manager-agent" + statusAwaitingMessageOK = "awaiting-message-approval" statusResolved = "resolved" statusDenied = "denied" statusRefunded = "refunded" @@ -54,6 +55,12 @@ const ( var agenticInputEmail = dex.DefineAttribute[string]("in-email") +// dex:indexed-attribute attribute-key:customer-email index-key:customer-email index-type:fulltext value-type:string description:"Customer email address" +var agenticCustomerEmail = dex.DefineAttribute[string]( + "customer-email", + dex.Indexed(dex.AttributeIndex{Type: dex.IndexFullText}), +) + var agenticChargeReference = dex.DefineAttribute[string]("in-charge-ref") var agenticPaymentAmount = dex.DefineAttribute[string]("ev-payment-amount") @@ -114,6 +121,15 @@ var agenticEmailSent = dex.DefineAttribute[string]("email-sent") var agenticOperatorNote = dex.DefineAttribute[string]("operator-note") +// dex:indexed-attribute attribute-key:refund-amount index-key:refund-amount index-type:double value-type:double description:"Refund amount in dollars" +var agenticRefundAmount = dex.DefineAttribute[float64]( + "refund-amount", + dex.Indexed(dex.AttributeIndex{Type: dex.IndexDouble}), +) + +// The message a person confirms or rewrites before it reaches the customer. +var agenticCustomerMessageDraft = dex.DefineAttribute[string]("customer-message-draft") + // dex:indexed-attribute value-type:string attribute-key:case-status description:"Current case status" index-type:keyword index-key:case-status var agenticCaseStatus = dex.DefineAttribute[string]( "case-status", @@ -122,11 +138,22 @@ var agenticCaseStatus = dex.DefineAttribute[string]( var agenticManagerApproval = dex.DefineChannelMap[string]("manager-approval") +var agenticMessageApproval = dex.DefineChannelMap[string]("message-approval") + type RejectRefundInput struct { Reason string `json:"reason"` GateRequestKey string `json:"gateRequestKey"` } +type ConfirmCustomerMessageInput struct { + GateRequestKey string `json:"gateRequestKey"` +} + +type EditCustomerMessageInput struct { + Message string `json:"message"` + GateRequestKey string `json:"gateRequestKey"` +} + type AgenticCustomerRefundFlow struct { dex.FlowDefaults service refundmodel.Service @@ -161,6 +188,8 @@ func (flow *AgenticCustomerRefundFlow) GetSteps() []dex.StepDef { dex.DefineStep(agenticOfferAccountCreditStep{service: flow.service}), dex.DefineStep(agenticVerifyBillingStep{service: flow.service}), dex.DefineStep(agenticApplySubscriptionStep{service: flow.service}), + dex.DefineStep(agenticDraftCustomerMessageStep{}), + dex.DefineStep(agenticConfirmCustomerMessageStep{}), dex.DefineStep(agenticSendCustomerMessageStep{service: flow.service}), dex.DefineStep(agenticNonConvergenceStep{}), dex.DefineStep(agenticNotARefundStep{}), @@ -178,11 +207,20 @@ func (flow *AgenticCustomerRefundFlow) GetRPCs() []dex.RPCDef { dex.LockAttribute(agenticGateRequestKey), }, } + messageOptions := &dex.RPCOptions{ + LockAttributes: []dex.AttributeLock{ + dex.LockAttribute(agenticCaseStatus), + dex.LockAttribute(agenticGateRequestKey), + dex.LockAttribute(agenticCustomerMessageDraft), + }, + } return []dex.RPCDef{ dex.DefineRPC(flow.GetDexSummary, nil), dex.DefineRPC(flow.GetDexDisplay, nil), dex.DefineRPC(flow.ApproveRefund, actionOptions), dex.DefineRPC(flow.RejectRefund, actionOptions), + dex.DefineRPC(flow.ConfirmCustomerMessage, messageOptions), + dex.DefineRPC(flow.EditCustomerMessage, messageOptions), } } @@ -221,8 +259,11 @@ func (*AgenticCustomerRefundFlow) GetPersistenceSchema() dex.PersistenceSchema { agenticEmailSent, agenticOperatorNote, agenticCaseStatus, + agenticRefundAmount, + agenticCustomerEmail, + agenticCustomerMessageDraft, }, - Channels: []dex.ChannelDef{agenticManagerApproval}, + Channels: []dex.ChannelDef{agenticManagerApproval, agenticMessageApproval}, } } @@ -258,6 +299,7 @@ func (*AgenticCustomerRefundFlow) GetDexSummary( }}, nil } +// dex:field attribute-key:customer-email value-type:string editable:false description:"Customer email" // dex:field attribute-key:in-email value-type:string editable:false description:"Customer request" // dex:field attribute-key:in-charge-ref value-type:string editable:false description:"Charge reference" // dex:field attribute-key:evidence-state value-type:string editable:false description:"Evidence state" @@ -270,6 +312,7 @@ func (*AgenticCustomerRefundFlow) GetDexSummary( // dex:field attribute-key:billing-outcome value-type:string editable:false description:"Billing effect" // dex:field attribute-key:subscription-applied value-type:string editable:false description:"Subscription effect" // dex:field attribute-key:email-sent value-type:string editable:false description:"Customer message effect" +// dex:field attribute-key:customer-message-draft value-type:string editable:true description:"Customer message" // dex:field attribute-key:operator-note value-type:string editable:true description:"Operator note" func (*AgenticCustomerRefundFlow) GetDexDisplay( ctx dex.Context, @@ -279,6 +322,7 @@ func (*AgenticCustomerRefundFlow) GetDexDisplay( key string attribute dex.Attribute[string] }{ + {"customer-email", agenticCustomerEmail}, {"in-email", agenticInputEmail}, {"in-charge-ref", agenticChargeReference}, {"evidence-state", agenticEvidenceState}, @@ -291,6 +335,7 @@ func (*AgenticCustomerRefundFlow) GetDexDisplay( {"billing-outcome", agenticBillingOutcome}, {"subscription-applied", agenticSubscriptionApplied}, {"email-sent", agenticEmailSent}, + {"customer-message-draft", agenticCustomerMessageDraft}, {"operator-note", agenticOperatorNote}, } output := make(map[string]any, len(fields)) @@ -302,6 +347,7 @@ func (*AgenticCustomerRefundFlow) GetDexDisplay( output[field.key] = value } return &dex.RPCResult[map[string]any]{Output: map[string]any{ + "customer-email": output["customer-email"], "in-email": output["in-email"], "in-charge-ref": output["in-charge-ref"], "evidence-state": output["evidence-state"], @@ -314,6 +360,7 @@ func (*AgenticCustomerRefundFlow) GetDexDisplay( "billing-outcome": output["billing-outcome"], "subscription-applied": output["subscription-applied"], "email-sent": output["email-sent"], + "customer-message-draft": output["customer-message-draft"], "operator-note": output["operator-note"], }}, nil } @@ -361,6 +408,53 @@ func (*AgenticCustomerRefundFlow) RejectRefund( return &dex.RPCResult[dex.None]{}, nil } +// dex:action action-label:"Send as written" +// dex:when attribute-key:case-status operator:in values:["awaiting-message-approval"] +// dex:input description:"Message gate" required:true source:attribute attribute-key:gate-request-key value-type:string field-name:gateRequestKey +func (*AgenticCustomerRefundFlow) ConfirmCustomerMessage( + ctx dex.Context, + input ConfirmCustomerMessageInput, +) (*dex.RPCResult[dex.None], error) { + gateRequestKey, err := agenticValidateOpenMessageGate(ctx) + if err != nil { + return nil, err + } + if input.GateRequestKey != gateRequestKey { + return nil, fmt.Errorf("message gate changed; refresh the case before confirming") + } + if err := agenticMessageApproval.Publish(ctx, gateRequestKey, "confirm"); err != nil { + return nil, err + } + return &dex.RPCResult[dex.None]{}, nil +} + +// dex:action action-label:"Rewrite and send" +// dex:when attribute-key:case-status operator:in values:["awaiting-message-approval"] +// dex:input field-name:message value-type:string source:user required:true description:"Message to send" +// dex:input description:"Message gate" required:true source:attribute attribute-key:gate-request-key value-type:string field-name:gateRequestKey +func (*AgenticCustomerRefundFlow) EditCustomerMessage( + ctx dex.Context, + input EditCustomerMessageInput, +) (*dex.RPCResult[dex.None], error) { + gateRequestKey, err := agenticValidateOpenMessageGate(ctx) + if err != nil { + return nil, err + } + if input.GateRequestKey != gateRequestKey { + return nil, fmt.Errorf("message gate changed; refresh the case before rewriting") + } + if input.Message == "" { + return nil, fmt.Errorf("message is required") + } + if err := agenticCustomerMessageDraft.Set(ctx, input.Message); err != nil { + return nil, err + } + if err := agenticMessageApproval.Publish(ctx, gateRequestKey, "edited"); err != nil { + return nil, err + } + return &dex.RPCResult[dex.None]{}, nil +} + // dex:group group-id:intake group-label:"Intake" // dex:explanation text:"Store the inbound refund request and open or reject the case." type agenticReceiveRequestStep struct { @@ -375,6 +469,9 @@ func (agenticReceiveRequestStep) Execute( ctx dex.Context, refundCase refundmodel.RefundCase, ) (*dex.StepDecision, error) { + if err := agenticCustomerEmail.Set(ctx, refundCase.CustomerEmail); err != nil { + return nil, err + } if err := agenticInputEmail.Set(ctx, refundCase.CustomerNote); err != nil { return nil, err } @@ -542,6 +639,9 @@ func (step agenticGetPaymentStep) Execute( if err := agenticPaymentAgeDays.Set(ctx, refundCase.OrderAgeDays); err != nil { return nil, err } + if err := agenticRefundAmount.Set(ctx, float64(refundCase.AmountCents)/100); err != nil { + return nil, err + } if err := agenticPaymentAmount.Set(ctx, fmt.Sprintf("%.2f", float64(refundCase.AmountCents)/100)); err != nil { return nil, err } @@ -713,7 +813,7 @@ func (agenticReCheckStep) Execute( if err := agenticCaseStatus.Set(ctx, statusDenied); err != nil { return nil, err } - return dex.GoTo(agenticSendCustomerMessageStep{}, refundCase), nil + return dex.GoTo(agenticDraftCustomerMessageStep{}, refundCase), nil } if err := agenticBoundAction.Set(ctx, actionIssueRefund); err != nil { return nil, err @@ -930,25 +1030,20 @@ func (step agenticApplySubscriptionStep) Execute( if err := agenticSubscriptionApplied.Set(ctx, "yes"); err != nil { return nil, err } - return dex.GoTo(agenticSendCustomerMessageStep{}, refundCase), nil + return dex.GoTo(agenticDraftCustomerMessageStep{}, refundCase), nil } // dex:group group-id:resolution group-label:"Resolution" -// dex:explanation text:"Send the customer the resolution message." -type agenticSendCustomerMessageStep struct { +// dex:explanation text:"Draft the customer message and open it for a person to confirm." +type agenticDraftCustomerMessageStep struct { dex.StepDefaultsNoWaitFor[refundmodel.RefundCase] - service refundmodel.Service } -func (agenticSendCustomerMessageStep) GetStepType() string { - return "SendCustomerMessageStep" +func (agenticDraftCustomerMessageStep) GetStepType() string { + return "DraftCustomerMessageStep" } -func (agenticSendCustomerMessageStep) GetStepOptions() *dex.StepOptions { - return &dex.StepOptions{ExecuteFailure: dex.ProceedToOnExecuteFailure(agenticEmailFailedStep{}, nil)} -} - -func (step agenticSendCustomerMessageStep) Execute( +func (agenticDraftCustomerMessageStep) Execute( ctx dex.Context, refundCase refundmodel.RefundCase, ) (*dex.StepDecision, error) { @@ -967,6 +1062,95 @@ func (step agenticSendCustomerMessageStep) Execute( case statusBusinessFailure: message = "We could not process a refund on this charge." } + if err := agenticCustomerMessageDraft.Set(ctx, message); err != nil { + return nil, err + } + // Same counter and key Attribute as the approval gate; the channel is what differs, + // so the two gates cannot consume each other's answer. + gateEntries, _, getErr := agenticOptionalAttribute(ctx, agenticGateEntries) + if getErr != nil { + return nil, getErr + } + gateEntries++ + if err := agenticGateEntries.Set(ctx, gateEntries); err != nil { + return nil, err + } + if err := agenticGateRequestKey.Set(ctx, fmt.Sprintf("%s:gate:%d", refundCase.CaseID, gateEntries)); err != nil { + return nil, err + } + if err := agenticCaseStatus.Set(ctx, statusAwaitingMessageOK); err != nil { + return nil, err + } + return dex.GoTo(agenticConfirmCustomerMessageStep{}, refundCase), nil +} + +// dex:group group-id:resolution group-label:"Resolution" +// dex:explanation text:"Wait for a person to confirm or rewrite the customer message." +type agenticConfirmCustomerMessageStep struct { + dex.StepDefaults +} + +func (agenticConfirmCustomerMessageStep) GetStepType() string { + return "ConfirmCustomerMessageStep" +} + +func (agenticConfirmCustomerMessageStep) WaitFor( + ctx dex.Context, + _ refundmodel.RefundCase, +) (*dex.Wait, error) { + gateRequestKey, err := agenticGateRequestKey.Get(ctx) + if err != nil { + return nil, err + } + return dex.Until(agenticMessageApproval.ForOne(gateRequestKey)), nil +} + +func (agenticConfirmCustomerMessageStep) Execute( + ctx dex.Context, + refundCase refundmodel.RefundCase, +) (*dex.StepDecision, error) { + gateRequestKey, err := agenticGateRequestKey.Get(ctx) + if err != nil { + return nil, err + } + verdicts, err := agenticMessageApproval.GetConditionResults(ctx, gateRequestKey) + if err != nil { + return nil, err + } + if len(verdicts) != 1 { + return nil, fmt.Errorf("message gate expected one verdict") + } + return dex.GoTo(agenticSendCustomerMessageStep{}, refundCase), nil +} + +// dex:group group-id:resolution group-label:"Resolution" +// dex:explanation text:"Send the customer the message a person confirmed." +type agenticSendCustomerMessageStep struct { + dex.StepDefaultsNoWaitFor[refundmodel.RefundCase] + service refundmodel.Service +} + +func (agenticSendCustomerMessageStep) GetStepType() string { + return "SendCustomerMessageStep" +} + +func (agenticSendCustomerMessageStep) GetStepOptions() *dex.StepOptions { + return &dex.StepOptions{ExecuteFailure: dex.ProceedToOnExecuteFailure(agenticEmailFailedStep{}, nil)} +} + +func (step agenticSendCustomerMessageStep) Execute( + ctx dex.Context, + refundCase refundmodel.RefundCase, +) (*dex.StepDecision, error) { + status, err := agenticCaseStatus.Get(ctx) + if err != nil { + return nil, err + } + // Whatever the person confirmed, not a freshly composed message. + message, err := agenticCustomerMessageDraft.Get(ctx) + if err != nil { + return nil, err + } if err := step.service.SendCustomerMessage(refundCase, message); err != nil { return nil, err } @@ -1038,7 +1222,7 @@ func (agenticBillingFailedStep) Execute( if err := agenticCaseStatus.Set(ctx, statusBusinessFailure); err != nil { return nil, err } - return dex.GoTo(agenticSendCustomerMessageStep{}, refundCase), nil + return dex.GoTo(agenticDraftCustomerMessageStep{}, refundCase), nil } // dex:group group-id:failure group-label:"Failure" @@ -1061,7 +1245,7 @@ func (agenticSubscriptionFailedStep) Execute( if err := agenticCaseStatus.Set(ctx, statusFollowUpSubscription); err != nil { return nil, err } - return dex.GoTo(agenticSendCustomerMessageStep{}, refundCase), nil + return dex.GoTo(agenticDraftCustomerMessageStep{}, refundCase), nil } // dex:group group-id:failure group-label:"Failure" @@ -1153,6 +1337,24 @@ func agenticChooseAction( return actionIssueRefund, "available evidence supports a refund", nil } +func agenticValidateOpenMessageGate(ctx dex.Context) (string, error) { + status, err := agenticCaseStatus.Get(ctx) + if err != nil { + return "", err + } + if status != statusAwaitingMessageOK { + return "", fmt.Errorf("case is not awaiting a customer message decision") + } + gateRequestKey, err := agenticGateRequestKey.Get(ctx) + if err != nil { + return "", err + } + if gateRequestKey == "" { + return "", fmt.Errorf("message gate is missing") + } + return gateRequestKey, nil +} + func agenticValidateOpenGate(ctx dex.Context) (string, error) { status, err := agenticCaseStatus.Get(ctx) if err != nil { diff --git a/examples/go/products/customer-refund/model/service.go b/examples/go/products/customer-refund/model/service.go index 0a1a98fcb..e6b9f46e7 100644 --- a/examples/go/products/customer-refund/model/service.go +++ b/examples/go/products/customer-refund/model/service.go @@ -32,11 +32,12 @@ const ( ) type RefundCase struct { - CaseID string `json:"caseId"` - Customer string `json:"customer"` - CustomerNote string `json:"customerNote"` - AmountCents int64 `json:"amountCents"` - OrderAgeDays int64 `json:"orderAgeDays"` + CaseID string `json:"caseId"` + Customer string `json:"customer"` + CustomerEmail string `json:"customerEmail"` + CustomerNote string `json:"customerNote"` + AmountCents int64 `json:"amountCents"` + OrderAgeDays int64 `json:"orderAgeDays"` } type Evidence struct { diff --git a/web/app/App.tsx b/web/app/App.tsx index d8600c8a8..a2988886a 100644 --- a/web/app/App.tsx +++ b/web/app/App.tsx @@ -6,15 +6,17 @@ // // SPDX-License-Identifier: LicenseRef-Sustainable-Use-1.0 -import { Navigate, Route, Routes, useParams } from 'react-router-dom'; +import { Link, Navigate, Route, Routes, useParams } from 'react-router-dom'; import { AppHeader } from './components/AppHeader'; +import { DebugWorkspace } from './v2/debug/DebugWorkspace'; import { CurrentRunRedirect } from './flows/CurrentRunRedirect'; import { FlowSearchPage } from './flows/FlowSearchPage'; import { RunDetailsPage } from './flows/RunDetailsPage'; import { PreferencesProvider } from './providers'; import { FlowRenderingPage } from './rendering/FlowRenderingPage'; import { ThemeProvider } from './theme'; -import { HomePage, V2Workspace } from './v2/V2Workspace'; +import { QueueWorkspace } from './v2/queue/QueueWorkspace'; +import { HomePage, RunWorkspace } from './v2/RunWorkspace'; import { WebCatalogProvider } from './v2/WebCatalogProvider'; export function App() { @@ -26,9 +28,15 @@ export function App() {
} /> - } /> - } /> - } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> } /> } /> } /> @@ -50,5 +58,19 @@ function CurrentFlowRoute() { function FlowRunRoute() { const { flowId = '', runId = '' } = useParams(); - return ; + const flowPath = `/v1/flows/${encodeURIComponent(flowId)}`; + return ( + + Flows/ + {flowId}/ + {runId} + + )} + flowId={flowId} + runId={runId} + runPath={(chainRunID) => `${flowPath}/${encodeURIComponent(chainRunID)}`} + /> + ); } diff --git a/web/app/components/AppHeader.tsx b/web/app/components/AppHeader.tsx index 90ae1f82b..c67d916e6 100644 --- a/web/app/components/AppHeader.tsx +++ b/web/app/components/AppHeader.tsx @@ -8,16 +8,25 @@ import { Link, useLocation, useNavigate } from 'react-router-dom'; import { usePreferences } from '../providers'; +import { v2HomePath, v2ModePath, type V2Mode } from '../v2/contract'; import { useWebCatalog } from '../v2/WebCatalogProvider'; import { ThemeToggle } from './ThemeToggle'; +const V2_MODES: { mode: V2Mode; label: string }[] = [ + { mode: 'run', label: 'Run' }, + { mode: 'queue', label: 'Inbox' }, +]; + export function AppHeader() { const { timezone, setTimezone } = usePreferences(); const { canUseV2 } = useWebCatalog(); const location = useLocation(); const navigate = useNavigate(); const isV2 = location.pathname === '/v2' || location.pathname.startsWith('/v2/'); - const home = canUseV2 && isV2 ? '/v2' : '/v1/flows'; + const activeMode: V2Mode = location.pathname.startsWith('/v2/queue') ? 'queue' : 'run'; + // Absolute timestamps are a Deep Dive concern; the other views show relative or local time. + const isDebug = location.pathname.includes('/debug'); + const home = canUseV2 && isV2 ? v2HomePath(canUseV2) : '/v1/flows'; return (
@@ -43,6 +52,21 @@ export function AppHeader() { Flow Rendering )} + {isV2 && canUseV2 && ( +
+ {V2_MODES.map(({ mode, label }) => ( + + ))} +
+ )} Dex server @@ -53,13 +77,16 @@ export function AppHeader() { )} + {(!isV2 || isDebug) && ( + )}
diff --git a/web/app/flows/RunDetailsPage.tsx b/web/app/flows/RunDetailsPage.tsx index 9963be847..11273ef06 100644 --- a/web/app/flows/RunDetailsPage.tsx +++ b/web/app/flows/RunDetailsPage.tsx @@ -7,7 +7,7 @@ // SPDX-License-Identifier: LicenseRef-Sustainable-Use-1.0 import { Link } from 'react-router-dom'; -import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; +import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type ReactNode } from 'react'; import type { CSSProperties, KeyboardEvent as ReactKeyboardEvent, @@ -98,7 +98,19 @@ function selectedEventConnectorTone(event: FlowHistoryEvent): SelectedEventConne return 'default'; } -export function RunDetailsPage({ flowId, runId }: { flowId: string; runId: string }) { +export function RunDetailsPage({ + flowId, + runId, + breadcrumb, + runPath, +}: { + flowId: string; + runId: string; + /** Supplied by the host shell, which owns where "up" goes. */ + breadcrumb: ReactNode; + /** Where a sibling run in this chain lives under the host's routes. */ + runPath: (chainRunID: string) => string; +}) { const { timezone } = usePreferences(); const [summary, setSummary] = useState(null); const [history, setHistory] = useState([]); @@ -449,11 +461,7 @@ export function RunDetailsPage({ flowId, runId }: { flowId: string; runId: strin return (
-
- Flows/ - {flowId}/ - {runId} -
+ {breadcrumb}

{summary?.flowType || 'Flow execution'}

@@ -461,10 +469,7 @@ export function RunDetailsPage({ flowId, runId }: { flowId: string; runId: strin
{continuedToRunId && ( - + Next run )} diff --git a/web/app/globals.css b/web/app/globals.css index 9c31aa88f..30dafff4b 100644 --- a/web/app/globals.css +++ b/web/app/globals.css @@ -241,6 +241,35 @@ a { color: var(--brand-dark); } +.v2-modes { + display: flex; + gap: 1px; + padding: 1px; + border: 1px solid var(--line); + border-radius: 8px; +} + +.v2-mode { + padding: 5px 11px; + border: 0; + border-radius: 6px; + background: transparent; + color: var(--muted); + font: inherit; + font-size: 12px; + font-weight: 650; + cursor: pointer; +} + +.v2-mode:hover { + color: var(--ink); +} + +.v2-mode[aria-pressed='true'] { + background: white; + color: var(--brand-dark); +} + .app-themebtn:focus-visible { outline: 1px solid var(--brand); outline-offset: 1px; @@ -305,6 +334,23 @@ a { color: #eff4fc; } +[data-theme='dark'] .v2-modes { + border-color: #2b313d; +} + +[data-theme='dark'] .v2-mode { + color: #8c9198; +} + +[data-theme='dark'] .v2-mode:hover { + color: #bec3ca; +} + +[data-theme='dark'] .v2-mode[aria-pressed='true'] { + background: #1f252d; + color: #eff4fc; +} + [data-theme='dark'] .app-themebtn-echo { color: #6c7077; } diff --git a/web/app/v2/RunWorkspace.tsx b/web/app/v2/RunWorkspace.tsx new file mode 100644 index 000000000..7a82a110c --- /dev/null +++ b/web/app/v2/RunWorkspace.tsx @@ -0,0 +1,175 @@ +// Copyright (c) 2026 Super Durable, Inc. +// +// Licensed under the Sustainable Use License 1.0. +// You may not use this file except in compliance with the License. +// See the LICENSE file in the repository root. +// +// SPDX-License-Identifier: LicenseRef-Sustainable-Use-1.0 + +import { useCallback, useEffect, useRef, useState, type CSSProperties } from 'react'; +import { Navigate, useNavigate, useParams } from 'react-router-dom'; +import type { FlowSummary } from '@/lib/types'; +import { v2HomePath, v2RunPath } from './contract'; +import './css/v2.css'; +import { RUN_COPY } from './run/copy'; +import { RunDetailDrawer, type StepBand } from './run/RunDetailDrawer'; +import type { ActionableStep } from './run/actionableSteps'; +import type { StepContextView } from './run/stepContext'; +import { RunList } from './workspace/RunList'; +import { V2Canvas } from './V2Canvas'; +import { + DRAWER_WIDTH_DEFAULT, + DRAWER_WIDTH_KEY, + V2SplitHandle, + readStoredPixels, + writeStoredPixels, +} from './V2SplitHandle'; +import { useWebCatalog } from './WebCatalogProvider'; +import { RunSearch } from './workspace/RunSearch'; +import { useFlowSearch } from './workspace/useFlowSearch'; +import { useRunQuery } from './workspace/useRunQuery'; +import { useStrandedRuns } from './workspace/useStrandedRuns'; + +export function HomePage() { + const { ready, canUseV2, error } = useWebCatalog(); + if (error) return
{error}
; + if (!ready) return
Loading Dex Web…
; + return ; +} + +/** + * The Admin semantic view: pick a run, see where it is on the canvas, act on it in the drawer. + * + * Selection is narrow and the canvas is wide on purpose. Scoping a search belongs to the Queue, + * and the technical record belongs to the Deep Dive. + */ +export function RunWorkspace() { + const { flowType = '', flowId = '' } = useParams(); + const navigate = useNavigate(); + const { ready, canUseV2, catalog, error } = useWebCatalog(); + const entry = catalog?.flows.find((candidate) => candidate.flowType === flowType); + const runQuery = useRunQuery(entry?.definition); + const search = useFlowSearch(flowType || undefined, entry?.definition, runQuery.appliedFilters); + const { strandedFlowIDs, rememberStranded } = useStrandedRuns(); + const [band, setBand] = useState(null); + const [stepView, setStepView] = useState(null); + const [actionable, setActionable] = useState([]); + const [summary, setSummary] = useState(null); + const [tick, setTick] = useState(0); + /** Bumped to tell the canvas to drop its Step selection. */ + const [deselectKey, setDeselectKey] = useState(0); + /** Dismissing the drawer keeps the run on the canvas; it just hands the width back. */ + const [drawerOpen, setDrawerOpen] = useState(true); + const shellRef = useRef(null); + const bodyRef = useRef(null); + const [drawerWidth, setDrawerWidth] = useState(() => { + const stored = readStoredPixels(DRAWER_WIDTH_KEY); + return Number.isFinite(stored) ? stored : DRAWER_WIDTH_DEFAULT; + }); + + const commitDrawerWidth = useCallback((width: number) => { + const next = Math.round(width); + setDrawerWidth(next); + writeStoredPixels(DRAWER_WIDTH_KEY, next); + }, []); + + // One clock: the canvas owns the run poll and the drawer refreshes on the same beat. + const onTick = useCallback(() => setTick((previous) => previous + 1), []); + + // A newly chosen run always opens its drawer, even if the last one was dismissed. + useEffect(() => { setDrawerOpen(true); }, [flowId]); + + if (!ready) return
Loading Dex Web…
; + if (!canUseV2) return ; + if (error) return
{error}
; + if (!catalog) return
Loading Dex Web…
; + if (catalog.flows.length === 0) { + return ( +
+
Load a valid Flow Definition Graph 2.0 file to search runs in v2.
+
+ ); + } + if (!flowType) return ; + if (!entry) return ; + + const selectedFlow = search.flows.find((flow) => flow.flowId === flowId); + // A Step click alone opens the drawer: its Flow-level meaning needs no run. + const showRun = Boolean(flowId) && drawerOpen; + const showDrawer = showRun || stepView !== null; + const paneStyle = { '--v2-drawer-w': `${drawerWidth}px` } as CSSProperties; + return ( +
+
+ + )} + search={search} + selectedFlowID={flowId} + strandedFlowIDs={strandedFlowIDs} + onSelectFlowType={(next) => navigate(v2RunPath(next))} + onSelectRun={(nextFlowID) => navigate(v2RunPath(entry.flowType, nextFlowID))} + /> +
+ +
+ {showDrawer ? ( + <> + + { + setDrawerOpen(false); + setDeselectKey((previous) => previous + 1); + }} + onStopped={search.runSearch} + onStranded={rememberStranded} + /> + + ) : null} +
+
+ ); +} diff --git a/web/app/v2/V2Canvas.tsx b/web/app/v2/V2Canvas.tsx index 6cd8eeee2..a1f7cae86 100644 --- a/web/app/v2/V2Canvas.tsx +++ b/web/app/v2/V2Canvas.tsx @@ -9,9 +9,14 @@ import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from 'react'; import { hydrateBlobs } from '@/lib/blobs'; import { readResponseJSON } from '@/lib/http'; -import type { FlowDefinitionCatalog, FlowHistoryEvent } from '@/lib/types'; +import type { FlowDefinitionCatalog, FlowHistoryEvent, FlowSummary } from '@/lib/types'; import { safeDecode } from './canvas/model/decode'; -import type { RunOverlay } from './canvas/model/run'; +import { + activeStepTypes, + executionsOf, + reasonLine, + type RunOverlay, +} from './canvas/model/run'; import { loadCurrentRun, loadRunHistory, @@ -26,11 +31,15 @@ import { DetailPanel } from './canvas/panel/DetailPanel'; import { buildPanel, type SectionId } from './canvas/panel/panelModel'; import { ArrowDefs } from './canvas/render/ArrowDefs'; import { Stage } from './canvas/render/Stage'; +import type { CanvasViewportHandle } from './canvas/render/viewport'; import { viewById } from './canvas/views'; import type { Detail, Direction } from './canvas/views/types'; import { Controls } from './flow/Controls'; import { Legend } from './flow/Legend'; import { groupsFromDefinition } from './groupsFromGraph'; +import { actionableSteps, type ActionableStep } from './run/actionableSteps'; +import type { StepBand } from './run/RunDetailDrawer'; +import { stepContext, type StepContextView } from './run/stepContext'; import { PANEL_WIDTH_DEFAULT, PANEL_WIDTH_KEY, @@ -39,12 +48,44 @@ import { writeStoredPixels, } from './V2SplitHandle'; -export function V2Canvas({ flowType, flowId = '' }: { flowType: string; flowId?: string }) { +export function V2Canvas({ + flowType, + flowId = '', + onActionable, + onBand, + onStepContext, + onSummary, + onTick, + deselectKey = 0, + focusBlockingStep = false, + showStepPanel = true, +}: { + flowType: string; + flowId?: string; + /** Bumped by the host to clear the Step selection. */ + deselectKey?: number; + /** Zoom in on the waiting Step instead of merely panning to it. */ + focusBlockingStep?: boolean; + /** False when the host renders step detail itself, so the canvas keeps its width. */ + showStepPanel?: boolean; + /** What the canvas is showing, so a host drawer can label it without owning selection. */ + onBand?: (band: StepBand | null) => void; + /** Flow-level meaning of the selected Step, for a host that explains it. */ + onStepContext?: (context: StepContextView | null) => void; + /** Every Step of this Flow that waits for a person, with its progress. */ + onActionable?: (steps: ActionableStep[]) => void; + onSummary?: (summary: FlowSummary | null) => void; + /** Fired on every run poll, so a host can refresh on the same beat. */ + onTick?: () => void; +}) { const canvasRef = useRef(null); + const viewportRef = useRef(null); const [catalog, setCatalog] = useState(null); const [detail, setDetail] = useState('collapsed'); const [direction, setDirection] = useState('tb'); const [selectedId, setSelectedId] = useState(null); + /** Auto-focus selects a Step for the reader; only their own click should fade the rest. */ + const [selectedByReader, setSelectedByReader] = useState(false); const [selectedGroupId, setSelectedGroupId] = useState(null); const [selectedExecutionId, setSelectedExecutionId] = useState(null); const [inspectSection, setInspectSection] = useState(null); @@ -104,6 +145,8 @@ export function V2Canvas({ flowType, flowId = '' }: { flowType: string; flowId?: activeSteps: state?.activeStepExecutions ?? [], })); setRunError(''); + onSummary?.(summary); + onTick?.(); if (summary.flowStatusCode === 1 && timer === undefined) { timer = window.setInterval(() => { void load(); }, 5000); } @@ -149,6 +192,67 @@ export function V2Canvas({ flowType, flowId = '' }: { flowType: string; flowId?: const overlay: RunOverlay | null = bundle?.overlay ?? null; const selectedStep = flow?.steps.find((step) => step.id === selectedId) ?? null; + /** The Step the run is actually waiting on, which is what an Admin came to see. */ + const blockingStepType = overlay ? (activeStepTypes(overlay)[0] ?? null) : null; + + // Choosing a run should land on where it stopped, once, without fighting later clicks. + const revealedFor = useRef(''); + useEffect(() => { + if (!flow || blockingStepType === null) return; + const key = `${flowId}|${blockingStepType}|${String(focusBlockingStep)}`; + if (revealedFor.current === key) return; + const step = flow.steps.find((candidate) => candidate.stepType === blockingStepType); + if (!step) return; + revealedFor.current = key; + // Selection only. The zoom is declarative via focusNodeId, so the pane resize that + // follows the drawer opening refits to the Step instead of racing an imperative call. + setSelectedId(focusBlockingStep ? step.id : null); + setSelectedByReader(false); + setSelectedGroupId(null); + }, [blockingStepType, flow, flowId, focusBlockingStep]); + + // The host closed its drawer, so nothing is being explained any more. + const firstDeselect = useRef(deselectKey); + useEffect(() => { + if (deselectKey === firstDeselect.current) return; + setSelectedId(null); + setSelectedGroupId(null); + }, [deselectKey]); + + const focusNodeId = useMemo(() => { + if (!focusBlockingStep || !flow || blockingStepType === null) return null; + return flow.steps.find((step) => step.stepType === blockingStepType)?.id ?? null; + }, [blockingStepType, flow, focusBlockingStep]); + + useEffect(() => { + if (!onActionable) return; + onActionable(flow ? actionableSteps(flow, overlay) : []); + }, [flow, onActionable, overlay]); + + useEffect(() => { + if (!onStepContext) return; + onStepContext( + flow && selectedStep ? stepContext(flow, selectedStep, groups) : null, + ); + }, [flow, groups, onStepContext, selectedStep]); + + useEffect(() => { + if (!onBand) return; + if (!selectedStep || !overlay) { + onBand(null); + return; + } + const executions = executionsOf(overlay, selectedStep.stepType); + const latest = executions[executions.length - 1]; + const reason = latest ? reasonLine(latest, Date.now()) : null; + onBand({ + stepType: selectedStep.stepType, + reason: reason?.text ?? null, + tone: reason?.tone ?? null, + isBlocking: selectedStep.stepType === blockingStepType, + }); + }, [blockingStepType, onBand, overlay, selectedStep]); + const scene = useMemo(() => { if (!flow) return null; return viewById('control').layout(flow, { @@ -271,7 +375,8 @@ export function V2Canvas({ flowType, flowId = '' }: { flowType: string; flowId?: return
No valid Flow Definition Graph 2.0 file for this Flow type.
; } - const canvasStyle = panel + const ownsPanel = showStepPanel && panel !== null; + const canvasStyle = ownsPanel ? ({ '--v2-panel-w': `${Math.round(panelWidth)}px` } as CSSProperties) : undefined; @@ -283,12 +388,15 @@ export function V2Canvas({ flowType, flowId = '' }: { flowType: string; flowId?:
{runError ?

{runError}

: null} { setSelectedGroupId(id); setSelectedId(null); @@ -297,6 +405,7 @@ export function V2Canvas({ flowType, flowId = '' }: { flowType: string; flowId?: }} onSelect={(id) => { setSelectedId(id); + setSelectedByReader(id !== null); setSelectedGroupId(null); setSelectedExecutionId(null); setInspectSection(null); @@ -329,9 +438,9 @@ export function V2Canvas({ flowType, flowId = '' }: { flowType: string; flowId?: ) : null}
)} - fitKey={`${flowType}|${flowId}|${detail}|${direction}|${selected.file}|${overlay?.executions.length ?? 0}|${panel ? `panel:${Math.round(panelWidth)}` : 'graph'}`} + fitKey={`${flowType}|${flowId}|${detail}|${direction}|${selected.file}|${overlay?.executions.length ?? 0}|${ownsPanel ? `panel:${Math.round(panelWidth)}` : 'graph'}`} /> - {panel ? ( + {ownsPanel && panel ? ( <> ; measureRef: RefObject; value: number; @@ -113,7 +115,9 @@ export function V2SplitHandle({ const clampLive = useCallback((desired: number) => { const box = measureRef.current?.getBoundingClientRect(); if (!box) return desired; - if (cssVariable === '--v2-panel-w') return clampPanelWidth(desired, box.width); + if (cssVariable === '--v2-panel-w' || cssVariable === '--v2-drawer-w') { + return clampPanelWidth(desired, box.width); + } if (cssVariable === '--v2-def-h') return clampDefHeight(desired, box.height); return axis === 'column' ? clampListWidth(desired, box.width) @@ -127,7 +131,7 @@ export function V2SplitHandle({ if (!box) return; let floor = LIST_WIDTH_MIN; let ceiling = box.width; - if (cssVariable === '--v2-panel-w') { + if (cssVariable === '--v2-panel-w' || cssVariable === '--v2-drawer-w') { floor = PANEL_WIDTH_MIN; ceiling = Math.max(floor, Math.min(box.width * 0.7, box.width - PANEL_CANVAS_REMAIN_MIN)); } else if (cssVariable === '--v2-def-h') { @@ -148,7 +152,7 @@ export function V2SplitHandle({ if (event.button !== 0) return; const box = measureRef.current?.getBoundingClientRect(); if (!box) return; - const fallback = cssVariable === '--v2-panel-w' + const fallback = cssVariable === '--v2-panel-w' || cssVariable === '--v2-drawer-w' ? PANEL_WIDTH_DEFAULT : cssVariable === '--v2-def-h' ? Math.min(DEF_HEIGHT_DEFAULT, Math.max(DEF_HEIGHT_MIN, box.height - EXEC_REMAIN_MIN)) diff --git a/web/app/v2/V2Workspace.tsx b/web/app/v2/V2Workspace.tsx deleted file mode 100644 index 018c84d50..000000000 --- a/web/app/v2/V2Workspace.tsx +++ /dev/null @@ -1,530 +0,0 @@ -// Copyright (c) 2026 Super Durable, Inc. -// -// Licensed under the Sustainable Use License 1.0. -// You may not use this file except in compliance with the License. -// See the LICENSE file in the repository root. -// -// SPDX-License-Identifier: LicenseRef-Sustainable-Use-1.0 - -import { useCallback, useEffect, useRef, useState, type ChangeEventHandler, type CSSProperties } from 'react'; -import { Navigate, useNavigate, useParams } from 'react-router-dom'; -import type { - FlowV2Action, - FlowV2ActionInputField, - FlowV2Definition, - V2ValueType, -} from '@superdurable/flow-definition-renderer'; -import { displayValue, formatDate } from '@/lib/format'; -import { readResponseJSON } from '@/lib/http'; -import type { V2Display, V2Flow, V2SearchResult } from '@/lib/types'; -import { usePreferences } from '../providers'; -import { - parseTypedValue, - v2ActionUserFields, - v2ActionUserInput, - v2FlowPath, - v2ListColumns, - visibleV2Actions, -} from './contract'; -import './css/v2.css'; -import { V2Canvas } from './V2Canvas'; -import { - CASE_HEIGHT_KEY, - LIST_WIDTH_DEFAULT, - LIST_WIDTH_KEY, - V2SplitHandle, - readStoredPixels, - writeStoredPixels, -} from './V2SplitHandle'; -import { useWebCatalog } from './WebCatalogProvider'; - -interface FilterRow { - id: string; - field: string; - operator: string; - value: string; -} - -export function HomePage() { - const { ready, canUseV2, error } = useWebCatalog(); - if (error) return
{error}
; - if (!ready) return
Loading Dex Web…
; - return ; -} - -export function V2Workspace() { - const { flowType = '', flowId = '' } = useParams(); - const navigate = useNavigate(); - const { ready, canUseV2, catalog, error } = useWebCatalog(); - const { timezone } = usePreferences(); - const entry = catalog?.flows.find((candidate) => candidate.flowType === flowType); - const [filters, setFilters] = useState([]); - const [flows, setFlows] = useState([]); - const [loading, setLoading] = useState(false); - const [searchError, setSearchError] = useState(''); - const [nextPageToken, setNextPageToken] = useState(''); - const [pageTokens, setPageTokens] = useState(['']); - const [page, setPage] = useState(0); - const shellRef = useRef(null); - const bodyRef = useRef(null); - const listPaneRef = useRef(null); - const [listWidth, setListWidth] = useState(() => { - const stored = readStoredPixels(LIST_WIDTH_KEY); - return Number.isFinite(stored) ? stored : LIST_WIDTH_DEFAULT; - }); - const [caseHeight, setCaseHeight] = useState(() => readStoredPixels(CASE_HEIGHT_KEY)); - - const commitListWidth = useCallback((width: number) => { - const next = Math.round(width); - setListWidth(next); - writeStoredPixels(LIST_WIDTH_KEY, next); - }, []); - - const commitCaseHeight = useCallback((height: number) => { - const next = Math.round(height); - setCaseHeight(next); - writeStoredPixels(CASE_HEIGHT_KEY, next); - }, []); - - const executeSearch = useCallback(async (token = '', nextPage = 0) => { - if (!entry) return; - setLoading(true); - setSearchError(''); - try { - const response = await fetch('/api/v2/search', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - flowType: entry.flowType, - filters: filters.map((filter) => ({ - field: filter.field, - operator: filter.operator, - values: parseFilterValues(filter.value, filterValueType(filter.field, entry.definition)), - })), - pageSize: 50, - nextPageToken: token, - }), - }); - const result = await readResponseJSON(response); - setFlows(result.flows); - setNextPageToken(result.nextPageToken); - setPage(nextPage); - } catch (failedSearch) { - setSearchError(failedSearch instanceof Error ? failedSearch.message : 'Search failed'); - setFlows([]); - } finally { - setLoading(false); - } - }, [entry, filters]); - - useEffect(() => { - if (entry) void executeSearch(); - }, [entry]); - - if (!ready) return
Loading Dex Web…
; - if (!canUseV2) return ; - if (error) return
{error}
; - if (!catalog) return
Loading Dex Web…
; - if (catalog.flows.length === 0) { - return ( -
-
Load a valid Flow Definition Graph 2.0 file to search runs in v2.
-
- ); - } - if (!flowType) { - return ; - } - if (!entry) return ; - - const fields = filterFields(entry.definition); - const columns = v2ListColumns(entry.definition); - const paneStyle = { - '--v2-list-w': `${listWidth}px`, - ...(Number.isFinite(caseHeight) && caseHeight > 0 ? { '--v2-case-h': `${caseHeight}px` } : {}), - } as CSSProperties; - return ( -
-
- -
- -
-
-
- ); -} - -function SelectedRunPanel({ - flowType, - flowId, - definition, -}: { - flowType: string; - flowId: string; - definition: FlowV2Definition; -}) { - const [result, setResult] = useState(null); - const [error, setError] = useState(''); - const [busyKey, setBusyKey] = useState(''); - const [editingKey, setEditingKey] = useState(''); - const [editValue, setEditValue] = useState(''); - const [fieldErrors, setFieldErrors] = useState>({}); - const [actionValues, setActionValues] = useState>>({}); - - const loadDisplay = useCallback(async () => { - setError(''); - try { - const query = new URLSearchParams({ flowType, flowId }); - const response = await fetch(`/api/v2/display?${query}`); - setResult(await readResponseJSON(response)); - } catch (loadError) { - setError(loadError instanceof Error ? loadError.message : 'Display failed to load'); - } - }, [flowId, flowType]); - - useEffect(() => { void loadDisplay(); }, [loadDisplay]); - - async function saveField(attributeKey: string, valueType: V2ValueType) { - setBusyKey(attributeKey); - setError(''); - setFieldErrors((current) => ({ ...current, [attributeKey]: '' })); - try { - const response = await fetch('/api/v2/display', { - method: 'PATCH', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - flowType, flowId, attributeKey, - value: parseTypedValue(editValue, valueType), - }), - }); - await readResponseJSON(response); - setEditingKey(''); - await loadDisplay(); - } catch (saveError) { - setFieldErrors((current) => ({ - ...current, - [attributeKey]: saveError instanceof Error ? saveError.message : 'Edit failed', - })); - } finally { - setBusyKey(''); - } - } - - async function invokeAction(action: FlowV2Action) { - setBusyKey(action.rpcName); - setError(''); - try { - const input = v2ActionUserInput(action, actionValues[action.rpcName] ?? {}); - const response = await fetch('/api/v2/actions', { - method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - flowType, flowId, rpcName: action.rpcName, - input, attributeSnapshot: result?.attributeSnapshot ?? {}, - }), - }); - await readResponseJSON(response); - await loadDisplay(); - } catch (actionError) { - setError(actionError instanceof Error ? actionError.message : 'Action failed'); - } finally { - setBusyKey(''); - } - } - - return ( -
-
- {flowId} - {result && {result.flowStatus}} -
- {error &&

{error}

} - {!result && !error &&

Loading Display…

} - {result && ( - <> -
-
Display
-
- {definition.display.fields.map((field) => { - const isEditing = editingKey === field.attributeKey; - return ( -
-
{field.description}
-
- {isEditing ? ( - <> - - - - {fieldErrors[field.attributeKey] && {fieldErrors[field.attributeKey]}} - - ) : ( - <> - {displayValue(result.display[field.attributeKey])} - {field.editable && result.isActive && ( - - )} - - )} -
-
- ); - })} -
-
-
-
Actions
- {visibleV2Actions(definition.actions, result.eligibleActions).map((action) => { - const userFields = v2ActionUserFields(action); - return ( -
{ - event.preventDefault(); - void invokeAction(action); - }}> - {userFields.map((field) => ( - - ))} - -
- ); - })} - {definition.actions.length > 0 && result.eligibleActions.length === 0 && ( -

No Actions are available in the current state.

- )} -
- - )} -
- ); -} - -function filterFields(definition: FlowV2Definition) { - return [ - { key: 'flowId', label: 'Flow ID' }, - { key: 'executionStatus', label: 'Execution status' }, - { key: 'startTime', label: 'Start time' }, - { key: 'closeTime', label: 'Close time' }, - ...definition.indexedAttributes.map((attribute) => ({ key: attribute.attributeKey, label: attribute.description })), - ]; -} - -function filterValueType(field: string, definition: FlowV2Definition): V2ValueType { - if (field === 'startTime' || field === 'closeTime') return 'datetime'; - if (field === 'flowId' || field === 'executionStatus') return 'string'; - return definition.indexedAttributes.find((attribute) => attribute.attributeKey === field)?.valueType ?? 'string'; -} - -function filterIndexType(field: string, definition: FlowV2Definition) { - if (field === 'startTime' || field === 'closeTime') return 'datetime'; - if (field === 'flowId' || field === 'executionStatus') return 'keyword'; - return definition.indexedAttributes.find((attribute) => attribute.attributeKey === field)?.indexType ?? 'keyword'; -} - -function filterOperators(indexType: FlowV2Definition['indexedAttributes'][number]['indexType']) { - const equality = [{ value: 'eq', label: 'equals' }, { value: 'in', label: 'is one of' }]; - if (indexType === 'fulltext') return [...equality, { value: 'contains', label: 'contains' }]; - if (indexType === 'datetime' || indexType === 'int' || indexType === 'double') { - return [ - ...equality, - { value: 'gt', label: 'greater than' }, - { value: 'gte', label: 'at least' }, - { value: 'lt', label: 'less than' }, - { value: 'lte', label: 'at most' }, - ]; - } - return equality; -} - -function parseFilterValues(value: string, valueType: V2ValueType): unknown[] { - return value.split(',').map((part) => part.trim()).filter(Boolean).map((part) => parseTypedValue(part, valueType)); -} - -function FilterInput({ operator, value, valueType, onChange }: { - operator: string; - value: string; - valueType: V2ValueType; - onChange: ChangeEventHandler; -}) { - if (valueType === 'bool') { - return ; - } - const allowsAlternatives = operator === 'in'; - const type = allowsAlternatives - ? 'text' - : valueType === 'int64' || valueType === 'double' - ? 'number' - : valueType === 'datetime' - ? 'datetime-local' - : 'text'; - return ; -} - -function updateFilter(filters: FilterRow[], id: string, key: keyof FilterRow, value: string) { - return filters.map((filter) => filter.id === id ? { ...filter, [key]: value } : filter); -} - -function editableValue(value: unknown, valueType: V2ValueType): string { - if (value === null || value === undefined) return ''; - const text = typeof value === 'string' ? value : String(value); - if (valueType !== 'datetime') return text; - const date = new Date(text); - if (Number.isNaN(date.getTime())) return ''; - const localDate = new Date(date.getTime() - date.getTimezoneOffset() * 60_000); - return localDate.toISOString().slice(0, 16); -} - -function TypedInput({ field, value, onChange, required = false }: { - field: { valueType: V2ValueType; description: string }; - value: string; - onChange: (value: string) => void; - required?: boolean; -}) { - if (field.valueType === 'bool') { - return ; - } - return onChange(event.target.value)} />; -} - -function ActionInput({ field, value, onChange }: { - field: FlowV2ActionInputField; - value: string; - onChange: (value: string) => void; -}) { - return ; -} diff --git a/web/app/v2/canvas/render/ArrowDefs.tsx b/web/app/v2/canvas/render/ArrowDefs.tsx index 20b08a87f..b8fe660a0 100644 --- a/web/app/v2/canvas/render/ArrowDefs.tsx +++ b/web/app/v2/canvas/render/ArrowDefs.tsx @@ -35,7 +35,11 @@ import type { JSX } from 'react' * file that cannot read it — exactly what the no-hex compliance check exists to catch. Four * definitions now cover seven families. * - * SIZE IS ABSOLUTE. `markerUnits` defaults to `strokeWidth`, which multiplies the arrowhead by the + * SIZE IS ABSOLUTE, and therefore generous. `markerUnits="userSpaceOnUse"` means a head does not + * take the stroke's zoom compensation, so it shrinks with the graph while the line does not — + * direction was unreadable below about 0.6 zoom until these were enlarged. + * + * `markerUnits` defaults to `strokeWidth`, which multiplies the arrowhead by the * line's thickness — so the deliberately-thickest line on the canvas, a failure edge at 2.4px, got a * ~17px arrowhead, and `--zoom-comp` compounded it further. Every marker is `userSpaceOnUse`, so an * arrowhead is one size everywhere and thickness and head size stop fighting. @@ -53,8 +57,8 @@ export function ArrowDefs(): JSX.Element { viewBox="0 0 10 10" refX="9" refY="5" - markerWidth="7" - markerHeight="7" + markerWidth="11" + markerHeight="11" markerUnits="userSpaceOnUse" orient="auto-start-reverse" > @@ -71,8 +75,8 @@ export function ArrowDefs(): JSX.Element { viewBox="0 0 12 12" refX="10" refY="6" - markerWidth="9" - markerHeight="9" + markerWidth="13" + markerHeight="13" markerUnits="userSpaceOnUse" orient="auto-start-reverse" > @@ -90,8 +94,8 @@ export function ArrowDefs(): JSX.Element { viewBox="0 0 10 10" refX="5" refY="5" - markerWidth="6" - markerHeight="6" + markerWidth="9" + markerHeight="9" markerUnits="userSpaceOnUse" orient="auto" > @@ -115,8 +119,8 @@ export function ArrowDefs(): JSX.Element { viewBox="0 0 10 10" refX="8" refY="5" - markerWidth="7" - markerHeight="7" + markerWidth="11" + markerHeight="11" markerUnits="userSpaceOnUse" orient="auto-start-reverse" > diff --git a/web/app/v2/canvas/render/Stage.tsx b/web/app/v2/canvas/render/Stage.tsx index 3a211c4ed..a3421a98e 100644 --- a/web/app/v2/canvas/render/Stage.tsx +++ b/web/app/v2/canvas/render/Stage.tsx @@ -35,7 +35,14 @@ import { LEGEND_BOX_H, LEGEND_W, legendAnchor } from './legendAnchor' import type { CanvasViewportHandle } from './viewport' import { EDGE_MARKER, RPC_TAIL } from './markers' import { NodeBox } from './NodeBox' -import { chromeCompensation, edgeContrastBoost, FIT_MAX_ZOOM, MAX_ZOOM, MIN_ZOOM } from './zoom' +import { + chromeCompensation, + edgeContrastBoost, + FIT_MAX_ZOOM, + FOCUS_ZOOM, + MAX_ZOOM, + MIN_ZOOM, +} from './zoom' import '@xyflow/react/dist/style.css' /** @@ -172,7 +179,7 @@ const NODE_TYPES = { pbox: BoxNode, band: BandNode, legend: LegendNode } * the layout — so a fit that used an even padding put the first Step underneath it. */ const FIT_BASE = { - padding: { top: '22%', bottom: '6%', left: '6%', right: '6%' }, + padding: { top: '8%', bottom: '4%', left: '4%', right: '4%' }, minZoom: MIN_ZOOM, maxZoom: FIT_MAX_ZOOM, } as const @@ -181,7 +188,7 @@ function fitOptions(insetRightPx: number | null | undefined, paneWidth: number) if (insetRightPx == null || insetRightPx <= 0 || paneWidth <= 0) return FIT_BASE const rightPct = Math.min(58, Math.max(18, (insetRightPx / paneWidth) * 100 + 4)) return { - padding: { top: '22%', bottom: '6%', left: '6%', right: `${rightPct}%` }, + padding: { top: '8%', bottom: '4%', left: '4%', right: `${rightPct}%` }, minZoom: MIN_ZOOM, maxZoom: FIT_MAX_ZOOM, } as const @@ -191,6 +198,8 @@ function Inner({ scene, detail, direction, + dimUnrelated = true, + focusNodeId, insetRightPx, legend, handleRef, @@ -228,6 +237,10 @@ function Inner({ onInspect?: (id: string) => void /** Rendered as a node at the diagram's top-left, so it zooms and pans with the drawing. */ legend?: () => JSX.Element + /** False when the selection was made for the reader, so the graph is not faded for them. */ + dimUnrelated?: boolean + /** When set, fit targets this node at reading zoom instead of the whole graph. */ + focusNodeId?: string | null /** Publishes fit and zoom so a keyboard shortcut has something to drive. */ handleRef?: Ref /** A click inside a group band's own space, outside any card. `additive` is Cmd (macOS) or Ctrl. */ @@ -453,7 +466,7 @@ function Inner({ * edge the run took stays bright even while a selection is dimming everything else, because * "where has this run been" must not be erasable by clicking a card. */ - className: `pedge pedge-${l.family}${l.selfLoop === true ? ' pedge-loop' : ''}${l.onPath === undefined ? '' : ' pedge-path'}${lit ? ' pedge-lit' : ''}${selectedId !== null && !lit && l.onPath === undefined ? ' pedge-dim' : ''}`, + className: `pedge pedge-${l.family}${l.selfLoop === true ? ' pedge-loop' : ''}${l.onPath === undefined ? '' : ' pedge-path'}${lit ? ' pedge-lit' : ''}${dimUnrelated && selectedId !== null && !lit && l.onPath === undefined ? ' pedge-dim' : ''}`, /** * The LABEL only — never `detail`. `detail` holds the full guard text for the panel, and * printing it here put source expressions back on the canvas by the side door. @@ -480,6 +493,11 @@ function Inner({ zoomOut: () => void rf.zoomOut({ duration: 120 }), fit: () => void rf.fitView(fitOptions(insetRightPx, paneRef.current?.clientWidth ?? 0)), zoom: () => rf.getZoom(), + focus: (nodeId: string) => { + const id = rf.getNode(nodeId) !== undefined ? nodeId : `band:${nodeId}` + if (rf.getNode(id) === undefined) return + void rf.fitView({ nodes: [{ id }], padding: 0.45, maxZoom: FOCUS_ZOOM, duration: 260 }) + }, reveal: (nodeId: string) => { /** * A BAND IS REGISTERED UNDER A PREFIX, and resolving that is this function's job rather than the @@ -495,6 +513,9 @@ function Inner({ [rf, insetRightPx], ) + const focusNodeIdRef = useRef(focusNodeId) + useEffect(() => { focusNodeIdRef.current = focusNodeId }, [focusNodeId]) + /** * Refit when the thing being shown changes — never on zoom. * @@ -509,6 +530,14 @@ function Inner({ cancelAnimationFrame(raf) raf = requestAnimationFrame(() => { if (el.clientWidth < 8 || el.clientHeight < 8) return + const focusId = focusNodeIdRef.current ?? null + const resolved = focusId === null + ? null + : (rf.getNode(focusId) !== undefined ? focusId : `band:${focusId}`) + if (resolved !== null && rf.getNode(resolved) !== undefined) { + void rf.fitView({ nodes: [{ id: resolved }], padding: 0.45, maxZoom: FOCUS_ZOOM, duration: 160 }) + return + } void rf.fitView({ ...fitOptions(insetRightPx, el.clientWidth), duration: 160 }) }) } @@ -521,7 +550,7 @@ function Inner({ } /* `insetRightPx` is a dependency because it changes what "fits" MEANS: the reserved strip on the right is part of the fit, so a panel opening or resize has to refit rather than leave the drawing under it. */ - }, [rf, fitKey, insetRightPx]) + }, [rf, fitKey, insetRightPx, focusNodeId]) /** * Zoom compensation, written to CSS custom properties rather than React state. @@ -595,6 +624,10 @@ export function Stage(props: { /** Rendered as a node at the diagram's top-left, so it zooms and pans with the drawing. */ legend?: () => JSX.Element /** Publishes fit and zoom so a keyboard shortcut has something to drive. */ + /** False when the selection was made for the reader, so the graph is not faded for them. */ + dimUnrelated?: boolean + /** When set, fit targets this node at reading zoom instead of the whole graph. */ + focusNodeId?: string | null handleRef?: Ref /** A click inside a group band's own space, outside any card. `additive` is Cmd (macOS) or Ctrl. */ onSelectGroup?: (id: string | null, additive: boolean) => void diff --git a/web/app/v2/canvas/render/viewport.ts b/web/app/v2/canvas/render/viewport.ts index 4fba6c3e1..56ddf7737 100644 --- a/web/app/v2/canvas/render/viewport.ts +++ b/web/app/v2/canvas/render/viewport.ts @@ -19,6 +19,8 @@ export interface CanvasViewportHandle { fit(): void /** Current zoom, for the readout. */ zoom(): number - /** Pan the minimum distance that brings a node fully into view. */ + /** Pan the minimum distance that brings a node fully into view. Never zooms. */ reveal(nodeId: string): void + /** Zoom in on one node, for when reading it is the point. */ + focus(nodeId: string): void } diff --git a/web/app/v2/canvas/render/zoom.ts b/web/app/v2/canvas/render/zoom.ts index e1d0fd83e..2905e3e92 100644 --- a/web/app/v2/canvas/render/zoom.ts +++ b/web/app/v2/canvas/render/zoom.ts @@ -24,8 +24,14 @@ export const MIN_ZOOM = 0.14 export const MAX_ZOOM = 2.2 -/** Fit never zooms IN past 1:1 — a three-Step flow blown up to fill a wall reads as a mistake. */ -export const FIT_MAX_ZOOM = 1 +/** + * Fit may zoom past 1:1, up to this. The cap used to be 1, which left a wide canvas mostly + * empty whenever the graph already fitted; a small flow still must not fill a wall. + */ +export const FIT_MAX_ZOOM = 1.7 + +/** Focus on one Step zooms in properly — the point is to read it without reaching for zoom. */ +export const FOCUS_ZOOM = 1.45 /** * Edge contrast RISES as you zoom out. diff --git a/web/app/v2/canvas/views/README.md b/web/app/v2/canvas/views/README.md new file mode 100644 index 000000000..2423a2a45 --- /dev/null +++ b/web/app/v2/canvas/views/README.md @@ -0,0 +1,78 @@ +# Canvas layout: principles, not per-screenshot tuning + +A view here places boxes by arithmetic over a ranked graph. Every spacing constant is therefore a +claim about every Flow, not just the one on screen. This directory holds the machinery that keeps +that claim honest. + +## The loop + +Change a layout constant or rule the same way every time: + +1. **Measure first.** `layoutQuality.test.ts` runs all shipped Flow definitions in + `docs/src/data/flow-definitions/` through every detail and direction. Run it before touching + anything and keep the numbers. +2. **Find the worst case by number, not by eye.** The report names the flow and mode holding each + worst metric. That is where to look, and it is often not the drawing you were shown. +3. **Change the rule, then re-measure the whole corpus.** A constant that improves one graph and + worsens four is a regression the screenshot cannot show you. +4. **Break the guardrail on purpose.** Revert the fix and confirm the test fails. A guardrail that + passes both ways guards nothing — three attempts at one in this directory were vacuous before + anyone noticed. +5. **Set the limit from the gap between the two runs.** With the merge guard removed, the region-hole + metric reads 1.09; an ordinary gap between fan members reads 0.65. The limit sits at 0.85 because + the data put it there. + +## The principles + +Declared in `layoutQuality.ts` with their metric and limit, so each one can fail a test rather than +only guide a reader. Current corpus worst is in the report the test prints. + +| Principle | Reading | +| --- | --- | +| `no-collision` | Two cards never occupy the same space. | +| `tight-regions` | A region has no gap inside it wide enough to hold a card. | +| `group-cohesion` | A group sits in one place where the graph allows it. | +| `short-edges` | An arrow spans about a rank, not the whole drawing. | +| `reading-order` | The first card is the start of the Flow. | + +They pull against each other on purpose. Cohesion moves a card toward its group and lengthens its +edges; `short-edges` is where that stops. Do not relax a limit to let a change through without saying +which principle you are trading away and what the corpus number becomes. + +## Regions only exist for FDG 2.0 + +`dex:group` is an FDG 2.0 directive, so a 1.0 definition produces no regions and passes +`tight-regions` and `group-cohesion` vacuously. Two of the shipped definitions carry groups today, so +those two principles are exercised by 8 of 132 scenes. Do not read the corpus count as their coverage. +The generation side needs nothing: the analyzer already errors unless every Step declares exactly one +`dex:group`, and rejects one group id used with two labels, so membership is total and unambiguous. + +Porting a second FDG 2.0 Flow is the way to widen that, not loosening a limit. + +## Metric traps found the hard way + +- **Area is not tightness.** Five failure Steps at five ranks make a tall thin region that is 81% + floor and entirely correct. Judging a region by how much of its area holds a card punished exactly + the vertical strip a group is supposed to form. +- **Do not measure the band, measure the cards.** Uncovered-width charged every region for its own + padding and label strip: 0.435 of a single-card band left-right, with no defect present. +- **Express a gap in cards, not pixels.** A hole reads as a hole because a missing box would fit. The + same 240px means different things beside a 260px card and a 60px one. +- **Both axes exist.** Ranks run down the page top-down and across it left-right. Two metrics here + assumed one axis and reported nonsense for half the corpus, including a 650 where the true value + was 5.3. +- **Check the edge set reaches the function.** `cyclicRanks` takes `{from, to}`; `PocFlow` + transitions carry `fromStepId`/`toStepId`. Passing them unmapped silently dropped every edge and + returned rank 0 for all 25 Steps, which made an adjacency test vacuously true and moved cards that + should not have moved. + +## Bugs this machinery has caught + +- **Region merging under-converged.** The greedy merge bounded its passes by the live `rects.length`, + which shrinks on every merge while the counter rises. A group spanning seven rows stopped after + four merges and shipped three regions where one was available. +- **A stylesheet import order made `stroke-width` dead.** `@xyflow/react`'s stylesheet loads after + `canvas.css` at equal specificity, so an unqualified `.react-flow__edge-path` rule never applied and + every edge sat at the library default. +- **`nodesep`/`ranksep` are inert here.** This layout takes only within-rank order from dagre. + Setting them looks like a spacing fix and changes nothing; `rankBase` is the real lever. diff --git a/web/app/v2/canvas/views/controlTopology.test.ts b/web/app/v2/canvas/views/controlTopology.test.ts index 1d5bbba01..d396ec33a 100644 --- a/web/app/v2/canvas/views/controlTopology.test.ts +++ b/web/app/v2/canvas/views/controlTopology.test.ts @@ -7,6 +7,7 @@ // SPDX-License-Identifier: LicenseRef-Sustainable-Use-1.0 import { describe, expect, it } from 'vitest'; +import refundGraph from '../../../../../docs/src/data/flow-definitions/customer-refund-agentic.json'; import { safeDecode } from '../model/decode'; import type { FlowDefinitionGraph } from '../model/fdg'; import { controlTopologyView } from './controlTopology'; @@ -39,8 +40,61 @@ describe('control topology group bands', () => { expect(failure.length).toBeGreaterThan(1); expect(scene.bands.filter((band) => band.style === 'group' && !band.label)).toEqual([]); }); + + /* + * The guard the row gap needs, run over the real two-gate refund graph rather than a fixture. + * + * A hand-built linear flow does not reproduce this: the collision needs wrapped wide ranks and a + * band spanning non-adjacent ranks, which is a shape easier to import than to describe. Verified + * to fail at rankBase 36 and below, which is how the floor of 44 was chosen. + */ + it('never overlaps two group bands on the shipped refund graph', () => { + const scene = refundScene(); + const groups = scene.bands.filter((band) => band.style === 'group'); + + expect(groups.length).toBeGreaterThan(1); + expect(overlappingPairs(groups)).toEqual([]); + }); + + it('never overlaps two Step boxes on the shipped refund graph', () => { + expect(overlappingPairs(refundScene().boxes)).toEqual([]); + }); }); +interface FdgGroup { id: string; label: string; stepIds: string[] } + +function refundScene() { + const graph = refundGraph as unknown as FlowDefinitionGraph & { groups?: FdgGroup[] }; + const flow = safeDecode(graph, { generated: true, note: 'customer-refund-agentic' }); + const groups = (graph.groups ?? []).map((group: FdgGroup) => ({ + id: group.id, + label: group.label, + reason: group.label, + stepTypes: group.stepIds.flatMap((stepId: string) => { + const step = flow.steps.find((candidate) => candidate.id === stepId); + return step === undefined ? [] : [step.stepType]; + }), + })); + return controlTopologyView.layout(flow, viewOpts(groups)); +} + +interface Rect { id: string; x: number; y: number; w: number; h: number } + +/** Pairs sharing more than a hairline of area, named so a failure says which collided. */ +function overlappingPairs(rects: readonly Rect[]): string[] { + const hits: string[] = []; + for (let left = 0; left < rects.length; left += 1) { + for (let right = left + 1; right < rects.length; right += 1) { + const a = rects[left]; + const b = rects[right]; + const overlapX = Math.min(a.x + a.w, b.x + b.w) - Math.max(a.x, b.x); + const overlapY = Math.min(a.y + a.h, b.y + b.h) - Math.max(a.y, b.y); + if (overlapX > 0.5 && overlapY > 0.5) hits.push(`${a.id} + ${b.id}`); + } + } + return hits; +} + function viewOpts(groups: StepGroup[]): ViewOpts { return { detail: 'collapsed', direction: 'tb', selectedId: null, run: null, groups }; } diff --git a/web/app/v2/canvas/views/controlTopology.ts b/web/app/v2/canvas/views/controlTopology.ts index f2d6408de..dabceaa31 100644 --- a/web/app/v2/canvas/views/controlTopology.ts +++ b/web/app/v2/canvas/views/controlTopology.ts @@ -20,6 +20,7 @@ import { subflowBoxes, } from './shared' import { STEP_W, stepBox, stepContent } from './stepBox' +import type { StepGroup } from './groups' import type { Band, Box, Scene, ViewOpts, ViewSpec } from './types' import { boundsOf } from './types' @@ -188,12 +189,73 @@ function recoverySteps(flow: PocFlow): Set { return new Set([...unreachable, ...hubs]) } +/** + * A group that is MOSTLY ERROR HANDLING is error handling, so all of it shares the recovery lane. + * + * Lane by group rather than by step. Reachability decides a step's lane correctly and still drew the + * refund flow's Failure group as five regions in three columns, because only three of its members are + * reached by failing — the other two are guarded branch targets that happen to be terminal. Judged as + * a group instead, Failure is one vertical strip, which is what the declaration already claimed. + * + * Majority, not presence: one compensating Step must not drag its whole phase aside. Measured over the + * corpus, Failure is the only group that reaches the threshold, so no other drawing moves. + */ +function cohesionMoves(flow: PocFlow, groups: StepGroup[], aside: Set): Set { + const targetCount = (id: string): number => + new Set( + flow.transitions + .filter((t) => t.fromStepId === id && t.kind === 'transition' && !t.isSelfLoop) + .map((t) => t.toStepId), + ).size + + const moves = new Set() + for (const group of groups) { + const members = flow.steps.filter((step) => group.stepTypes.includes(step.stepType)) + const handling = members.filter( + (step) => step.recoveryRole !== 'none' || aside.has(step.id), + ) + if (handling.length * 2 <= members.length) continue + for (const step of members) { + // A branch point belongs where the spine reads it, however its group is classified. + if (aside.has(step.id) || step.isStart || targetCount(step.id) > 1) continue + moves.add(step.id) + } + } + return moves +} + +/** + * A region is contiguous: a gap wide enough to hold another card is a hole, not part of it. + */ +function contiguousRuns( + members: { x: number; y: number; h: number }[], + lr: boolean, +): { x: number; y: number; h: number }[][] { + const start = (m: { x: number; y: number }) => (lr ? m.y : m.x) + const extent = (m: { h: number }) => (lr ? m.h : STEP_W) + const sorted = [...members].sort((a, b) => start(a) - start(b)) + const runs: { x: number; y: number; h: number }[][] = [] + for (const member of sorted) { + const current = runs[runs.length - 1] + const last = current?.[current.length - 1] + if (current === undefined || last === undefined) { + runs.push([member]) + continue + } + const hole = start(member) - (start(last) + extent(last)) + if (hole > extent(member)) runs.push([member]) + else current.push(member) + } + return runs +} + function layout(flow: PocFlow, opts: ViewOpts): Scene { if (flow.steps.length === 0) { return { boxes: [], links: [], bands: [], width: 640, height: 200, notes: ['No steps.'] } } - const aside = recoverySteps(flow) + const recovery = recoverySteps(flow) + const aside = new Set([...recovery, ...cohesionMoves(flow, opts.groups ?? [], recovery)]) const main = flow.steps.filter((s) => !aside.has(s.id)) const links = controlLinks(flow, opts) const control = links.filter((l) => l.family === 'control') @@ -315,7 +377,12 @@ function layout(flow: PocFlow, opts: ViewOpts): Scene { * group for band edges they do not have, so a step sat as far from its own group partner as from * an unrelated step. */ - const rankBase = anyAnatomy ? 56 : 30 + /* + * 44 is the floor. Measured over the two-gate refund graph: 36 overlaps two band pairs by 6px + * and 30 by 12px, which is the defect the two attempts above were fixing. See the band-overlap + * test beside this file. + */ + const rankBase = anyAnatomy ? 44 : 30 /** * A WRAPPED continuation needs the allowance too. * @@ -681,19 +748,26 @@ function layout(flow: PocFlow, opts: ViewOpts): Scene { } let rects = [...rows.entries()] .sort((a, b) => a[0] - b[0]) - .map(([, rs]) => { - const x0 = Math.min(...rs.map((r) => r.x)) - const y0 = Math.min(...rs.map((r) => r.y)) + .flatMap(([, rs]) => contiguousRuns(rs, lr).map((run) => { + const x0 = Math.min(...run.map((r) => r.x)) + const y0 = Math.min(...run.map((r) => r.y)) return { x: x0, y: y0, - w: Math.max(...rs.map((r) => r.x + STEP_W)) - x0, - h: Math.max(...rs.map((r) => r.y + r.h)) - y0, + w: Math.max(...run.map((r) => r.x + STEP_W)) - x0, + h: Math.max(...run.map((r) => r.y + r.h)) - y0, } - }) + })) - // Greedy pairwise merge until nothing more can join without capturing a foreign card. - for (let pass = 0; pass < rects.length; pass++) { + /** + * Greedy pairwise merge until nothing more can join without capturing a foreign card. + * + * The bound is the STARTING count. Comparing against the live length under-converged: every + * merge shortens the list while the counter rises, so a group of seven rows stopped after four + * merges and shipped three regions where one was available. + */ + const passes = rects.length + for (let pass = 0; pass < passes; pass++) { let merged = false for (let a = 0; a < rects.length - 1 && !merged; a++) { const p = rects[a] as { x: number; y: number; w: number; h: number } @@ -707,6 +781,11 @@ function layout(flow: PocFlow, opts: ViewOpts): Scene { h: Math.max(p.y + p.h, q.y + q.h) - y0, } if (swallows(union)) continue + // Stacked rows merge; side-by-side rects do not, or the union reinstates the hole. + const overlapsAcross = lr + ? p.y < q.y + q.h && q.y < p.y + p.h + : p.x < q.x + q.w && q.x < p.x + p.w + if (!overlapsAcross) continue rects = [...rects.slice(0, a), union, ...rects.slice(a + 2)] merged = true } @@ -802,3 +881,4 @@ export const controlTopologyView: ViewSpec = { risk: 'Loop-heavy and hub-prone, and silent about who has to act unless colour carries it.', layout, } + diff --git a/web/app/v2/canvas/views/layoutQuality.test.ts b/web/app/v2/canvas/views/layoutQuality.test.ts new file mode 100644 index 000000000..a8cc0bf66 --- /dev/null +++ b/web/app/v2/canvas/views/layoutQuality.test.ts @@ -0,0 +1,98 @@ +// Copyright (c) 2026 Super Durable, Inc. +// +// Licensed under the Sustainable Use License 1.0. +// You may not use this file except in compliance with the License. +// See the LICENSE file in the repository root. +// +// SPDX-License-Identifier: LicenseRef-Sustainable-Use-1.0 + +import { describe, expect, it } from 'vitest'; +import { groupsFromDefinition } from '../../groupsFromGraph'; +import { safeDecode } from '../model/decode'; +import { controlTopologyView } from './controlTopology'; +import { LAYOUT_PRINCIPLES, measureScene, violations } from './layoutQuality'; +import type { Detail, Direction, ViewOpts } from './types'; + +/** + * Every Flow the docs ship, drawn four ways. Spacing constants are judged here rather than + * against one screenshot, so a change that helps one graph cannot quietly break twelve. + */ +/** The two decoders disagree on their graph type, and a fixture only has to satisfy both. */ +type CorpusGraph = { nodes?: unknown[] }; + +const CORPUS = (import.meta as unknown as { + glob: (pattern: string, opts: object) => Record; +}).glob('../../../../../docs/src/data/flow-definitions/**/*.json', { + eager: true, + import: 'default', +}); + +const DETAILS: Detail[] = ['collapsed', 'expanded']; +const DIRECTIONS: Direction[] = ['tb', 'lr']; + +function scenesOf(graph: CorpusGraph) { + const flow = safeDecode(graph as never, { generated: true, note: 'corpus' }); + const groups = groupsFromDefinition(graph as never, flow); + return DETAILS.flatMap((detail) => DIRECTIONS.map((direction) => { + const opts: ViewOpts = { detail, direction, selectedId: null, run: null, groups }; + return { detail, direction, flow, scene: controlTopologyView.layout(flow, opts) }; + })); +} + +const NAMED = Object.entries(CORPUS) + .map(([path, graph]) => ({ name: path.split('/').slice(-1)[0] as string, graph })) + .filter(({ graph }) => Array.isArray(graph.nodes) && graph.nodes.length > 0) + .sort((left, right) => left.name.localeCompare(right.name)); + +describe('layout principles hold across the shipped corpus', () => { + it('found the corpus', () => { + expect(NAMED.length).toBeGreaterThan(20); + }); + + it.each(NAMED)('$name obeys every principle in all four view modes', ({ graph }) => { + for (const { detail, direction, flow, scene } of scenesOf(graph)) { + const broken = violations(flow, scene, direction); + expect(broken, `${detail}/${direction}: ${JSON.stringify(broken)}`).toEqual([]); + } + }); + + /** + * Regions come from `dex:group`, an FDG 2.0 directive, so a 1.0 definition has none and passes the + * region principles vacuously. Pinned so stale regenerated JSON cannot quietly empty the corpus. + */ + it('still has definitions that carry groups', () => { + const carrying = NAMED.filter(({ graph }) => + scenesOf(graph).some(({ scene }) => scene.bands.some((band) => band.style === 'group'))); + expect(carrying.map(({ name }) => name)).toEqual([ + 'customer-refund-agentic.json', + 'customer-refund.json', + ]); + }); + + it('reports the worst metric per principle, so a regression has a number', () => { + const worst = new Map(); + let regionScenes = 0; + for (const { name, graph } of NAMED) { + for (const { detail, direction, flow, scene } of scenesOf(graph)) { + if (scene.bands.some((band) => band.style === 'group')) regionScenes++; + const metrics = measureScene(flow, scene, direction); + for (const [key, value] of Object.entries(metrics)) { + const prior = worst.get(key); + if (prior === undefined || value > prior.value) { + worst.set(key, { value, where: `${name} ${detail}/${direction}` }); + } + } + } + } + const lines = [...worst.entries()].map(([key, { value, where }]) => + `${key.padEnd(12)} ${value.toFixed(3).padStart(8)} ${where}`); + const scenes = NAMED.length * DETAILS.length * DIRECTIONS.length; + // eslint-disable-next-line no-console + console.log( + `\n${LAYOUT_PRINCIPLES.length} principles over ${NAMED.length} flows (${scenes} scenes)` + + `\nregion principles exercised by ${regionScenes}/${scenes} scenes — the rest declare no groups` + + `\n${lines.join('\n')}`, + ); + expect(lines.length).toBeGreaterThan(0); + }); +}); diff --git a/web/app/v2/canvas/views/layoutQuality.ts b/web/app/v2/canvas/views/layoutQuality.ts new file mode 100644 index 000000000..ad7607110 --- /dev/null +++ b/web/app/v2/canvas/views/layoutQuality.ts @@ -0,0 +1,288 @@ +// Copyright (c) 2026 Super Durable, Inc. +// +// Licensed under the Sustainable Use License 1.0. +// You may not use this file except in compliance with the License. +// See the LICENSE file in the repository root. +// +// SPDX-License-Identifier: LicenseRef-Sustainable-Use-1.0 + +import type { PocFlow } from '../model/pocFlow' +import type { Band, Box, Direction, Scene } from './types' + +/** + * What a good drawing has to achieve, stated once and measured, so spacing stops being tuned + * per screenshot. Each principle carries the proxy that can fail a test. + */ +export interface LayoutPrinciple { + id: PrincipleId + statement: string + measure: string + /** A scene violates the principle when its metric exceeds this. */ + limit: number +} + +export type PrincipleId = + | 'no-collision' + | 'tight-regions' + | 'group-cohesion' + | 'short-edges' + | 'reading-order' + +/** + * Lane coherence was measured and dropped, not forgotten. + * + * Grouping by `recoveryRole` claimed the refund flow split a class across lanes. It does not: + * BillingFailedStep is reachable by three ordinary transitions, so the spine is correct for it. + * The role is just too coarse to predict a lane, which made the metric accuse a truthful drawing. + */ + +export const LAYOUT_PRINCIPLES: readonly LayoutPrinciple[] = [ + { + id: 'no-collision', + statement: 'Two cards never occupy the same space.', + measure: 'Overlapping step-box pairs.', + limit: 0, + }, + { + id: 'tight-regions', + statement: 'A region has no gap inside it wide enough to hold a card.', + measure: "Worst region's largest sideways hole, in card widths.", + /** + * A HOLE, not a fill fraction, and measured against the cards rather than the band. + * + * Two earlier forms were wrong. Area punished the shape a strip is meant to have: five failure + * Steps at five ranks give a tall thin region that is 81% floor and still correct. Uncovered + * width then charged every region for its own padding and label strip, which is 0.435 of a + * single-card band left-right. The ordinary gap between fan members is 0.65 of a card. + */ + limit: 0.85, + }, + { + id: 'group-cohesion', + statement: 'A group sits in one place where the graph allows it.', + measure: 'Same-label regions at one rank split across two lanes.', + limit: 0, + }, + { + id: 'short-edges', + statement: 'An arrow spans about a rank, not the whole drawing.', + measure: 'Mean edge length in rank-pitch units.', + // Corpus worst is 5.7. Cohesion costs edge length, which is the tension this limit holds. + limit: 6.5, + }, + { + id: 'reading-order', + statement: 'The first card is the start of the flow.', + measure: 'Cards placed before the start Step.', + limit: 0, + }, +] as const + +export interface LayoutMetrics { + collisions: number + /** Worst region's largest across-axis hole in card widths, or 0 with no region. */ + regionHole: number + straddledGroups: number + edgeSpan: number + beforeStart: number +} + +export type Violation = { id: PrincipleId; value: number; limit: number } + +const METRIC_OF: Record = { + 'no-collision': 'collisions', + 'tight-regions': 'regionHole', + 'group-cohesion': 'straddledGroups', + 'short-edges': 'edgeSpan', + 'reading-order': 'beforeStart', +} + +/** Which principles this scene breaks. Empty is the gate every corpus flow has to pass. */ +export function violations(flow: PocFlow, scene: Scene, direction: Direction): Violation[] { + const metrics = measureScene(flow, scene, direction) + return LAYOUT_PRINCIPLES.filter((principle) => metrics[METRIC_OF[principle.id]] > principle.limit) + .map((principle) => ({ + id: principle.id, + value: round(metrics[METRIC_OF[principle.id]]), + limit: principle.limit, + })) +} + +/** + * Direction is required, not inferred. + * + * Ranks run down the page in 'tb' and across it in 'lr', so a metric that assumed one axis + * reported nonsense for half the corpus. + */ +export function measureScene(flow: PocFlow, scene: Scene, direction: Direction): LayoutMetrics { + const cards = scene.boxes.filter((box) => box.kind === 'step') + const axes = direction === 'lr' + ? { + along: (card: Box) => card.x, + extent: (card: Box) => card.w, + across: (card: Box) => card.y, + acrossExtent: (card: Box) => card.h, + spanAlong: (rect: Rect) => ({ min: rect.x, max: rect.x + rect.w }), + } + : { + along: (card: Box) => card.y, + extent: (card: Box) => card.h, + across: (card: Box) => card.x, + acrossExtent: (card: Box) => card.w, + spanAlong: (rect: Rect) => ({ min: rect.y, max: rect.y + rect.h }), + } + return { + collisions: countCollisions(cards), + regionHole: worstRegionHole(scene.bands, cards, axes.across, axes.acrossExtent), + straddledGroups: countStraddledGroups(scene.bands, cards, axes.across, axes.spanAlong), + edgeSpan: meanEdgeSpan(scene, cards, axes.along), + beforeStart: countBeforeStart(flow, cards, axes.along, axes.extent), + } +} + +type Reader = (card: Box) => number + +function countCollisions(cards: readonly Box[]): number { + let hits = 0 + for (let a = 0; a < cards.length; a++) { + for (let b = a + 1; b < cards.length; b++) { + if (overlaps(cards[a] as Box, cards[b] as Box)) hits++ + } + } + return hits +} + +function overlaps(one: Rect, two: Rect): boolean { + return one.x < two.x + two.w && two.x < one.x + one.w + && one.y < two.y + two.h && two.y < one.y + one.h +} + +interface Rect { x: number; y: number; w: number; h: number } + +/** + * The widest sideways gap inside a region, divided by the narrowest card in it. + * + * Relative to a card because that is what makes a gap read as a hole: a reader sees room for a + * missing box, not a pixel count. Gaps along the ranks are the corridor a region is meant to cover. + */ +function worstRegionHole( + bands: readonly Band[], + cards: readonly Box[], + across: Reader, + acrossExtent: Reader, +): number { + let worst = 0 + for (const region of bands.filter((band) => band.style === 'group')) { + const inside = cards.filter((card) => centreInside(card, region)) + if (inside.length < 2) continue + const unit = Math.min(...inside.map(acrossExtent)) + if (unit <= 0) continue + const spans = inside + .map((card) => ({ min: across(card), max: across(card) + acrossExtent(card) })) + .sort((left, right) => left.min - right.min) + let reach = -Infinity + for (const span of spans) { + if (reach > -Infinity && span.min > reach) worst = Math.max(worst, (span.min - reach) / unit) + reach = Math.max(reach, span.max) + } + } + return worst +} + +function centreInside(card: Box, region: Rect): boolean { + const cx = card.x + card.w / 2 + const cy = card.y + card.h / 2 + return cx >= region.x && cx <= region.x + region.w + && cy >= region.y && cy <= region.y + region.h +} + +/** + * One group drawn as two regions side by side, at the same point in the flow. + * + * That is the shape a reader cannot explain: the same label twice at one rank with a lane between. + * Members genuinely ranks apart are fine, and stay counted as separate places. + */ +function countStraddledGroups( + bands: readonly Band[], + cards: readonly Box[], + across: Reader, + spanAlong: (rect: Rect) => { min: number; max: number }, +): number { + const far = Math.max(...cards.map(across), 0) + const regions = bands + .filter((band) => band.style === 'group' && band.label !== undefined) + .map((band) => { + const inside = cards.filter((card) => centreInside(card, band)) + return { + label: band.label as string, + lane: inside.some((card) => across(card) >= far) ? 1 : 0, + span: spanAlong(band), + } + }) + let straddles = 0 + for (let a = 0; a < regions.length; a++) { + for (let b = a + 1; b < regions.length; b++) { + const one = regions[a] as (typeof regions)[number] + const two = regions[b] as (typeof regions)[number] + if (one.label !== two.label || one.lane === two.lane) continue + if (one.span.min < two.span.max && two.span.min < one.span.max) straddles++ + } + } + return straddles +} + +/** + * Length per edge, divided by the pitch between neighbouring ranks. + * + * Normalised so the number means the same thing for a 6-step flow and a 30-step one. + */ +function meanEdgeSpan(scene: Scene, cards: readonly Box[], along: Reader): number { + const centres = new Map(cards.map((card) => [card.id, { + x: card.x + card.w / 2, + y: card.y + card.h / 2, + }])) + const spans = scene.links + .filter((link) => centres.has(link.from) && centres.has(link.to) && link.from !== link.to) + .map((link) => { + const from = centres.get(link.from) as { x: number; y: number } + const to = centres.get(link.to) as { x: number; y: number } + return Math.abs(from.x - to.x) + Math.abs(from.y - to.y) + }) + if (spans.length === 0) return 0 + const pitch = rankPitch(cards, along) + const mean = spans.reduce((sum, span) => sum + span, 0) / spans.length + return pitch <= 0 ? 0 : mean / pitch +} + +/** + * The TYPICAL gap between neighbouring rows, taken as the median. + * + * Not the minimum: a wrapped rank sits deliberately close, and dividing by that made a normal + * drawing look as though every edge ran the length of the page. + */ +function rankPitch(cards: readonly Box[], along: Reader): number { + const rows = [...new Set(cards.map((card) => Math.round(along(card))))].sort((a, b) => a - b) + if (rows.length < 2) return 0 + const gaps = rows.slice(1) + .map((row, index) => row - (rows[index] as number)) + .filter((gap) => gap > 0) + .sort((left, right) => left - right) + if (gaps.length === 0) return 0 + return gaps[Math.floor(gaps.length / 2)] as number +} + +function countBeforeStart( + flow: PocFlow, + cards: readonly Box[], + along: Reader, + extent: Reader, +): number { + const startId = flow.startStepId ?? flow.steps.find((step) => step.isStart)?.id + const start = cards.find((card) => card.id === startId) + if (start === undefined) return 0 + return cards.filter((card) => along(card) + extent(card) <= along(start)).length +} + +function round(value: number): number { + return Math.round(value * 1000) / 1000 +} diff --git a/web/app/v2/contract.test.ts b/web/app/v2/contract.test.ts index 7d731c774..40c30377d 100644 --- a/web/app/v2/contract.test.ts +++ b/web/app/v2/contract.test.ts @@ -14,15 +14,17 @@ import type { import { v2ActionUserFields, v2ActionUserInput, - v2FlowPath, v2HomePath, + v2DebugPath, v2ListColumns, + v2QueuePath, + v2RunPath, visibleV2Actions, } from './contract'; describe('Dex Web v2 contract helpers', () => { - it('defaults to v2 only when a JSON directory is configured', () => { - expect(v2HomePath(true)).toBe('/v2'); + it('defaults to v2 Run only when a JSON directory is configured', () => { + expect(v2HomePath(true)).toBe('/v2/run'); expect(v2HomePath(false)).toBe('/v1/flows'); }); @@ -35,10 +37,18 @@ describe('Dex Web v2 contract helpers', () => { ]); }); - it('builds Flow-ID-only v2 routes', () => { - const path = v2FlowPath('Refund Flow', 'refund/42'); - expect(path).toBe('/v2/Refund%20Flow/refund%2F42'); - expect(path).not.toContain('run'); + it('builds Flow-ID-only routes per mode', () => { + expect(v2RunPath('Refund Flow', 'refund/42')).toBe('/v2/run/Refund%20Flow/refund%2F42'); + expect(v2QueuePath('Refund Flow', 'refund/42')).toBe('/v2/queue/Refund%20Flow/refund%2F42'); + expect(v2RunPath()).toBe('/v2/run'); + expect(v2QueuePath('Refund Flow')).toBe('/v2/queue/Refund%20Flow'); + }); + + it('nests the Deep Dive under the run it belongs to, optionally keyed by run', () => { + expect(v2DebugPath('Refund Flow', 'refund/42')) + .toBe('/v2/run/Refund%20Flow/refund%2F42/debug'); + expect(v2DebugPath('Refund Flow', 'refund/42', 'run/7')) + .toBe('/v2/run/Refund%20Flow/refund%2F42/debug/run%2F7'); }); it('shows only eligible Actions and hides Attribute-sourced inputs', () => { diff --git a/web/app/v2/contract.ts b/web/app/v2/contract.ts index ff10295a5..b55178962 100644 --- a/web/app/v2/contract.ts +++ b/web/app/v2/contract.ts @@ -11,16 +11,35 @@ import type { FlowV2Definition, } from '@superdurable/flow-definition-renderer'; +/** Run drives one run on the definition canvas; Queue clears work without a diagram. */ +export type V2Mode = 'run' | 'queue'; + export function v2HomePath(canUseV2: boolean) { - return canUseV2 ? '/v2' : '/v1/flows'; + return canUseV2 ? v2ModePath('run') : '/v1/flows'; } -export function v2FlowPath(flowType: string, flowID?: string) { - const typePath = `/v2/${encodeURIComponent(flowType)}`; +export function v2ModePath(mode: V2Mode, flowType?: string, flowID?: string) { + const modePath = `/v2/${mode}`; + if (flowType === undefined) return modePath; + const typePath = `${modePath}/${encodeURIComponent(flowType)}`; if (flowID === undefined) return typePath; return `${typePath}/${encodeURIComponent(flowID)}`; } +export function v2RunPath(flowType?: string, flowID?: string) { + return v2ModePath('run', flowType, flowID); +} + +export function v2QueuePath(flowType?: string, flowID?: string) { + return v2ModePath('queue', flowType, flowID); +} + +/** The Deep Dive is keyed per run: Time Travel and continue-as-new walk the run chain. */ +export function v2DebugPath(flowType: string, flowID: string, runID?: string) { + const base = `${v2RunPath(flowType, flowID)}/debug`; + return runID === undefined ? base : `${base}/${encodeURIComponent(runID)}`; +} + export function v2ListColumns(definition: FlowV2Definition) { return [ ...definition.indexedAttributes.map((attribute) => ({ diff --git a/web/app/v2/css/canvas.css b/web/app/v2/css/canvas.css index 0b60d356e..73c16ae4c 100644 --- a/web/app/v2/css/canvas.css +++ b/web/app/v2/css/canvas.css @@ -1095,13 +1095,13 @@ React re-rendering. Solid = control flow, dashed = everything else: one pre-attentive channel carrying the most important distinction. */ -.react-flow__edge-path { - stroke-width: calc(1.5px * var(--zoom-comp)); +.pedge .react-flow__edge-path { + stroke-width: calc(1.9px * var(--zoom-comp)); } .pedge-control .react-flow__edge-path { stroke: var(--p-edge-control); /* Contrast RISES as the graph shrinks, so the skeleton survives being small. Clamps at 1. */ - opacity: calc(0.82 * var(--edge-boost)); + opacity: calc(0.92 * var(--edge-boost)); } /* Recovery is the thickest line on the canvas — always drawn, and routed to a side gutter rather than hidden behind a toggle. */ @@ -1127,7 +1127,7 @@ opacity: 1; } .pedge-dim .react-flow__edge-path { - opacity: 0.24; + opacity: 0.45; } /* React Flow's own theming surface. Bare --xy-* names; there is no `--xy-background-pattern-color-default`, the fallback is per-variant. */ diff --git a/web/app/v2/css/debug.css b/web/app/v2/css/debug.css new file mode 100644 index 000000000..0064156b3 --- /dev/null +++ b/web/app/v2/css/debug.css @@ -0,0 +1,434 @@ +/* + * Copyright (c) 2026 Super Durable, Inc. + * + * Licensed under the Sustainable Use License 1.0. + * You may not use this file except in compliance with the License. + * See the LICENSE file in the repository root. + * + * SPDX-License-Identifier: LicenseRef-Sustainable-Use-1.0 + */ + +/* + * Colour and type for the Deep Dive, which reuses the v1 detail components. + * + * globals.css still owns their LAYOUT and still serves /v1 unchanged, so this file + * only re-points colour and type, scoped under .v2-debug. Same precedent as the + * .v2-shell .ppan block in canvas.css. + * + * Mapped by semantic family, not by literal: v1's greens carry three different + * meanings (interactive, succeeded, emphasis) and v2 reserves green for done. + */ + +.v2-debug { + /* + * v2 has no interactive token: --p-* are all status colours and --p-external means + * "external party", not "clickable". Its own chrome already treats this blue as + * interactive via --accent-wash and --focus-ring, so name it once here. + */ + --v2-debug-accent: var(--hue-progress-ink); + + overflow-y: auto; + height: 100%; + background: var(--p-surface-1); + color: var(--p-ink-1); + font-family: var(--font-sans); +} + +.v2-debug .run-page { + max-width: none; + padding: 0 20px 28px; +} + +.v2-debug-head { + display: flex; + align-items: baseline; + gap: 10px; + padding: 10px 20px; + border-bottom: 1px solid var(--p-line-soft); +} + +.v2-debug-back { + color: var(--p-ink-2); + font-size: 11px; +} + +.v2-debug-back:hover { + color: var(--p-ink-0); +} + +.v2-debug-label { + color: var(--p-ink-0); + font-size: 11px; + letter-spacing: 0.06em; + text-transform: uppercase; +} + +.v2-debug-note { + color: var(--p-ink-3); + font-size: 10px; +} + +/* ------------------------------------------------------------------ type */ + +/* + * v1's h1 is clamp(28px, 4vw, 42px) against v2's 15px ceiling. Left large enough + * to head a page, small enough to sit beside v2 chrome. + */ +.v2-debug h1 { + color: var(--p-ink-0); + font-size: 20px; + font-weight: 600; + letter-spacing: -0.01em; +} + +.v2-debug h2 { + color: var(--p-ink-0); + font-size: 14px; + font-weight: 600; +} + +.v2-debug h3 { + color: var(--p-ink-1); + font-size: 12px; + font-weight: 600; +} + +.v2-debug .eyebrow { + color: var(--p-ink-3); + font-size: 9.5px; + letter-spacing: 0.08em; +} + +.v2-debug .muted { + color: var(--p-ink-2); +} + +.v2-debug .mono, +.v2-debug code, +.v2-debug pre { + font-family: var(--font-mono); + font-variant-ligatures: none; +} + +/* --------------------------------------------------------------- surfaces */ + +.v2-debug .card, +.v2-debug .overview-card, +.v2-debug .summary-strip, +.v2-debug .graph-view, +.v2-debug .timeline-wrap, +.v2-debug .run-sidebar { + border: 1px solid var(--p-line-soft); + border-radius: var(--p-radius); + background: var(--p-surface-2); + box-shadow: none; +} + +.v2-debug .live-state-block, +.v2-debug .active-step-card, +.v2-debug .event-card, +.v2-debug .sub-flow-record, +.v2-debug .channel-message-row { + border-color: var(--p-line-soft); + background: var(--p-surface-3); +} + +.v2-debug pre, +.v2-debug .definition-list, +.v2-debug .metric-grid { + border-color: var(--p-line-soft); + background: var(--p-surface-3); + color: var(--p-ink-1); +} + +.v2-debug hr, +.v2-debug .run-header, +.v2-debug .run-tabs { + border-color: var(--p-line-soft); +} + +/* ---------------------------------------------------------------- controls */ + +/* Neutral by default: green here would collide with v2 reserving green for done. */ +.v2-debug .button, +.v2-debug .section-expand-toggle, +.v2-debug .graph-minimap-toggle { + border: 1px solid var(--p-line); + border-radius: 5px; + background: var(--p-surface-3); + color: var(--p-ink-1); + box-shadow: none; +} + +.v2-debug .button:hover:enabled { + border-color: var(--p-ink-3); + color: var(--p-ink-0); +} + +.v2-debug .button.primary { + border-color: var(--v2-debug-accent); + background: var(--p-surface-3); + color: var(--v2-debug-accent); +} + +.v2-debug .button.danger { + border-color: var(--p-run-failed); + background: var(--p-surface-3); + color: var(--p-run-failed); +} + +.v2-debug .button:disabled { + color: var(--p-ink-3); +} + +.v2-debug .run-tabs button { + border: 0; + background: transparent; + color: var(--p-ink-2); + font-size: 11.5px; +} + +.v2-debug .run-tabs button:hover { + color: var(--p-ink-0); +} + +.v2-debug .run-tabs button.active { + color: var(--p-ink-0); +} + +.v2-debug .run-tabs button.active::after { + background: var(--v2-debug-accent); +} + +.v2-debug a { + color: var(--v2-debug-accent); +} + +.v2-debug .breadcrumbs, +.v2-debug .breadcrumbs a { + color: var(--p-ink-2); + font-size: 10.5px; +} + +/* ------------------------------------------------------- run-state tones */ + +/* + * One row per meaning. v1 spelled these as 74 distinct literals; v2 already has + * a token per run state and the graph/timeline tones reuse them. + */ +.v2-debug .status-badge, +.v2-debug .event-type, +.v2-debug .timeline-dot, +.v2-debug .phase { + border-color: var(--p-line-soft); + background: var(--p-surface-3); + color: var(--p-ink-2); +} + +.v2-debug .status-running, +.v2-debug .phase-active, +.v2-debug .tone-execute, +.v2-debug .event-type.tone-execute { + border-color: var(--p-run-active); + color: var(--p-run-active); +} + +.v2-debug .status-completed, +.v2-debug .event-type.tone-completed { + border-color: var(--p-run-done); + color: var(--p-run-done); +} + +.v2-debug .status-failed, +.v2-debug .status-terminated, +.v2-debug .event-type.tone-failed, +.v2-debug .run-error { + border-color: var(--p-run-failed); + color: var(--p-run-failed); +} + +.v2-debug .phase-waiting, +.v2-debug .tone-wait-for, +.v2-debug .event-type.tone-wait-for { + border-color: var(--p-clock); + color: var(--p-clock); +} + +.v2-debug .status-canceled, +.v2-debug .status-timed-out { + border-color: var(--p-unknown); + color: var(--p-unknown); +} + +/* ------------------------------------------------------------ graph nodes */ + +.v2-debug .react-flow__node.step-flow-node { + border-color: var(--p-line); + background: var(--p-surface-2); + color: var(--p-ink-1); +} + +.v2-debug .react-flow__node.node-running, +.v2-debug .graph-node-current { + border-color: var(--p-run-active); +} + +.v2-debug .react-flow__node.node-completed { + border-color: var(--p-run-done); +} + +.v2-debug .react-flow__node.node-failed { + border-color: var(--p-run-failed); +} + +.v2-debug .react-flow__node.node-waiting { + border-color: var(--p-clock); +} + +.v2-debug .react-flow__edge-path { + stroke: var(--p-edge-control); +} + +.v2-debug .graph-canvas, +.v2-debug .graph-minimap { + background: var(--p-bg); +} + +.v2-debug .graph-legend, +.v2-debug .view-toolbar { + border-color: var(--p-line-soft); + background: var(--p-surface-2); + color: var(--p-ink-2); +} + +/* --------------------------------------------------------------- timeline */ + +.v2-debug .timeline-rail, +.v2-debug .timeline-step-link { + border-color: var(--p-line); + background: var(--p-line-soft); +} + +.v2-debug .timeline-time, +.v2-debug .event-id, +.v2-debug .timeline-step-duration { + color: var(--p-ink-3); +} + +.v2-debug .timeline-row.selected .event-card { + border-color: var(--v2-debug-accent); +} + +.v2-debug .selected-event-connector path { + stroke: var(--v2-debug-accent); +} + +/* ----------------------------------------------------------------- modals */ + +.v2-debug .modal, +.v2-debug .modal-card { + border-color: var(--p-line); + background: var(--p-surface-2); + color: var(--p-ink-1); +} + +.v2-debug .modal-backdrop { + background: color-mix(in oklch, var(--p-bg) 78%, transparent); +} + +.v2-debug input, +.v2-debug select, +.v2-debug textarea { + border: 1px solid var(--p-line); + border-radius: 4px; + background: var(--p-surface-1); + color: var(--p-ink-0); + font-size: 11.5px; +} + +.v2-debug .error-banner { + border-color: var(--p-run-failed); + background: var(--p-surface-3); + color: var(--p-run-failed); +} + +/* + * The rest, found by probing for a near-white background in dark mode rather than by + * reading selectors — these are the families globals.css paints with literals. + */ +.v2-debug .semantic-record, +.v2-debug .semantic-section, +.v2-debug .channel-record, +.v2-debug .sidebar-stack, +.v2-debug .selected-event-anchor, +.v2-debug .overview-selected-event, +.v2-debug .event-detail-tabs, +.v2-debug code { + border-color: var(--p-line-soft); + background: var(--p-surface-3); + color: var(--p-ink-1); +} + +.v2-debug .event-detail-tabs button.active { + background: var(--p-surface-2); + color: var(--p-ink-0); +} + +.v2-debug .graph-methods { + background: transparent; +} + +.v2-debug .graph-method { + border-color: var(--p-line-soft); + background: var(--p-surface-3); + color: var(--p-ink-2); +} + +.v2-debug .graph-method-wait-for { + border-color: var(--p-clock); + color: var(--p-clock); +} + +.v2-debug .graph-method-execute { + border-color: var(--p-run-done); + color: var(--p-run-done); +} + +.v2-debug .legend-source, +.v2-debug .legend-subflow { + background: var(--p-surface-3); +} + +.v2-debug .react-flow__controls-button { + border-color: var(--p-line-soft); + background: var(--p-surface-2); + fill: var(--p-ink-1); +} + +/* Every .tone-* variant carries its own light tint, so outrank them all at once. */ +.v2-debug .event-type[class*='tone-'], +.v2-debug .timeline-dot[class*='tone-'] { + background: var(--p-surface-3); +} + +.v2-debug .json-toggle, +.v2-debug .json-view-body, +.v2-debug .json-view-body .event-detail-tabs, +.v2-debug .json-view-raw { + border-color: var(--p-line-soft); + background: var(--p-surface-3); + color: var(--p-ink-1); +} + +.v2-debug .json-toggle span { + color: var(--p-ink-2); +} + +/* Unclassed chips: .event-highlights > span, and the Overview metric cells. */ +.v2-debug .event-highlights > span, +.v2-debug .metric-grid > div, +.v2-debug .definition-list > div { + border-color: var(--p-line-soft); + background: var(--p-surface-2); + color: var(--p-ink-2); +} diff --git a/web/app/v2/css/listing.css b/web/app/v2/css/listing.css index 7827e6269..930365a4a 100644 --- a/web/app/v2/css/listing.css +++ b/web/app/v2/css/listing.css @@ -42,7 +42,7 @@ .sv-strap { margin-top: 2px; color: var(--p-ink-2); - font-size: 11.5px; + font-size: 12.5px; } .sv-choose { @@ -55,7 +55,7 @@ .sv-chooselabel { color: var(--p-ink-3); - font-size: 10px; + font-size: 11px; letter-spacing: 0.06em; text-transform: uppercase; } @@ -65,7 +65,7 @@ border: 1px solid var(--p-line); border-radius: 5px; color: var(--p-ink-1); - font-size: 11.5px; + font-size: 12.5px; } .sv-flow[aria-pressed='true'] { @@ -79,10 +79,9 @@ cursor: not-allowed; } -.sv-nonone, .sv-nograph { color: var(--p-ink-3); - font-size: 10.5px; + font-size: 11.5px; } .sv-nograph { @@ -111,7 +110,7 @@ } .sq-title { - font-size: 11px; + font-size: 12px; letter-spacing: 0.06em; text-transform: uppercase; color: var(--p-ink-2); @@ -120,14 +119,14 @@ /* The opposite marker from everywhere else in this repo: this one has to say it IS real. */ .sq-live { color: var(--p-ink-3); - font-size: 9px; + font-size: 10px; letter-spacing: 0.06em; } .sq-refresh { margin-left: auto; color: var(--p-ink-2); - font-size: 10px; + font-size: 11px; text-decoration: underline; text-decoration-color: var(--p-line); text-underline-offset: 2px; @@ -136,7 +135,7 @@ .sq-state { margin: 6px 0 10px; color: var(--p-ink-2); - font-size: 10.5px; + font-size: 11.5px; line-height: 1.5; } @@ -149,24 +148,9 @@ .sq-why { display: block; color: var(--p-ink-3); - font-size: 9.5px; -} - -.sq-group { - margin-bottom: 14px; -} - -.sq-grouphead { - margin-bottom: 4px; - color: var(--p-ink-1); font-size: 10.5px; } -/* Blocked work is the only kind that gets emphasis. The other two are real but not urgent. */ -.sq-group[data-kind='blocked'] .sq-grouphead { - color: var(--p-external); -} - .sq-list { display: flex; flex-direction: column; @@ -193,14 +177,14 @@ .sq-run { color: var(--p-ink-0); - font-size: 10.5px; + font-size: 11.5px; overflow-wrap: anywhere; } .sq-attention, .sq-step { color: var(--p-ink-2); - font-size: 10px; + font-size: 11px; } .sq-step { @@ -217,7 +201,7 @@ .sq-unknown { grid-column: 1 / -1; color: var(--p-run-failed); - font-size: 9.5px; + font-size: 10.5px; } /* ----------------------------------------------------------------- item */ @@ -241,21 +225,20 @@ font-weight: 600; } -.sc-subtitle, .sc-status { color: var(--p-ink-2); - font-size: 11px; + font-size: 12px; } .sc-stale { margin-left: auto; color: var(--p-run-failed); - font-size: 10px; + font-size: 11px; } .sc-state { color: var(--p-ink-2); - font-size: 11px; + font-size: 12px; } .sc-state[data-liveness='unreachable'] { @@ -265,7 +248,7 @@ .sc-why { display: block; color: var(--p-ink-3); - font-size: 9.5px; + font-size: 10.5px; } .sc-block { @@ -275,27 +258,11 @@ .sc-blockhead { margin-bottom: 5px; color: var(--p-ink-3); - font-size: 10px; + font-size: 11px; letter-spacing: 0.06em; text-transform: uppercase; } -.sc-reason, -.sc-recommend { - color: var(--p-ink-0); - font-size: 12px; - line-height: 1.5; -} - -.sc-verbatim { - padding-left: 10px; - border-left: 2px solid var(--p-line); - color: var(--p-ink-1); - font-size: 12px; - font-style: italic; - line-height: 1.5; -} - /* * THE ONE THING THIS VIEW HAS THAT A TRANSCRIPT DOES NOT: a table. These are quantities to weigh against each * other, and comparison wants columns — which was the open question the earlier finding could not settle. @@ -306,6 +273,22 @@ gap: 3px 14px; } +/* One Action per form; inputs stack above the button that submits them. */ +.sc-actions { + display: flex; + flex-direction: column; + gap: 6px; + align-items: flex-start; + margin-bottom: 12px; +} + +.sc-actions label { + display: flex; + flex-direction: column; + gap: 3px; + width: 100%; +} + .sc-fact { display: grid; grid-column: 1 / -1; @@ -314,12 +297,12 @@ .sc-fname { color: var(--p-ink-2); - font-size: 10.5px; + font-size: 11.5px; } .sc-fvalue { color: var(--p-ink-0); - font-size: 11.5px; + font-size: 12.5px; } .sc-facts[data-pivotal='true'] .sc-fvalue { @@ -327,104 +310,8 @@ font-weight: 600; } -.sc-rest { - margin-top: 16px; -} - -.sc-rest summary { - cursor: default; -} - -.sc-decide { - padding-top: 14px; - border-top: 1px solid var(--p-line-soft); -} - .sc-none { color: var(--p-ink-2); - font-size: 11.5px; - line-height: 1.5; -} - -.sc-verdicts { - display: flex; - gap: 8px; -} - -.sc-verdict { - padding: 5px 14px; - border: 1px solid var(--p-line); - border-radius: 5px; - color: var(--p-ink-0); - font-size: 12px; -} - -.sc-verdict:hover:enabled { - background: var(--p-surface-3); -} - -.sc-verdict:disabled { - color: var(--p-ink-3); -} - -.sc-sending, -.sc-outcome { - margin-top: 6px; - font-size: 11px; - line-height: 1.5; -} - -.sc-sending { - color: var(--p-ink-2); -} - -.sc-outcome[data-outcome='accepted'] { - color: var(--p-run-done); -} - -/* Refused and lost are different facts and neither is a success. */ -.sc-outcome[data-outcome='refused'], -.sc-outcome[data-outcome='lost'] { - color: var(--p-run-failed); -} - -.sc-asked { - display: flex; - flex-direction: column; - gap: 8px; - margin-bottom: 8px; -} - -.sc-q { - color: var(--p-ink-2); - font-size: 11px; -} - -.sc-a { - color: var(--p-ink-0); - font-size: 12px; + font-size: 12.5px; line-height: 1.5; } - -.sc-askrow { - display: flex; - gap: 8px; -} - -.sc-askbox { - flex: 1 1 auto; - padding: 5px 9px; - border: 1px solid var(--p-line); - border-radius: 5px; - background: var(--p-surface-1); - color: var(--p-ink-0); - font-size: 12px; -} - -.sc-asksend { - padding: 5px 12px; - border: 1px solid var(--p-line); - border-radius: 5px; - color: var(--p-ink-1); - font-size: 11.5px; -} diff --git a/web/app/v2/css/v2.css b/web/app/v2/css/v2.css index 443b4f501..23ac69b9c 100644 --- a/web/app/v2/css/v2.css +++ b/web/app/v2/css/v2.css @@ -158,7 +158,7 @@ .v2-primary { padding: 4px 9px; border-radius: 5px; - font-size: 11px; + font-size: 12px; } .v2-ghost { @@ -176,7 +176,7 @@ .v2-error { margin: 0 0 8px; color: var(--p-run-failed); - font-size: 11px; + font-size: 12px; } .v2-shell .sq-list { @@ -213,11 +213,643 @@ .v2-column { color: var(--p-ink-2); - font-size: 10px; + font-size: 11px; } .v2-empty { padding: 18px 8px; color: var(--p-ink-3); + font-size: 13px; +} + +/* ------------------------------ run mode: switcher | canvas | run drawer */ + +.v2-shell.v2-run { + min-height: 0; + height: 100%; + font-family: var(--font-sans); +} + +.v2-run-body { + position: relative; + display: grid; + grid-template-columns: 15rem minmax(0, 1fr); + height: 100%; + min-height: 0; +} + +.v2-run-body[data-has-run='true'] { + grid-template-columns: 15rem minmax(0, 1fr) var(--v2-drawer-w, 25rem); +} + +.v2-inbox-case { + display: flex; + overflow-y: auto; + flex-direction: column; + min-width: 0; + min-height: 0; +} + +/* Centred rather than hard-left: a capped reading column with a void beside it reads as broken. */ +.v2-inbox-case .v2-case { + width: 100%; + max-width: 46rem; + margin: 0 auto; + max-height: none; + height: auto; + border-top: 0; +} + +.v2-inbox-empty { + padding: 18px 20px; +} + +/* Collapsed by default so the list reads as a list; the sentence stays visible either way. */ +.fb { + margin: 8px 0 6px; +} + +.fb-summary { + color: var(--p-ink-3); + cursor: pointer; + font-size: 10.5px; + line-height: 1.45; +} + +.fb-summary:hover { + color: var(--p-ink-1); +} + +.fb[open] .fb-summary { + margin-bottom: 8px; + color: var(--p-ink-2); +} + +.rsw { + display: flex; + overflow: hidden; + flex-direction: column; + min-width: 0; + min-height: 0; + padding: 14px 12px; + border-right: 1px solid var(--p-line-soft); +} + +.rsw .sv-choose { + flex-direction: column; + align-items: stretch; +} + +.rsw-scroll { + flex: 1 1 auto; + min-height: 0; + overflow: auto; +} + +/* A stronger rule between groups than between rows: the split is the coarser fact. */ +.rsw-group + .rsw-group { + margin-top: 12px; + padding-top: 10px; + border-top: 1px solid var(--p-line); +} + +.rsw-grouphead { + margin-bottom: 2px; + color: var(--p-ink-3); + font-size: 10px; + font-weight: 500; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.rsw-list { + list-style: none; + margin: 0; + padding: 0; +} + +/* Lines, not boxes: a run is an entry in a list, not a card competing with the canvas. */ +.rsw-item + .rsw-item { + border-top: 1px solid var(--p-line-soft); +} + +.rsw-row { + display: grid; + width: 100%; + border: 0; + border-left: 2px solid transparent; + padding: 7px 8px; + background: transparent; + color: inherit; + cursor: pointer; + gap: 1px 8px; + grid-template-columns: minmax(0, 1fr) auto; + text-align: left; +} + +.rsw-row:hover { + background: var(--p-surface-2); +} + +.rsw-item[data-selected='true'] .rsw-row { + border-left-color: var(--p-run-active); + background: var(--p-surface-3); +} + +.rsw-id { + color: var(--p-ink-0); + font-size: 11.5px; + overflow-wrap: anywhere; +} + +.rsw-time { + color: var(--p-ink-3); + font-size: 10.5px; + text-align: right; + white-space: nowrap; +} + +.rsw-state { + color: var(--p-ink-2); + font-size: 11px; + grid-column: 1 / -1; +} + +.rsw-stranded { + color: var(--p-run-failed); + font-size: 10.5px; + grid-column: 1 / -1; +} + +.rsw-item[data-stranded='true'] .rsw-id, +.rsw-item[data-stranded='true'] .rsw-state { + color: var(--p-ink-3); +} + +.rsw-flowtype { + width: 100%; + margin: 0; + padding: 4px 6px; + border: 1px solid var(--p-line); + border-radius: 5px; + background: var(--p-surface-2); + color: var(--p-ink-0); + font-size: 11.5px; +} + +/* The drawer scrolls; the Actions block does not leave the viewport. */ +.rdw { + display: flex; + overflow-y: auto; + flex-direction: column; + min-width: 0; + min-height: 0; + border-left: 1px solid var(--p-line-soft); +} + +/* Sizes to content: growing it left a gap above whatever follows in the drawer. */ +.rdw .v2-case { + flex: 0 0 auto; + max-height: none; + height: auto; + border-top: 0; +} + + +.rhd { + position: sticky; + top: 0; + z-index: 1; + padding: 12px 16px 10px; + border-bottom: 1px solid var(--p-line-soft); + background: var(--p-surface-1); +} + +.rhd-line { + display: flex; + align-items: baseline; + gap: 8px; +} + +.rhd-id { + color: var(--p-ink-0); + font-size: 13.5px; + font-weight: 600; + overflow-wrap: anywhere; +} + +/* Mono plus a border reads as an engineering surface without a label saying so. */ +.rhd-inspect { + display: inline-flex; + margin-left: auto; + align-items: baseline; + gap: 4px; + padding: 2px 7px; + border: 1px solid var(--p-line); + border-radius: 4px; + color: var(--p-ink-2); + font-size: 11px; + letter-spacing: 0.02em; + white-space: nowrap; +} + +.rhd-inspect:hover { + border-color: var(--p-ink-3); + color: var(--p-ink-0); +} + +.rhd-close { + padding: 0 4px; + border: 0; + background: transparent; + color: var(--p-ink-3); + font-size: 13px; + line-height: 1; + cursor: pointer; +} + +.rhd-close:hover { + color: var(--p-ink-0); +} + +.rhd-facts { + display: grid; + margin-top: 8px; + grid-template-columns: max-content 1fr; + gap: 2px 12px; +} + +.rhd-facts > div { + display: grid; + grid-column: 1 / -1; + grid-template-columns: subgrid; +} + +.rhd-facts dt { + color: var(--p-ink-3); + font-size: 10.5px; + letter-spacing: 0.05em; + text-transform: uppercase; +} + +.rhd-facts dd { + color: var(--p-ink-1); + font-size: 11.5px; + overflow-wrap: anywhere; +} + +.rhd-stop { + margin-top: 10px; + padding: 4px 12px; + border: 1px solid var(--p-run-failed); + border-radius: 5px; + color: var(--p-run-failed); font-size: 12px; } + +/* Follows the canvas selection; the facts and Actions below it do not move. */ +.rdw-band { + display: grid; + gap: 2px; + padding: 10px 16px; + border-bottom: 1px solid var(--p-line-soft); + background: var(--p-surface-2); +} + +.rdw-bandlabel { + color: var(--p-ink-3); + font-size: 10.5px; + letter-spacing: 0.05em; + text-transform: uppercase; +} + +.rdw-band[data-tone='blocked'] .rdw-bandlabel { + color: var(--p-external); +} + +.rdw-band[data-tone='failed'] .rdw-bandlabel { + color: var(--p-run-failed); +} + +.rdw-bandstep { + color: var(--p-ink-0); + font-size: 12.5px; +} + +.rdw-bandwhy { + color: var(--p-ink-2); + font-size: 11px; + line-height: 1.45; +} + +/* -------------------------------------------------- queue mode, no canvas */ + +.v2-shell.v2-queue { + display: flex; + flex-direction: column; +} + +.v2-queue .sq { + border-right: 1px solid var(--p-line-soft); +} + +.v2-queue .v2-case { + max-height: none; + height: 100%; + border-top: none; + padding: 16px 20px 20px; +} + +.v2-queue .sc-none { + padding: 18px 20px; +} + +.v2-seemore { + margin-left: auto; + color: var(--p-ink-2); + font-size: 12px; + white-space: nowrap; +} + +.v2-seemore:hover { + color: var(--p-ink-1); +} + + +/* -------------------------------------- flow-level meaning of the open Step */ + +.scx { + padding: 14px 16px 20px; + border-top: 1px solid var(--p-line-soft); +} + +/* Standalone: no run selected, so the Step is all the drawer has to say. */ +.rdw[data-step-only='true'] .scx { + border-top: 0; +} + +.scx-step { + color: var(--p-ink-0); + font-size: 12px; +} + +.scx-purpose { + margin-top: 3px; + color: var(--p-ink-1); + font-size: 12px; + line-height: 1.5; +} + +.scx-facts { + display: grid; + margin-top: 10px; + grid-template-columns: max-content 1fr; + gap: 4px 14px; +} + +.scx-fact { + display: grid; + grid-column: 1 / -1; + grid-template-columns: subgrid; +} + +.scx-fact dt { + color: var(--p-ink-3); + font-size: 10px; + letter-spacing: 0.04em; + text-transform: uppercase; +} + +.scx-fact dd { + color: var(--p-ink-1); + font-size: 11.5px; + line-height: 1.45; + overflow-wrap: anywhere; +} + +.rdw-steponly { + display: flex; + align-items: baseline; + gap: 8px; + padding: 12px 16px 10px; + border-bottom: 1px solid var(--p-line-soft); +} + +.rdw-steponlylabel { + color: var(--p-ink-3); + font-size: 10px; + letter-spacing: 0.06em; + text-transform: uppercase; +} + +.rdw-steponly .rhd-close { + margin-left: auto; +} + +/* --------------------------------- three zones: pick a Flow, find runs, read the list + + Each is a separate surface because they are separate acts. Stacked controls with no boundary + read as one long form, and the list underneath looked like a fourth field. */ + +.rsw-zone { + margin-bottom: 10px; +} + +.rsw-zone[data-zone='find'] { + padding: 8px 9px 2px; + border: 1px solid var(--p-line-soft); + border-radius: 6px; + background: var(--p-surface-2); +} + +.rsw-zonehead { + margin: 0 0 6px; + color: var(--p-ink-2); + font-size: 9.5px; + font-weight: 600; + letter-spacing: 0.07em; + text-transform: uppercase; +} + +/* The list owns the rest of the panel, and a rule says where the controls stop. */ +.rsw-scroll[data-zone='list'] { + padding-top: 8px; + border-top: 1px solid var(--p-line); +} + +/* ------------------------------------------ find a run: two controls, three more behind one click */ + +.rsq { + display: flex; + flex-direction: column; + gap: 5px; +} + +.rsq-basic { + display: grid; + gap: 5px; + grid-template-columns: 1fr 1fr; +} + +.rsq-adv { + margin-top: 1px; +} + +.rsq-advhead { + padding: 3px 0; + color: var(--p-ink-2); + cursor: pointer; + font-size: 10.5px; + list-style: none; +} + +.rsq-advhead::marker, +.rsq-advhead::-webkit-details-marker { + display: none; +} + +/* A caret the reader can see, since the native marker is suppressed. */ +.rsq-advhead::before { + display: inline-block; + margin-right: 5px; + content: '▸'; + transition: transform 0.12s ease; +} + +.rsq-adv[open] > .rsq-advhead::before { + transform: rotate(90deg); +} + +.rsq-advhead:hover { + color: var(--p-ink-0); +} + +.rsq-field { + display: block; + margin-top: 6px; +} + +.rsq-label { + display: block; + margin-bottom: 3px; + color: var(--p-ink-3); + font-size: 9.5px; + letter-spacing: 0.04em; + text-transform: uppercase; +} + +/* The operator takes the width of its longest word; the value gets everything left. */ +.rsq-compare { + display: grid; + gap: 5px; + margin-top: 4px; + grid-template-columns: auto minmax(0, 1fr); +} + +.rsq-compare select { + width: auto; +} + +.rsq select, +.rsq input { + width: 100%; + min-width: 0; + padding: 4px 6px; + border: 1px solid var(--p-line); + border-radius: 5px; + background: var(--p-surface-2); + color: var(--p-ink-0); + font-size: 11.5px; +} + +.rsq select:disabled, +.rsq input:disabled { + color: var(--p-ink-3); +} + +.rsq-actions { + display: flex; + gap: 6px; + margin-top: 2px; +} + +/* ------------------------------ progress through a run's human gates */ + +.apg { + padding: 10px 16px; + border-bottom: 1px solid var(--p-line-soft); +} + +.apg-head { + display: flex; + align-items: baseline; + gap: 8px; +} + +.apg-label { + color: var(--p-ink-3); + font-size: 9.5px; + letter-spacing: 0.05em; + text-transform: uppercase; +} + +.apg-count { + margin-left: auto; + color: var(--p-ink-3); + font-size: 10px; +} + +.apg-list { + display: flex; + flex-direction: column; + gap: 3px; + margin: 7px 0 0; + padding: 0; + list-style: none; +} + +.apg-step { + display: flex; + align-items: center; + gap: 7px; +} + +.apg-mark { + display: inline-flex; + width: 15px; + height: 15px; + flex: 0 0 auto; + align-items: center; + justify-content: center; + border: 1px solid var(--p-line); + border-radius: 50%; + color: var(--p-ink-3); + font-size: 9px; +} + +.apg-name { + color: var(--p-ink-2); + font-size: 10.5px; + overflow-wrap: anywhere; +} + +/* The open gate is the one thing here worth emphasis. */ +.apg-step[data-state='current'] .apg-mark { + border-color: var(--p-external); + background: var(--p-external); + color: var(--p-surface-1); +} + +.apg-step[data-state='current'] .apg-name { + color: var(--p-ink-0); + font-weight: 600; +} + +.apg-step[data-state='done'] .apg-mark { + border-color: var(--p-run-done); + color: var(--p-run-done); +} + +.apg-step[data-state='upcoming'] .apg-name { + color: var(--p-ink-3); +} diff --git a/web/app/v2/debug/DebugWorkspace.tsx b/web/app/v2/debug/DebugWorkspace.tsx new file mode 100644 index 000000000..440ec4210 --- /dev/null +++ b/web/app/v2/debug/DebugWorkspace.tsx @@ -0,0 +1,74 @@ +// Copyright (c) 2026 Super Durable, Inc. +// +// Licensed under the Sustainable Use License 1.0. +// You may not use this file except in compliance with the License. +// See the LICENSE file in the repository root. +// +// SPDX-License-Identifier: LicenseRef-Sustainable-Use-1.0 + +import { useEffect, useState } from 'react'; +import { Link, useParams } from 'react-router-dom'; +import { RunDetailsPage } from '@/app/flows/RunDetailsPage'; +import { readResponseJSON } from '@/lib/http'; +import type { FlowSummary } from '@/lib/types'; +import { v2DebugPath, v2RunPath } from '../contract'; +import '../css/v2.css'; +import '../css/debug.css'; +import { DEBUG_COPY } from './copy'; + +/** + * The Deep Dive: v1's detail components, unchanged, inside the v2 shell. + * + * Its own runId, because Time Travel and continue-as-new navigate the run chain and the + * Run view only ever shows the current run. + */ +export function DebugWorkspace() { + const { flowType = '', flowId = '', runId = '' } = useParams(); + const [resolvedRunID, setResolvedRunID] = useState(runId); + const [error, setError] = useState(''); + + useEffect(() => { + if (runId) { + setResolvedRunID(runId); + return undefined; + } + const controller = new AbortController(); + void fetch(`/api/flows/summary?flowId=${encodeURIComponent(flowId)}`, { + signal: controller.signal, + }) + .then((response) => readResponseJSON(response)) + .then((summary) => setResolvedRunID(summary.runId)) + .catch((loadError: unknown) => { + if (!controller.signal.aborted) { + setError(loadError instanceof Error ? loadError.message : DEBUG_COPY.runUnresolved); + } + }); + return () => controller.abort(); + }, [flowId, runId]); + + return ( +
+
+ {DEBUG_COPY.back} + {DEBUG_COPY.label} + {DEBUG_COPY.note} +
+ {error &&
{error}
} + {!error && !resolvedRunID &&
{DEBUG_COPY.resolving}
} + {resolvedRunID && ( + + {flowType}/ + {flowId}/ + {resolvedRunID} +
+ )} + flowId={flowId} + runId={resolvedRunID} + runPath={(chainRunID) => v2DebugPath(flowType, flowId, chainRunID)} + /> + )} +
+ ); +} diff --git a/web/app/v2/debug/copy.ts b/web/app/v2/debug/copy.ts new file mode 100644 index 000000000..e42cd6595 --- /dev/null +++ b/web/app/v2/debug/copy.ts @@ -0,0 +1,16 @@ +// Copyright (c) 2026 Super Durable, Inc. +// +// Licensed under the Sustainable Use License 1.0. +// You may not use this file except in compliance with the License. +// See the LICENSE file in the repository root. +// +// SPDX-License-Identifier: LicenseRef-Sustainable-Use-1.0 + +export const DEBUG_COPY = { + label: 'Deep dive', + note: 'Everything the run recorded. The Run view shows what it means.', + back: '← Run', + resolving: 'Finding the current run…', + runUnresolved: 'Could not find a run for this Flow ID.', + openLabel: 'Deep dive', +} as const; diff --git a/web/app/v2/queue/QueueWorkspace.tsx b/web/app/v2/queue/QueueWorkspace.tsx new file mode 100644 index 000000000..56e987aec --- /dev/null +++ b/web/app/v2/queue/QueueWorkspace.tsx @@ -0,0 +1,113 @@ +// Copyright (c) 2026 Super Durable, Inc. +// +// Licensed under the Sustainable Use License 1.0. +// You may not use this file except in compliance with the License. +// See the LICENSE file in the repository root. +// +// SPDX-License-Identifier: LicenseRef-Sustainable-Use-1.0 + +import { Link, Navigate, useNavigate, useParams } from 'react-router-dom'; +import { v2QueuePath, v2RunPath } from '../contract'; +import '../css/v2.css'; +import { useWebCatalog } from '../WebCatalogProvider'; +import { RunList } from '../workspace/RunList'; +import { RunSearch } from '../workspace/RunSearch'; +import { EMPTY_RUN_QUERY } from '../workspace/runQuery'; +import { useRunQuery } from '../workspace/useRunQuery'; +import { SelectedRunPanel } from '../workspace/SelectedRunPanel'; +import { useFlowSearch } from '../workspace/useFlowSearch'; +import { useStrandedRuns } from '../workspace/useStrandedRuns'; +import { QUEUE_COPY } from './copy'; +import { openFlowStatusLabel } from './liveness'; + +/** + * What has arrived for the reader. No canvas: clearing work does not need the shape of the + * process, and "See the process" opens the same run in Run mode. + * + * Shares its list and its case panel with Run, so the two cannot drift apart. What differs is + * deliberate: the scope control, and evidence before the decision. + */ +export function QueueWorkspace() { + const { flowType = '', flowId = '' } = useParams(); + const navigate = useNavigate(); + const { ready, canUseV2, catalog, error } = useWebCatalog(); + const entry = catalog?.flows.find((candidate) => candidate.flowType === flowType); + // An inbox opens on what is still open; the control is there to widen it. + const runQuery = useRunQuery(entry?.definition, { ...EMPTY_RUN_QUERY, status: openFlowStatusLabel() }); + const search = useFlowSearch(flowType || undefined, entry?.definition, runQuery.appliedFilters); + const { strandedFlowIDs, rememberStranded } = useStrandedRuns(); + + if (!ready) return
Loading Dex Web…
; + if (!canUseV2) return ; + if (error) return
{error}
; + if (!catalog) return
Loading Dex Web…
; + if (catalog.flows.length === 0) { + return ( +
+
Load a valid Flow Definition Graph 2.0 file to work an inbox in v2.
+
+ ); + } + if (!flowType) return ; + if (!entry) return ; + + const selectedFlow = search.flows.find((flow) => flow.flowId === flowId); + return ( +
+
+ + )} + search={search} + selectedFlowID={flowId} + strandedFlowIDs={strandedFlowIDs} + onSelectFlowType={(next) => navigate(v2QueuePath(next))} + onSelectRun={(nextFlowID) => navigate(v2QueuePath(entry.flowType, nextFlowID))} + /> +
+ {flowId ? ( + + + {QUEUE_COPY.seeProcess} + + + + )} + /> + ) : ( +

{QUEUE_COPY.selectPrompt}

+ )} +
+
+
+ ); +} diff --git a/web/app/v2/queue/copy.ts b/web/app/v2/queue/copy.ts new file mode 100644 index 000000000..646158c43 --- /dev/null +++ b/web/app/v2/queue/copy.ts @@ -0,0 +1,40 @@ +// Copyright (c) 2026 Super Durable, Inc. +// +// Licensed under the Sustainable Use License 1.0. +// You may not use this file except in compliance with the License. +// See the LICENSE file in the repository root. +// +// SPDX-License-Identifier: LicenseRef-Sustainable-Use-1.0 + +/** + * Every string the queue can say, in one table so the wording can be audited. + * + * Nothing here claims a person is needed. A Flow Definition Graph declares when an Action + * may be offered, not who must act, so the queue says what it read and where it read it. + */ +export const QUEUE_COPY = { + appName: 'Inbox', + liveNote: 'live', + + /** Derived from the live filter rows: the sentence must not outlive a filter the reader deleted. */ + clear: 'Nothing open on this page.', + loading: 'Asking the process…', + unreachable: 'Cannot reach the process, so this list is not the whole picture.', + stale: 'Showing the last answer — the process did not respond just now.', + staleShort: 'stale', + + /** Actions are gated on live Attributes, so the list cannot promise one is available. */ + actionsProvenance: 'Which Actions are available is decided per run when you open it.', + + selectPrompt: 'Select a run to see what it reports and which Actions are available.', + seeProcess: 'See the process', + close: 'Close this item', + + /** + * Dex routes a Flow RPC to the worker that owns the run, so this run is unreadable for + * good while the server itself is fine. + */ + stranded: + 'This one cannot be read or acted on: it was started by a worker that is no longer running, so nothing can reach it. The process itself is fine.', + strandedRow: 'cannot be reached', +} as const; diff --git a/web/app/v2/queue/liveness.test.ts b/web/app/v2/queue/liveness.test.ts new file mode 100644 index 000000000..94125e6b1 --- /dev/null +++ b/web/app/v2/queue/liveness.test.ts @@ -0,0 +1,128 @@ +// Copyright (c) 2026 Super Durable, Inc. +// +// Licensed under the Sustainable Use License 1.0. +// You may not use this file except in compliance with the License. +// See the LICENSE file in the repository root. +// +// SPDX-License-Identifier: LicenseRef-Sustainable-Use-1.0 + +import { describe, expect, it } from 'vitest'; +import { DexAPIError } from '@/lib/http'; +import { + RUNNING_FLOW_STATUS_CODE, + absorb, + classifyReadFailure, + isOpenFlowStatusCode, + isStrandedRunFailure, + nothingHeld, + openFlowStatusLabel, + type Held, +} from './liveness'; + +const COMPLETED_FLOW_STATUS_CODE = 2; + +// Measured against a live server: a dead worker and a closed run share this code and status. +const workerGone = () => new DexAPIError( + 'connection error: desc = "transport: Error while dialing: dial tcp 127.0.0.1:8873: connect: connection refused"', + 409, + 9, +); +const inactiveFlow = () => new DexAPIError('Flow is not active', 409, 9); + +describe('absorb', () => { + it('starts as loading, which is not the same as an empty answer', () => { + expect(nothingHeld()).toEqual({ value: null, liveness: 'loading', reason: null }); + }); + + it('keeps the previous answer and marks it stale when a refresh fails', () => { + const ok = absorb(nothingHeld(), { state: 'ok', value: ['a'] }); + const failed = absorb(ok, { state: 'unreachable', reason: 'timeout' }); + expect(failed.value).toEqual(['a']); + expect(failed.liveness).toBe('stale'); + expect(failed.reason).toBe('timeout'); + }); + + it('reports unreachable with no value when nothing ever arrived', () => { + const failed = absorb(nothingHeld(), { state: 'unreachable', reason: 'refused' }); + expect(failed.value).toBeNull(); + expect(failed.liveness).toBe('unreachable'); + }); + + it('distinguishes an empty success from a failure', () => { + const empty = absorb(nothingHeld(), { state: 'ok', value: [] }); + expect(empty.liveness).toBe('ok'); + expect(empty.value).toEqual([]); + expect(empty.reason).toBeNull(); + }); + + it('does not call a stranded run stale, because nothing is coming back', () => { + const ok = absorb(nothingHeld(), { state: 'ok', value: ['a'] }); + const stranded = absorb(ok, { state: 'stranded', reason: 'worker gone' }); + expect(stranded.liveness).toBe('stranded'); + expect(stranded.value).toEqual(['a']); + }); + + it('clears a stale reason once a read succeeds again', () => { + const stale = absorb( + { value: ['a'], liveness: 'stale', reason: 'timeout' }, + { state: 'ok', value: ['b'] }, + ); + expect(stale).toEqual({ value: ['b'], liveness: 'ok', reason: null }); + }); + + it('is pure: it neither mutates the prior nor varies between identical calls', () => { + const prior: Held = { value: ['a'], liveness: 'ok', reason: null }; + const frozen = { ...prior, value: [...prior.value as string[]] }; + const first = absorb(prior, { state: 'unreachable', reason: 'timeout' }); + const second = absorb(prior, { state: 'unreachable', reason: 'timeout' }); + expect(first).toEqual(second); + expect(prior).toEqual(frozen); + }); +}); + +describe('isStrandedRunFailure', () => { + it('calls a running run whose worker exited stranded', () => { + expect(isStrandedRunFailure(workerGone(), RUNNING_FLOW_STATUS_CODE)).toBe(true); + }); + + it('does not call a closed run stranded, even though its Display is equally unreadable', () => { + expect(isStrandedRunFailure(workerGone(), COMPLETED_FLOW_STATUS_CODE)).toBe(false); + }); + + it('does not treat "Flow is not active" as stranded, though it shares the gRPC code', () => { + expect(isStrandedRunFailure(inactiveFlow(), RUNNING_FLOW_STATUS_CODE)).toBe(false); + }); + + it('ignores other gRPC codes and plain errors', () => { + const notFound = new DexAPIError('workflow not found for ID: nope', 404, 5); + expect(isStrandedRunFailure(notFound, RUNNING_FLOW_STATUS_CODE)).toBe(false); + expect(isStrandedRunFailure(new Error('network down'), RUNNING_FLOW_STATUS_CODE)).toBe(false); + }); + + it('is not stranded when the run status is unknown', () => { + expect(isStrandedRunFailure(workerGone(), undefined)).toBe(false); + }); +}); + +describe('classifyReadFailure', () => { + it('routes a stranded run and a general failure to different outcomes', () => { + expect(classifyReadFailure(workerGone(), RUNNING_FLOW_STATUS_CODE).state).toBe('stranded'); + expect(classifyReadFailure(workerGone(), COMPLETED_FLOW_STATUS_CODE).state).toBe('unreachable'); + const failure = classifyReadFailure(new Error('boom'), RUNNING_FLOW_STATUS_CODE); + expect(failure.state === 'ok' ? '' : failure.reason).toBe('boom'); + }); +}); + +describe('open flow status', () => { + it('treats only Running as open work', () => { + expect(isOpenFlowStatusCode(RUNNING_FLOW_STATUS_CODE)).toBe(true); + for (const closed of [0, 2, 3, 4, 5, 6, 7]) { + expect(isOpenFlowStatusCode(closed)).toBe(false); + } + expect(isOpenFlowStatusCode(undefined)).toBe(false); + }); + + it('names the status with the label the search filter accepts', () => { + expect(openFlowStatusLabel()).toBe('Running'); + }); +}); diff --git a/web/app/v2/queue/liveness.ts b/web/app/v2/queue/liveness.ts new file mode 100644 index 000000000..5482db4a8 --- /dev/null +++ b/web/app/v2/queue/liveness.ts @@ -0,0 +1,85 @@ +// Copyright (c) 2026 Super Durable, Inc. +// +// Licensed under the Sustainable Use License 1.0. +// You may not use this file except in compliance with the License. +// See the LICENSE file in the repository root. +// +// SPDX-License-Identifier: LicenseRef-Sustainable-Use-1.0 + +import { DexAPIError } from '@/lib/http'; +import { FLOW_STATUS } from '@/lib/types'; + +/** + * An empty answer, a failed refresh over a good answer, and never having asked are + * three different things. Collapsing them sends a reader to the wrong place. + */ +export type Liveness = 'loading' | 'ok' | 'stale' | 'unreachable' | 'stranded'; + +export interface Held { + readonly value: T | null; + readonly liveness: Liveness; + readonly reason: string | null; +} + +export type ReadOutcome = + | { readonly state: 'ok'; readonly value: T } + | { readonly state: 'unreachable'; readonly reason: string } + | { readonly state: 'stranded'; readonly reason: string }; + +export function nothingHeld(): Held { + return { value: null, liveness: 'loading', reason: null }; +} + +export function absorb(prior: Held, next: ReadOutcome): Held { + if (next.state === 'ok') return { value: next.value, liveness: 'ok', reason: null }; + // A stranded run keeps its own liveness: nothing is coming back, so it is not stale. + if (next.state === 'stranded') { + return { value: prior.value, liveness: 'stranded', reason: next.reason }; + } + // Showing a true-a-moment-ago answer late beats blanking it; reporting it as fresh is worse. + if (prior.value !== null) return { value: prior.value, liveness: 'stale', reason: next.reason }; + return { value: null, liveness: 'unreachable', reason: next.reason }; +} + +export function readFailureReason(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +/** + * Dex routes a Flow RPC to the worker that owns the run, so a worker that exited makes + * one run permanently unreadable while the server stays healthy. + * + * Measured, not assumed: that surfaces as FailedPrecondition, which the same code also + * uses for "Flow is not active". A closed run is therefore excluded by status — its + * Display is equally unreadable, but nothing is owed on it. + */ +const GRPC_FAILED_PRECONDITION = 9; +const INACTIVE_FLOW_MESSAGE = 'Flow is not active'; + +export function isStrandedRunFailure(error: unknown, flowStatusCode: number | undefined): boolean { + if (!(error instanceof DexAPIError)) return false; + if (error.grpcCode !== GRPC_FAILED_PRECONDITION) return false; + if (error.message.trim() === INACTIVE_FLOW_MESSAGE) return false; + return isOpenFlowStatusCode(flowStatusCode); +} + +export function classifyReadFailure( + error: unknown, + flowStatusCode: number | undefined, +): ReadOutcome { + const reason = readFailureReason(error); + return isStrandedRunFailure(error, flowStatusCode) + ? { state: 'stranded', reason } + : { state: 'unreachable', reason }; +} + +/** Running is the only non-terminal status. Gate on the code: the Go and TS labels disagree. */ +export const RUNNING_FLOW_STATUS_CODE = 1; + +export function isOpenFlowStatusCode(flowStatusCode: number | undefined): boolean { + return flowStatusCode === RUNNING_FLOW_STATUS_CODE; +} + +export function openFlowStatusLabel(): string { + return FLOW_STATUS[RUNNING_FLOW_STATUS_CODE]; +} diff --git a/web/app/v2/run/RunDetailDrawer.tsx b/web/app/v2/run/RunDetailDrawer.tsx new file mode 100644 index 000000000..f39ef47c0 --- /dev/null +++ b/web/app/v2/run/RunDetailDrawer.tsx @@ -0,0 +1,158 @@ +// Copyright (c) 2026 Super Durable, Inc. +// +// Licensed under the Sustainable Use License 1.0. +// You may not use this file except in compliance with the License. +// See the LICENSE file in the repository root. +// +// SPDX-License-Identifier: LicenseRef-Sustainable-Use-1.0 + +import type { FlowV2Definition } from '@superdurable/flow-definition-renderer'; +import type { FlowSummary } from '@/lib/types'; +import { SelectedRunPanel } from '../workspace/SelectedRunPanel'; +import { RUN_COPY } from './copy'; +import { RunHeader } from './RunHeader'; +import { hasMultipleActions, type ActionableStep } from './actionableSteps'; +import type { StepContextView } from './stepContext'; + +/** What the canvas is currently showing, so the drawer can say so without owning selection. */ +export interface StepBand { + stepType: string; + /** The templated why-line the canvas already renders, or null for a healthy Step. */ + reason: string | null; + tone: 'blocked' | 'failed' | 'terminal' | null; + /** True when this is the Step the run is actually waiting on. */ + isBlocking: boolean; +} + +/** + * Run facts and Actions stay put; only the top band follows the canvas. Actions are never + * hidden behind a tab or a scroll, because they are the reason an Admin opened the run. + */ +export function RunDetailDrawer({ + flowType, + flowId, + definition, + summary, + flowStatusCode, + band, + actionable, + stepContext, + reloadKey, + onStranded, + onStopped, + onClose, +}: { + flowType: string; + /** Empty when nothing is selected: the drawer then explains the Step alone. */ + flowId: string; + definition: FlowV2Definition; + summary: FlowSummary | null; + flowStatusCode?: number; + band: StepBand | null; + actionable: readonly ActionableStep[]; + stepContext: StepContextView | null; + reloadKey: number; + onStranded?: (flowID: string) => void; + onStopped: () => void; + onClose: () => void; +}) { + const hasRun = flowId !== ''; + return ( +
+ {hasRun ? ( + + ) : ( +
+ {RUN_COPY.stepOnly} + +
+ )} + {hasRun && hasMultipleActions(actionable) && } + {hasRun && band && ( +
+ + {band.isBlocking ? RUN_COPY.waitingAt : RUN_COPY.showingStep} + + {band.stepType} + {band.reason && {band.reason}} +
+ )} + {hasRun && ( + { + // Closing was right when one Action ended the run. With another gate pending, + // advancing is the point: the reader should not have to find it again. + if (actionable.filter((step) => step.state !== 'done').length <= 1) onClose(); + }} + onStranded={onStranded} + /> + )} + {stepContext !== null && } +
+ ); +} + +/** What the selected Step is for, and where it sits. Flow-level: no run values here. */ +function StepContextBlock({ view }: { view: StepContextView }) { + return ( +
+
{RUN_COPY.thisStep}
+

{view.stepType}

+

{view.explanation ?? RUN_COPY.noPurpose}

+
+ {view.facts.map((fact) => ( +
+
{fact.label}
+
{fact.value}
+
+ ))} +
+
+ ); +} + +/** + * Progress through a Flow's human gates. Only one is ever open, so this reads as a sequence + * rather than a list of things that could be done now. + */ +function ActionProgress({ steps }: { steps: readonly ActionableStep[] }) { + const current = steps.findIndex((step) => step.state === 'current'); + const remaining = steps.filter((step) => step.state !== 'done').length; + return ( +
+
+ {RUN_COPY.actionProgress} + + {current < 0 ? RUN_COPY.allActionsDone : RUN_COPY.actionsLeft(remaining)} + +
+
    + {steps.map((step, index) => ( +
  1. + + {step.stepType} +
  2. + ))} +
+
+ ); +} diff --git a/web/app/v2/run/RunHeader.tsx b/web/app/v2/run/RunHeader.tsx new file mode 100644 index 000000000..102cb463c --- /dev/null +++ b/web/app/v2/run/RunHeader.tsx @@ -0,0 +1,89 @@ +// Copyright (c) 2026 Super Durable, Inc. +// +// Licensed under the Sustainable Use License 1.0. +// You may not use this file except in compliance with the License. +// See the LICENSE file in the repository root. +// +// SPDX-License-Identifier: LicenseRef-Sustainable-Use-1.0 + +import { useState } from 'react'; +import { Link } from 'react-router-dom'; +import { StopFlowDialog } from '@/app/flows/details/StopFlowDialog'; +import { formatDate, formatDuration } from '@/lib/format'; +import type { FlowSummary } from '@/lib/types'; +import { usePreferences } from '../../providers'; +import { v2DebugPath } from '../contract'; +import { isOpenFlowStatusCode } from '../queue/liveness'; +import { RUN_COPY } from './copy'; + +/** + * The run's own identity, which v2 never rendered even though the canvas already fetches it. + * Stop lives here because v2 could not stop a run at all before. + */ +export function RunHeader({ + flowType, + flowId, + summary, + onStopped, + onClose, +}: { + flowType: string; + flowId: string; + summary: FlowSummary | null; + onStopped: () => void; + onClose: () => void; +}) { + const { timezone } = usePreferences(); + const [stopOpen, setStopOpen] = useState(false); + const isOpen = isOpenFlowStatusCode(summary?.flowStatusCode); + return ( +
+
+ {flowId} + {summary && {summary.flowStatus}} + + {RUN_COPY.inspect} + + + +
+ {summary && ( +
+
Run
{summary.runId}
+
Started
{formatDate(summary.startTime, timezone)}
+
+
{isOpen ? RUN_COPY.elapsed : RUN_COPY.closed}
+
+ {isOpen + ? formatDuration(summary.startTime, null) + : formatDate(summary.closeTime, timezone)} +
+
+
+ )} + {summary && isOpen && ( + + )} + {summary && ( + setStopOpen(false)} + onStopped={() => { + setStopOpen(false); + onStopped(); + }} + /> + )} +
+ ); +} + diff --git a/web/app/v2/run/actionableSteps.test.ts b/web/app/v2/run/actionableSteps.test.ts new file mode 100644 index 000000000..fc34625e0 --- /dev/null +++ b/web/app/v2/run/actionableSteps.test.ts @@ -0,0 +1,127 @@ +// Copyright (c) 2026 Super Durable, Inc. +// +// Licensed under the Sustainable Use License 1.0. +// You may not use this file except in compliance with the License. +// See the LICENSE file in the repository root. +// +// SPDX-License-Identifier: LicenseRef-Sustainable-Use-1.0 + +import { describe, expect, it } from 'vitest'; +import type { PocFlow } from '../canvas/model/pocFlow'; +import type { RunOverlay } from '../canvas/model/run'; +import { actionableSteps, hasMultipleActions } from './actionableSteps'; + +/** + * A two-gate refund shape: start -> guard -> approve(gate) -> draft -> confirm(gate) -> send. + * The gates wait on their own Channels, each opened by its own external entry. + */ +const flow = { + steps: [ + step('step:Start', 'ReceiveStep', { isStart: true }), + step('step:Guard', 'GuardrailStep'), + step('step:Approve', 'RequestHumanApprovalStep', { channel: 'manager-approval' }), + step('step:Draft', 'DraftCustomerMessageStep'), + step('step:Confirm', 'ConfirmCustomerMessageStep', { channel: 'message-approval' }), + step('step:Send', 'SendCustomerMessageStep'), + ], + transitions: [ + edge('step:Start', 'step:Guard'), + edge('step:Guard', 'step:Approve'), + edge('step:Approve', 'step:Draft'), + edge('step:Draft', 'step:Confirm'), + edge('step:Confirm', 'step:Send'), + ], + entries: [ + { name: 'ApproveRefund', opensGates: [{ channelId: 'manager-approval' }] }, + { name: 'RejectRefund', opensGates: [{ channelId: 'manager-approval' }] }, + { name: 'ConfirmCustomerMessage', opensGates: [{ channelId: 'message-approval' }] }, + ], +} as unknown as PocFlow; + +function step(id: string, stepType: string, opts: { isStart?: boolean; channel?: string } = {}) { + return { + id, + stepType, + label: stepType, + isStart: opts.isStart ?? false, + actor: 'machine', + waitFor: opts.channel === undefined ? null : { + type: 'until', + sentence: `waits on ${opts.channel}`, + conditions: [{ kind: 'channel', resourceId: opts.channel, label: opts.channel }], + }, + execute: { branches: [] }, + resources: [], + }; +} + +function edge(fromStepId: string, toStepId: string) { + return { id: `${fromStepId}->${toStepId}`, fromStepId, toStepId, kind: 'transition', guard: null, mergedGuards: [], isSelfLoop: false }; +} + +const overlay = (open: string[], ran: string[]) => ({ + executions: [ + ...ran.map((stepType) => ({ + stepType, waitFor: { status: 'completed' }, execute: { status: 'completed' }, + })), + ...open.map((stepType) => ({ + stepType, waitFor: { status: 'waiting' }, execute: { status: 'not_started' }, + })), + ], +} as unknown as RunOverlay); + +describe('actionableSteps', () => { + it('finds only the Steps something outside the Flow answers', () => { + expect(actionableSteps(flow, null).map((s) => s.stepType)) + .toEqual(['RequestHumanApprovalStep', 'ConfirmCustomerMessageStep']); + }); + + it('orders them the way the graph reaches them, not by name', () => { + const reordered = { ...flow, steps: [...flow.steps].reverse() } as unknown as PocFlow; + expect(actionableSteps(reordered, null).map((s) => s.stepType)) + .toEqual(['RequestHumanApprovalStep', 'ConfirmCustomerMessageStep']); + }); + + it('names the RPCs that answer each gate', () => { + const [approve, confirm] = actionableSteps(flow, null); + expect(approve.answeredBy).toEqual(['ApproveRefund', 'RejectRefund']); + expect(confirm.answeredBy).toEqual(['ConfirmCustomerMessage']); + }); + + it('marks the open gate current and the later one upcoming', () => { + const steps = actionableSteps(flow, overlay(['RequestHumanApprovalStep'], ['GuardrailStep'])); + expect(steps.map((s) => s.state)).toEqual(['current', 'upcoming']); + }); + + it('advances: the answered gate is done and the next one is current', () => { + const steps = actionableSteps( + flow, + overlay(['ConfirmCustomerMessageStep'], ['GuardrailStep', 'RequestHumanApprovalStep']), + ); + expect(steps.map((s) => s.state)).toEqual(['done', 'current']); + }); + + it('shows every gate done once the run has closed', () => { + const steps = actionableSteps( + flow, + overlay([], ['RequestHumanApprovalStep', 'ConfirmCustomerMessageStep']), + ); + expect(steps.map((s) => s.state)).toEqual(['done', 'done']); + }); + + it('calls a gate a branch skipped done rather than still pending', () => { + // Small refunds never reach the manager gate, but still need the message confirmed. + const steps = actionableSteps(flow, overlay(['ConfirmCustomerMessageStep'], ['GuardrailStep'])); + expect(steps.map((s) => s.state)).toEqual(['done', 'current']); + }); + + it('treats everything as upcoming before a run exists', () => { + expect(actionableSteps(flow, null).map((s) => s.state)).toEqual(['upcoming', 'upcoming']); + }); + + it('draws no stepper for a single gate', () => { + expect(hasMultipleActions(actionableSteps(flow, null))).toBe(true); + expect(hasMultipleActions([])).toBe(false); + expect(hasMultipleActions(actionableSteps(flow, null).slice(0, 1))).toBe(false); + }); +}); diff --git a/web/app/v2/run/actionableSteps.ts b/web/app/v2/run/actionableSteps.ts new file mode 100644 index 000000000..98959728b --- /dev/null +++ b/web/app/v2/run/actionableSteps.ts @@ -0,0 +1,91 @@ +// Copyright (c) 2026 Super Durable, Inc. +// +// Licensed under the Sustainable Use License 1.0. +// You may not use this file except in compliance with the License. +// See the LICENSE file in the repository root. +// +// SPDX-License-Identifier: LicenseRef-Sustainable-Use-1.0 + +import type { PocFlow } from '../canvas/model/pocFlow'; +import { activeStepTypes, type RunOverlay } from '../canvas/model/run'; +import { answeredBy } from '../canvas/views/stepBox'; + +export type ActionableState = 'done' | 'current' | 'upcoming'; + +export interface ActionableStep { + stepId: string; + stepType: string; + /** The RPC names that answer this Step's wait, from the graph. */ + answeredBy: string[]; + state: ActionableState; +} + +/** + * The Steps of a Flow that wait for a person, in the order the graph reaches them. + * + * Derived, not declared: a Step is human-actionable when something outside the Flow publishes to + * the Channel it waits on, which `answeredBy` reads straight off the graph. So this needs no new + * contract — and a Flow that grows a third gate grows a third entry with no UI change. + */ +export function actionableSteps(flow: PocFlow, overlay: RunOverlay | null): ActionableStep[] { + const open = overlay === null ? [] : activeStepTypes(overlay); + const ordered = orderedStepIds(flow); + const waiting = flow.steps + .filter((step) => answeredBy(flow, step).length > 0) + .sort((left, right) => ordered.indexOf(left.id) - ordered.indexOf(right.id)); + + const currentIndex = waiting.findIndex((step) => open.includes(step.stepType)); + return waiting.map((step, index) => ({ + stepId: step.id, + stepType: step.stepType, + answeredBy: answeredBy(flow, step), + state: stateFor(index, currentIndex, overlay, step.stepType), + })); +} + +function stateFor( + index: number, + currentIndex: number, + overlay: RunOverlay | null, + stepType: string, +): ActionableState { + if (currentIndex === index) return 'current'; + // No run yet: the list is the Flow's shape, so nothing has happened to any of it. + if (overlay === null) return 'upcoming'; + const ran = overlay.executions.some((execution) => execution.stepType === stepType); + if (ran) return 'done'; + // Before the open gate in graph order but never executed: a branch skipped it. + return currentIndex >= 0 && index < currentIndex ? 'done' : 'upcoming'; +} + +/** + * Step ids in breadth-first order from the start, which is the order a reader meets them. + * + * Graph order rather than execution order: an upcoming gate has no execution to sort by, and a + * stepper has to place it before the reader gets there. + */ +function orderedStepIds(flow: PocFlow): string[] { + const start = flow.steps.find((step) => step.isStart); + const order: string[] = []; + const seen = new Set(); + const queue = start === undefined ? [] : [start.id]; + while (queue.length > 0) { + const id = queue.shift() as string; + if (seen.has(id)) continue; + seen.add(id); + order.push(id); + for (const transition of flow.transitions) { + if (transition.fromStepId === id && transition.kind === 'transition' && !transition.isSelfLoop) { + queue.push(transition.toStepId); + } + } + } + // Anything unreachable still gets a stable position rather than -1. + for (const step of flow.steps) if (!seen.has(step.id)) order.push(step.id); + return order; +} + +/** Whether a stepper is worth drawing at all. One gate needs no progress strip. */ +export function hasMultipleActions(steps: readonly ActionableStep[]): boolean { + return steps.length > 1; +} diff --git a/web/app/v2/run/copy.ts b/web/app/v2/run/copy.ts new file mode 100644 index 000000000..0a4bea83d --- /dev/null +++ b/web/app/v2/run/copy.ts @@ -0,0 +1,36 @@ +// Copyright (c) 2026 Super Durable, Inc. +// +// Licensed under the Sustainable Use License 1.0. +// You may not use this file except in compliance with the License. +// See the LICENSE file in the repository root. +// +// SPDX-License-Identifier: LicenseRef-Sustainable-Use-1.0 + +export const RUN_COPY = { + runsHeading: 'Runs', + noRuns: 'No runs of this Flow type.', + /** The order is stated rather than left for the reader to infer. */ + order: 'Open first, then newest.', + selectPrompt: 'Select a run to see where it is and what it needs.', + /** The step the canvas is showing, which is not always the one that needs somebody. */ + showingStep: 'Showing', + waitingAt: 'Waiting at', + actionsHeading: 'Actions', + displayHeading: 'Reported', + noActions: 'No Actions are available in the current state.', + stop: 'Stop', + stopped: 'Stopping…', + inspect: 'Inspect', + inspectHint: 'The full technical record for this run', + actionProgress: 'Your actions', + allActionsDone: 'all done', + actionsLeft(n: number): string { + return n === 1 ? '1 left' : `${n} left`; + }, + thisStep: 'This step', + stepOnly: 'Step in this flow', + noPurpose: 'This Flow does not say what this Step is for.', + close: 'Close the run panel', + elapsed: 'Running for', + closed: 'Closed', +} as const; diff --git a/web/app/v2/run/runOrder.ts b/web/app/v2/run/runOrder.ts new file mode 100644 index 000000000..5fd036a67 --- /dev/null +++ b/web/app/v2/run/runOrder.ts @@ -0,0 +1,40 @@ +// Copyright (c) 2026 Super Durable, Inc. +// +// Licensed under the Sustainable Use License 1.0. +// You may not use this file except in compliance with the License. +// See the LICENSE file in the repository root. +// +// SPDX-License-Identifier: LicenseRef-Sustainable-Use-1.0 + +import type { V2Flow } from '@/lib/types'; +import { isOpenFlowStatusCode } from '../queue/liveness'; + +export type RunGroupKey = 'open' | 'closed'; + +export interface RunGroup { + key: RunGroupKey; + label: string; + flows: V2Flow[]; +} + +const GROUP_LABEL: Record = { open: 'Open', closed: 'Closed' }; + +/** + * Open runs first, then the server's own newest-first order within each group. + * + * A stable partition, so the order is never invented: /api/v2/search already sorts by start + * time descending, and status is read from the run rather than inferred. Which run most needs + * somebody cannot be ranked until the contract declares a role. + * + * Per page only, which is honest here because Run mode has no pager. + */ +export function groupRuns(flows: readonly V2Flow[]): RunGroup[] { + const open: V2Flow[] = []; + const closed: V2Flow[] = []; + for (const flow of flows) { + (isOpenFlowStatusCode(flow.flowStatusCode) ? open : closed).push(flow); + } + return ([['open', open], ['closed', closed]] as const) + .filter(([, group]) => group.length > 0) + .map(([key, group]) => ({ key, label: GROUP_LABEL[key], flows: group })); +} diff --git a/web/app/v2/run/stepContext.test.ts b/web/app/v2/run/stepContext.test.ts new file mode 100644 index 000000000..0ccdf2a25 --- /dev/null +++ b/web/app/v2/run/stepContext.test.ts @@ -0,0 +1,115 @@ +// Copyright (c) 2026 Super Durable, Inc. +// +// Licensed under the Sustainable Use License 1.0. +// You may not use this file except in compliance with the License. +// See the LICENSE file in the repository root. +// +// SPDX-License-Identifier: LicenseRef-Sustainable-Use-1.0 + +import { describe, expect, it } from 'vitest'; +import type { PocFlow, StepModel } from '../canvas/model/pocFlow'; +import type { StepGroup } from '../canvas/views/groups'; +import { stepContext } from './stepContext'; + +const step = (over: Partial & { id: string; stepType: string }): StepModel => ({ + label: over.stepType, + isStart: false, + actor: 'machine', + isHub: false, + isRecoveryHub: false, + recoveryRole: 'none', + hasUniformFailurePolicy: false, + inboundCount: 0, + inboundFailureCount: 0, + waitFor: null, + execute: { branches: [], kind: 'runs' } as StepModel['execute'], + resources: [], + ...over, +}); + +const transition = (from: string, to: string, kind: 'transition' | 'failure_transition') => ({ + id: `${from}->${to}`, + fromStepId: from, + toStepId: to, + kind, + guard: null, + mergedGuards: [], + isSelfLoop: false, +}); + +const gate = step({ + id: 'step:Gate', + stepType: 'GateStep', + actor: 'external', + explanation: 'Pause for a manager decision before issuing a refund.', + waitFor: { type: 'until', conditions: [], sentence: 'waits for a message on manager-approval' }, +}); +const guard = step({ id: 'step:Guard', stepType: 'GuardrailStep' }); +const next = step({ id: 'step:Next', stepType: 'ReCheckStep' }); +const sink = step({ id: 'step:Sink', stepType: 'BillingFailedStep' }); + +const flow = { + steps: [guard, gate, next, sink], + transitions: [ + transition('step:Guard', 'step:Gate', 'transition'), + transition('step:Gate', 'step:Next', 'transition'), + transition('step:Gate', 'step:Sink', 'failure_transition'), + ], +} as unknown as PocFlow; + +const groups: StepGroup[] = [ + { id: 'control', label: 'Control', reason: 'Control', stepTypes: ['GuardrailStep', 'GateStep'] } as StepGroup, +]; + +describe('stepContext', () => { + it('leads with the purpose the Flow declared', () => { + expect(stepContext(flow, gate, groups).explanation) + .toBe('Pause for a manager decision before issuing a refund.'); + }); + + it('never invents a purpose the Flow did not declare', () => { + expect(stepContext(flow, guard, groups).explanation).toBeNull(); + }); + + it('places the Step in its phase and names who acts', () => { + const facts = labelled(stepContext(flow, gate, groups)); + expect(facts['In phase']).toBe('Control'); + expect(facts['Who acts']).toBe('a person or system outside the flow'); + }); + + it('reads what reaches it and where it goes from the graph', () => { + const facts = labelled(stepContext(flow, gate, groups)); + expect(facts['Reached from']).toBe('GuardrailStep'); + expect(facts['Then goes to']).toBe('ReCheckStep'); + }); + + it('keeps recovery separate from the ordinary path', () => { + const facts = labelled(stepContext(flow, gate, groups)); + expect(facts['If it fails']).toBe('BillingFailedStep'); + expect(facts['Then goes to']).not.toContain('BillingFailedStep'); + }); + + it('reuses the wait phase\'s own prose rather than rebuilding it', () => { + expect(labelled(stepContext(flow, gate, groups))['Waits for']) + .toBe('waits for a message on manager-approval'); + }); + + it('omits a wait line for a Step that does not wait', () => { + expect(labelled(stepContext(flow, next, groups))['Waits for']).toBeUndefined(); + }); + + it('says the flow starts here rather than showing an empty inbound list', () => { + const start = step({ id: 'step:Start', stepType: 'ReceiveStep', isStart: true }); + const withStart = { ...flow, steps: [...flow.steps, start] } as unknown as PocFlow; + expect(labelled(stepContext(withStart, start, groups))['Reached from']) + .toBe('the flow starts here'); + }); + + it('omits the phase line when no group claims the Step', () => { + expect(labelled(stepContext(flow, sink, groups))['In phase']).toBeUndefined(); + }); +}); + +function labelled(view: ReturnType): Record { + return Object.fromEntries(view.facts.map((fact) => [fact.label, fact.value])); +} diff --git a/web/app/v2/run/stepContext.ts b/web/app/v2/run/stepContext.ts new file mode 100644 index 000000000..683fd4ac7 --- /dev/null +++ b/web/app/v2/run/stepContext.ts @@ -0,0 +1,98 @@ +// Copyright (c) 2026 Super Durable, Inc. +// +// Licensed under the Sustainable Use License 1.0. +// You may not use this file except in compliance with the License. +// See the LICENSE file in the repository root. +// +// SPDX-License-Identifier: LicenseRef-Sustainable-Use-1.0 + +import type { Actor, PocFlow, StepModel } from '../canvas/model/pocFlow'; +import type { StepGroup } from '../canvas/views/groups'; + +export interface StepFact { + label: string; + value: string; +} + +export interface StepContextView { + stepType: string; + /** The Step's declared purpose, or null when the Flow never said. Never invented. */ + explanation: string | null; + facts: StepFact[]; +} + +/** Who moves this Step along. The graph knows this; a reader should not have to infer it. */ +const ACTOR_PHRASE: Record = { + external: 'a person or system outside the flow', + child: 'a SubFlow', + clock: 'a timer', + machine: 'the flow itself', + unknown: 'not stated', +}; + +/** + * Where a Step sits in its flow, in the flow's own terms. + * + * Purpose first, then placement: the phase it belongs to, who acts, what reaches it, what it + * waits for, and where it can go. Every line is read from the graph — nothing is inferred, and a + * fact the Flow did not declare is omitted rather than guessed at. + */ +export function stepContext( + flow: PocFlow, + step: StepModel, + groups: readonly StepGroup[], +): StepContextView { + const facts: StepFact[] = []; + + const phase = groups.find((group) => group.stepTypes.includes(step.stepType)); + if (phase !== undefined) facts.push({ label: 'In phase', value: phase.label }); + + facts.push({ label: 'Who acts', value: ACTOR_PHRASE[step.actor] }); + + if (step.isStart) { + facts.push({ label: 'Reached from', value: 'the flow starts here' }); + } else { + const inbound = flow.transitions + .filter((t) => t.toStepId === step.id && t.kind === 'transition' && !t.isSelfLoop) + .map((t) => stepTypeOf(flow, t.fromStepId)) + .filter((name): name is string => name !== null); + if (inbound.length > 0) { + facts.push({ label: 'Reached from', value: unique(inbound).join(', ') }); + } + } + + // The wait phase already carries one prose line, paraphrased with identifiers verbatim. + if (step.waitFor !== null && step.waitFor.sentence !== '') { + facts.push({ label: 'Waits for', value: step.waitFor.sentence }); + } + + const outbound = flow.transitions + .filter((t) => t.fromStepId === step.id && t.kind === 'transition' && !t.isSelfLoop) + .map((t) => stepTypeOf(flow, t.toStepId)) + .filter((name): name is string => name !== null); + if (outbound.length > 0) { + facts.push({ label: 'Then goes to', value: unique(outbound).join(', ') }); + } + + const recovery = flow.transitions + .filter((t) => t.fromStepId === step.id && t.kind === 'failure_transition') + .map((t) => stepTypeOf(flow, t.toStepId)) + .filter((name): name is string => name !== null); + if (recovery.length > 0) { + facts.push({ label: 'If it fails', value: unique(recovery).join(', ') }); + } + + return { + stepType: step.stepType, + explanation: step.explanation ?? null, + facts, + }; +} + +function stepTypeOf(flow: PocFlow, stepId: string): string | null { + return flow.steps.find((step) => step.id === stepId)?.stepType ?? null; +} + +function unique(values: readonly string[]): string[] { + return [...new Set(values)]; +} diff --git a/web/app/v2/workspace/RunList.tsx b/web/app/v2/workspace/RunList.tsx new file mode 100644 index 000000000..dcf4881fc --- /dev/null +++ b/web/app/v2/workspace/RunList.tsx @@ -0,0 +1,131 @@ +// Copyright (c) 2026 Super Durable, Inc. +// +// Licensed under the Sustainable Use License 1.0. +// You may not use this file except in compliance with the License. +// See the LICENSE file in the repository root. +// +// SPDX-License-Identifier: LicenseRef-Sustainable-Use-1.0 + +import type { ReactNode } from 'react'; +import { formatTimeOfDay } from '@/lib/format'; +import type { V2CatalogEntry, V2Flow } from '@/lib/types'; +import { usePreferences } from '../../providers'; +import { QUEUE_COPY } from '../queue/copy'; +import { RUN_COPY } from '../run/copy'; +import { groupRuns } from '../run/runOrder'; +import { SEARCH_COPY } from './searchCopy'; +import type { FlowSearch } from './useFlowSearch'; + +/** + * One list of runs, shared by Run and Inbox so the two cannot drift into two visual languages. + * + * Everything either view needs to differ on arrives as a prop: the heading, the note beside it, + * and an optional scope control. The rows themselves are identical by construction. + */ +export function RunList({ + entry, + flowTypes, + search, + selectedFlowID, + strandedFlowIDs, + attentionAttributeKey, + heading, + headerNote, + scope, + emptyText, + onSelectFlowType, + onSelectRun, +}: { + entry: V2CatalogEntry; + flowTypes: V2CatalogEntry[]; + search: FlowSearch; + selectedFlowID: string; + strandedFlowIDs: ReadonlySet; + /** First indexed Attribute, shown under the run id as its own value. */ + attentionAttributeKey: string | null; + heading: string; + headerNote?: string; + /** What the list is narrowed to, and the control that narrowed it. */ + scope?: ReactNode; + emptyText: string; + onSelectFlowType: (flowType: string) => void; + onSelectRun: (flowID: string) => void; +}) { + const { timezone } = usePreferences(); + const { flows, liveness, loading } = search; + const groups = groupRuns(flows); + const stateText = liveness === 'loading' + ? QUEUE_COPY.loading + : liveness === 'unreachable' + ? QUEUE_COPY.unreachable + : liveness === 'stale' + ? QUEUE_COPY.stale + : flows.length === 0 + ? emptyText + : ''; + return ( + + ); +} + +/** The Flow's own indexed value when it has one, else the execution status. */ +function runStateText(flow: V2Flow, attentionAttributeKey: string | null): string { + if (attentionAttributeKey === null) return flow.flowStatus; + const value = flow.indexedAttributes[attentionAttributeKey]; + if (value === null || value === undefined || value === '') return flow.flowStatus; + return String(value); +} diff --git a/web/app/v2/workspace/RunSearch.tsx b/web/app/v2/workspace/RunSearch.tsx new file mode 100644 index 000000000..704e62897 --- /dev/null +++ b/web/app/v2/workspace/RunSearch.tsx @@ -0,0 +1,191 @@ +// Copyright (c) 2026 Super Durable, Inc. +// +// Licensed under the Sustainable Use License 1.0. +// You may not use this file except in compliance with the License. +// See the LICENSE file in the repository root. +// +// SPDX-License-Identifier: LicenseRef-Sustainable-Use-1.0 + +import { useState } from 'react'; +import type { FlowV2Definition } from '@superdurable/flow-definition-renderer'; +import { SEARCH_COPY } from './searchCopy'; +import { + SINCE_WINDOWS, + attributeByKey, + defaultOperator, + filterableAttributes, + hasAdvancedQuery, + operatorsFor, + statusOptions, + valueInputKind, + type RunOperator, + type RunQuery, + type SinceWindow, +} from './runQuery'; + +/** + * Status and time in front; attribute comparisons and an exact run id behind a disclosure. + * + * Two controls answer most questions about a list of runs, and they are the two every Flow has + * whether or not it declared anything indexable. What only some Flows can answer, and what needs a + * reader to choose an operator, goes one click away rather than into the default view. + */ +export function RunSearch({ + definition, + query, + busy, + onChange, + onSubmit, + onClear, +}: { + definition: FlowV2Definition; + query: RunQuery; + busy: boolean; + onChange: (query: RunQuery) => void; + onSubmit: () => void; + onClear: () => void; +}) { + /** + * The disclosure owns its own open state after the first render. + * + * Deriving `open` from the query collapsed the panel the moment somebody chose an Attribute, + * because a chosen Attribute with no value yet is not an active filter. + */ + const [advancedOpen, setAdvancedOpen] = useState(() => hasAdvancedQuery(query)); + const attributes = filterableAttributes(definition); + const chosen = attributeByKey(definition, query.attributeKey); + const operators = chosen === null ? [] : operatorsFor(chosen.indexType); + const kind = valueInputKind(chosen?.valueType); + const set = (patch: Partial) => onChange({ ...query, ...patch }); + return ( +
{ + event.preventDefault(); + onSubmit(); + }} + > +
+ + +
+ +
setAdvancedOpen(event.currentTarget.open)} + > + {SEARCH_COPY.advanced} + + + + {attributes.length > 0 && ( +
+ {SEARCH_COPY.attributeLabel} + +
+ + set({ attributeValue: value })} + /> +
+
+ )} +
+ +
+ + +
+
+ ); +} + +/** The value control the Attribute's own type calls for, so an impossible value cannot be sent. */ +function ValueInput({ disabled, kind, value, onChange }: { + disabled: boolean; + kind: 'text' | 'number' | 'datetime' | 'bool'; + value: string; + onChange: (value: string) => void; +}) { + if (kind === 'bool') { + return ( + + ); + } + return ( + onChange(event.target.value)} + /> + ); +} diff --git a/web/app/v2/workspace/SelectedRunPanel.tsx b/web/app/v2/workspace/SelectedRunPanel.tsx new file mode 100644 index 000000000..25b77e862 --- /dev/null +++ b/web/app/v2/workspace/SelectedRunPanel.tsx @@ -0,0 +1,402 @@ +// Copyright (c) 2026 Super Durable, Inc. +// +// Licensed under the Sustainable Use License 1.0. +// You may not use this file except in compliance with the License. +// See the LICENSE file in the repository root. +// +// SPDX-License-Identifier: LicenseRef-Sustainable-Use-1.0 + +import { useCallback, useEffect, useState, type ReactNode } from 'react'; +import type { + FlowV2Action, + FlowV2ActionInputField, + FlowV2Definition, + V2ValueType, +} from '@superdurable/flow-definition-renderer'; +import { displayValue } from '@/lib/format'; +import { readResponseJSON } from '@/lib/http'; +import type { V2Display } from '@/lib/types'; +import { parseTypedValue, v2ActionUserFields, v2ActionUserInput, visibleV2Actions } from '../contract'; +import { QUEUE_COPY } from '../queue/copy'; +import { absorb, classifyReadFailure, nothingHeld, readFailureReason } from '../queue/liveness'; + +export function SelectedRunPanel({ + flowType, + flowId, + definition, + flowStatusCode, + footer, + reloadKey = 0, + order, + showHeading = true, + onActed, + onStranded, +}: { + flowType: string; + flowId: string; + definition: FlowV2Definition; + /** From the search row, so a dead worker can be told apart from a closed run. */ + flowStatusCode?: number; + footer?: ReactNode; + /** Bumped by the view's shared clock, so the fields and the canvas move together. */ + reloadKey?: number; + /** + * Admin already knows the case, so Run puts the decision first. A participant needs the + * evidence before the decision, so the Queue reads the other way. + */ + order: 'actions-first' | 'evidence-first'; + /** False when the host already names the run, so it is not named twice. */ + showHeading?: boolean; + /** An Action succeeded, so whatever the run was waiting for has moved on. */ + onActed?: () => void; + /** Reported up so the list can mark the row; a search cannot discover this. */ + onStranded?: (flowID: string) => void; +}) { + const [held, setHeld] = useState(() => nothingHeld()); + /** A write that failed is not a stale read, so it does not touch held. */ + const [actionError, setActionError] = useState(''); + const [busyKey, setBusyKey] = useState(''); + const [editingKey, setEditingKey] = useState(''); + const [editValue, setEditValue] = useState(''); + const [fieldErrors, setFieldErrors] = useState>({}); + const [actionValues, setActionValues] = useState>>({}); + + const loadDisplay = useCallback(async () => { + try { + const query = new URLSearchParams({ flowType, flowId }); + const response = await fetch(`/api/v2/display?${query}`); + const display = await readResponseJSON(response); + setHeld((prior) => absorb(prior, { state: 'ok', value: display })); + } catch (loadError) { + // Only knowable once somebody opens the run: a search says nothing about its worker. + const outcome = classifyReadFailure(loadError, flowStatusCode); + if (outcome.state === 'stranded') onStranded?.(flowId); + setHeld((prior) => absorb(prior, outcome)); + } + }, [flowId, flowStatusCode, flowType, onStranded]); + + // A new run must not inherit the previous run's values while its own read is in flight. + useEffect(() => { setHeld(nothingHeld()); }, [flowId, flowType]); + + useEffect(() => { void loadDisplay(); }, [loadDisplay, reloadKey]); + + const result = held.value; + const isStranded = held.liveness === 'stranded'; + + async function saveField(attributeKey: string, valueType: V2ValueType) { + setBusyKey(attributeKey); + setActionError(''); + setFieldErrors((current) => ({ ...current, [attributeKey]: '' })); + try { + const response = await fetch('/api/v2/display', { + method: 'PATCH', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + flowType, flowId, attributeKey, + value: parseTypedValue(editValue, valueType), + }), + }); + await readResponseJSON(response); + setEditingKey(''); + await loadDisplay(); + } catch (saveError) { + setFieldErrors((current) => ({ + ...current, + [attributeKey]: saveError instanceof Error ? saveError.message : 'Edit failed', + })); + } finally { + setBusyKey(''); + } + } + + async function invokeAction(action: FlowV2Action) { + setBusyKey(action.rpcName); + setActionError(''); + try { + const input = v2ActionUserInput(action, actionValues[action.rpcName] ?? {}); + const response = await fetch('/api/v2/actions', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + flowType, flowId, rpcName: action.rpcName, + input, attributeSnapshot: result?.attributeSnapshot ?? {}, + }), + }); + await readResponseJSON(response); + await loadDisplay(); + onActed?.(); + } catch (actionError) { + setActionError(readFailureReason(actionError)); + } finally { + setBusyKey(''); + } + } + + return ( +
+ {(showHeading || footer || held.liveness === 'stale') && ( +
+ {showHeading && {flowId}} + {showHeading && result && {result.flowStatus}} + {held.liveness === 'stale' && {QUEUE_COPY.staleShort}} + {footer} +
+ )} + {isStranded &&

{QUEUE_COPY.stranded}

} + {held.liveness === 'unreachable' &&

{held.reason}

} + {held.liveness === 'stale' &&

{held.reason}

} + {held.liveness === 'loading' &&

{QUEUE_COPY.loading}

} + {actionError &&

{actionError}

} + {result && !isStranded && ( + <> + {order === 'actions-first' ? (<> +
+
Actions
+ {visibleV2Actions(definition.actions, result.eligibleActions).map((action) => { + const userFields = v2ActionUserFields(action); + return ( +
{ + event.preventDefault(); + void invokeAction(action); + }} + > + {userFields.map((field) => ( + + ))} + +
+ ); + })} + {definition.actions.length > 0 && result.eligibleActions.length === 0 && ( +

No Actions are available in the current state.

+ )} +
+ +
+
Display
+
+ {definition.display.fields.map((field) => { + const isEditing = editingKey === field.attributeKey; + return ( +
+
{field.description}
+
+ {isEditing ? ( + <> + + + + {fieldErrors[field.attributeKey] && ( + {fieldErrors[field.attributeKey]} + )} + + ) : ( + <> + {displayValue(result.display[field.attributeKey])} + {field.editable && result.isActive && ( + + )} + + )} +
+
+ ); + })} +
+
+) : (<> +
+
Display
+
+ {definition.display.fields.map((field) => { + const isEditing = editingKey === field.attributeKey; + return ( +
+
{field.description}
+
+ {isEditing ? ( + <> + + + + {fieldErrors[field.attributeKey] && ( + {fieldErrors[field.attributeKey]} + )} + + ) : ( + <> + {displayValue(result.display[field.attributeKey])} + {field.editable && result.isActive && ( + + )} + + )} +
+
+ ); + })} +
+
+ +
+
Actions
+ {visibleV2Actions(definition.actions, result.eligibleActions).map((action) => { + const userFields = v2ActionUserFields(action); + return ( +
{ + event.preventDefault(); + void invokeAction(action); + }} + > + {userFields.map((field) => ( + + ))} + +
+ ); + })} + {definition.actions.length > 0 && result.eligibleActions.length === 0 && ( +

No Actions are available in the current state.

+ )} +
+)} + + )} +
+ ); +} + +function editableValue(value: unknown, valueType: V2ValueType): string { + if (value === null || value === undefined) return ''; + const text = typeof value === 'string' ? value : String(value); + if (valueType !== 'datetime') return text; + const date = new Date(text); + if (Number.isNaN(date.getTime())) return ''; + const localDate = new Date(date.getTime() - date.getTimezoneOffset() * 60_000); + return localDate.toISOString().slice(0, 16); +} + +function TypedInput({ field, value, onChange, required = false }: { + field: { valueType: V2ValueType; description: string }; + value: string; + onChange: (value: string) => void; + required?: boolean; +}) { + if (field.valueType === 'bool') { + return ( + + ); + } + return ( + onChange(event.target.value)} + /> + ); +} + +function ActionInput({ field, value, onChange }: { + field: FlowV2ActionInputField; + value: string; + onChange: (value: string) => void; +}) { + return ; +} diff --git a/web/app/v2/workspace/filters.ts b/web/app/v2/workspace/filters.ts new file mode 100644 index 000000000..962756f86 --- /dev/null +++ b/web/app/v2/workspace/filters.ts @@ -0,0 +1,82 @@ +// Copyright (c) 2026 Super Durable, Inc. +// +// Licensed under the Sustainable Use License 1.0. +// You may not use this file except in compliance with the License. +// See the LICENSE file in the repository root. +// +// SPDX-License-Identifier: LicenseRef-Sustainable-Use-1.0 + +import type { FlowV2Definition, V2ValueType } from '@superdurable/flow-definition-renderer'; +import { parseTypedValue } from '../contract'; + +export interface FilterRow { + id: string; + field: string; + operator: string; + value: string; +} + +export function filterFields(definition: FlowV2Definition) { + return [ + { key: 'flowId', label: 'Flow ID' }, + { key: 'executionStatus', label: 'Execution status' }, + { key: 'startTime', label: 'Start time' }, + { key: 'closeTime', label: 'Close time' }, + ...definition.indexedAttributes.map((attribute) => ({ + key: attribute.attributeKey, + label: attribute.description, + })), + ]; +} + +export function filterValueType(field: string, definition: FlowV2Definition): V2ValueType { + if (field === 'startTime' || field === 'closeTime') return 'datetime'; + if (field === 'flowId' || field === 'executionStatus') return 'string'; + return definition.indexedAttributes + .find((attribute) => attribute.attributeKey === field)?.valueType ?? 'string'; +} + +export function filterIndexType(field: string, definition: FlowV2Definition) { + if (field === 'startTime' || field === 'closeTime') return 'datetime'; + if (field === 'flowId' || field === 'executionStatus') return 'keyword'; + return definition.indexedAttributes + .find((attribute) => attribute.attributeKey === field)?.indexType ?? 'keyword'; +} + +export function filterOperators( + indexType: FlowV2Definition['indexedAttributes'][number]['indexType'], +) { + const equality = [{ value: 'eq', label: 'equals' }, { value: 'in', label: 'is one of' }]; + if (indexType === 'fulltext') return [...equality, { value: 'contains', label: 'contains' }]; + if (indexType === 'datetime' || indexType === 'int' || indexType === 'double') { + return [ + ...equality, + { value: 'gt', label: 'greater than' }, + { value: 'gte', label: 'at least' }, + { value: 'lt', label: 'less than' }, + { value: 'lte', label: 'at most' }, + ]; + } + return equality; +} + +export function parseFilterValues(value: string, valueType: V2ValueType): unknown[] { + return value + .split(',') + .map((part) => part.trim()) + .filter(Boolean) + .map((part) => parseTypedValue(part, valueType)); +} + +export function updateFilter( + filters: FilterRow[], + id: string, + key: keyof FilterRow, + value: string, +) { + return filters.map((filter) => (filter.id === id ? { ...filter, [key]: value } : filter)); +} + +export function newFilterRow(field: string, operator: string, value: string): FilterRow { + return { id: `${Date.now()}-${Math.random()}`, field, operator, value }; +} diff --git a/web/app/v2/workspace/runQuery.test.ts b/web/app/v2/workspace/runQuery.test.ts new file mode 100644 index 000000000..886beb405 --- /dev/null +++ b/web/app/v2/workspace/runQuery.test.ts @@ -0,0 +1,165 @@ +// Copyright (c) 2026 Super Durable, Inc. +// +// Licensed under the Sustainable Use License 1.0. +// You may not use this file except in compliance with the License. +// See the LICENSE file in the repository root. +// +// SPDX-License-Identifier: LicenseRef-Sustainable-Use-1.0 + +import { describe, expect, it } from 'vitest'; +import type { FlowV2Definition } from '@superdurable/flow-definition-renderer'; +import { + EMPTY_RUN_QUERY, + defaultOperator, + filterableAttributes, + hasAdvancedQuery, + isEmptyQuery, + operatorsFor, + statusOptions, + toFilterRows, + valueInputKind, +} from './runQuery'; + +const indexed = (attributeKey: string, indexType: string, valueType: string) => ({ + attributeKey, indexKey: attributeKey, indexType, valueType, description: attributeKey, +}) as FlowV2Definition['indexedAttributes'][number]; + +const searchable = { + indexedAttributes: [ + indexed('customer-email', 'fulltext', 'string'), + indexed('refund-amount', 'double', 'double'), + indexed('case-status', 'keyword', 'string'), + ], + summary: { rpcName: 'GetDexSummary', fields: [] }, + display: { rpcName: 'GetDexDisplay', fields: [] }, + actions: [], +} as FlowV2Definition; + +/** The deterministic refund Flow: one keyword Attribute, nothing full-text. */ +const keywordOnly = { + ...searchable, + indexedAttributes: [indexed('case-status', 'keyword', 'string')], +} as FlowV2Definition; + +const NOW = Date.parse('2026-09-20T12:00:00.000Z'); +const rows = (q: Partial, definition = searchable) => + toFilterRows({ ...EMPTY_RUN_QUERY, ...q }, definition, NOW) + .map((row) => `${row.field} ${row.operator} ${row.value}`); + +describe('operatorsFor', () => { + it('offers contains only where the index is full-text', () => { + expect(operatorsFor('fulltext').map((o) => o.value)).toEqual(['contains', 'eq']); + expect(operatorsFor('keyword').map((o) => o.value)).toEqual(['eq']); + }); + + it('offers ordering only where values order', () => { + expect(operatorsFor('double').map((o) => o.value)).toEqual(['eq', 'gte', 'lte']); + expect(operatorsFor('int').map((o) => o.value)).toEqual(['eq', 'gte', 'lte']); + expect(operatorsFor('datetime').map((o) => o.value)).toEqual(['eq', 'gte', 'lte']); + }); + + it('never offers an operator the server rejects for that index', () => { + expect(operatorsFor('keyword').map((o) => o.value)).not.toContain('contains'); + expect(operatorsFor('double').map((o) => o.value)).not.toContain('contains'); + }); + + it('defaults to the comparison that index type is for', () => { + expect(defaultOperator('fulltext')).toBe('contains'); + expect(defaultOperator('keyword')).toBe('eq'); + expect(defaultOperator('double')).toBe('eq'); + }); +}); + +describe('valueInputKind', () => { + it('types the value control from the Attribute, not the reader', () => { + expect(valueInputKind('double')).toBe('number'); + expect(valueInputKind('int64')).toBe('number'); + expect(valueInputKind('datetime')).toBe('datetime'); + expect(valueInputKind('bool')).toBe('bool'); + expect(valueInputKind('string')).toBe('text'); + expect(valueInputKind(undefined)).toBe('text'); + }); +}); + +describe('filterableAttributes', () => { + it('offers every declared Indexed Attribute, full-text included', () => { + expect(filterableAttributes(searchable).map((a) => a.attributeKey)) + .toEqual(['customer-email', 'refund-amount', 'case-status']); + }); +}); + +describe('toFilterRows', () => { + it('compiles status to an exact execution-status filter', () => { + expect(rows({ status: 'Running' })).toEqual(['executionStatus eq Running']); + }); + + it('compiles a relative window to an absolute start time', () => { + expect(rows({ since: '24h' })).toEqual(['startTime gte 2026-09-19T12:00:00.000Z']); + }); + + it('looks a run up by exact id', () => { + expect(rows({ runId: ' adv-1 ' })).toEqual(['flowId eq adv-1']); + }); + + it('carries the chosen operator through', () => { + expect(rows({ attributeKey: 'refund-amount', attributeOperator: 'gte', attributeValue: '450' })) + .toEqual(['refund-amount gte 450']); + expect(rows({ attributeKey: 'customer-email', attributeOperator: 'contains', attributeValue: 'acme' })) + .toEqual(['customer-email contains acme']); + }); + + it('substitutes the default when the operator is illegal for the index', () => { + expect(rows({ attributeKey: 'case-status', attributeOperator: 'contains', attributeValue: 'open' })) + .toEqual(['case-status eq open']); + }); + + it('ignores an attribute with no value, and a value with no attribute', () => { + expect(rows({ attributeKey: 'refund-amount', attributeValue: ' ' })).toEqual([]); + expect(rows({ attributeValue: '450' })).toEqual([]); + }); + + it('ignores an attribute the Flow does not declare', () => { + expect(rows({ attributeKey: 'refund-amount', attributeValue: '450' }, keywordOnly)).toEqual([]); + }); + + it('combines every control that is set', () => { + expect(rows({ + status: 'Running', + since: '1h', + runId: 'adv-1', + attributeKey: 'refund-amount', + attributeOperator: 'lte', + attributeValue: '450', + })).toEqual([ + 'executionStatus eq Running', + 'startTime gte 2026-09-20T11:00:00.000Z', + 'flowId eq adv-1', + 'refund-amount lte 450', + ]); + }); +}); + +describe('statusOptions', () => { + it('is a real enum with nothing unspecified in it', () => { + const options = statusOptions(); + expect(options).toContain('Running'); + expect(options).not.toContain('Unspecified'); + }); +}); + +describe('query emptiness', () => { + it('knows when nothing is narrowing the list', () => { + expect(isEmptyQuery(EMPTY_RUN_QUERY)).toBe(true); + expect(isEmptyQuery({ ...EMPTY_RUN_QUERY, status: 'Running' })).toBe(false); + expect(isEmptyQuery({ ...EMPTY_RUN_QUERY, runId: ' ' })).toBe(true); + }); + + it('opens the disclosure when something inside it is set', () => { + expect(hasAdvancedQuery(EMPTY_RUN_QUERY)).toBe(false); + expect(hasAdvancedQuery({ ...EMPTY_RUN_QUERY, status: 'Running' })).toBe(false); + expect(hasAdvancedQuery({ ...EMPTY_RUN_QUERY, runId: 'adv-1' })).toBe(true); + expect(hasAdvancedQuery({ + ...EMPTY_RUN_QUERY, attributeKey: 'refund-amount', attributeValue: '450', + })).toBe(true); + }); +}); diff --git a/web/app/v2/workspace/runQuery.ts b/web/app/v2/workspace/runQuery.ts new file mode 100644 index 000000000..4b0a1aa0a --- /dev/null +++ b/web/app/v2/workspace/runQuery.ts @@ -0,0 +1,162 @@ +// Copyright (c) 2026 Super Durable, Inc. +// +// Licensed under the Sustainable Use License 1.0. +// You may not use this file except in compliance with the License. +// See the LICENSE file in the repository root. +// +// SPDX-License-Identifier: LicenseRef-Sustainable-Use-1.0 + +import type { FlowV2Definition, V2ValueType } from '@superdurable/flow-definition-renderer'; +import { FLOW_STATUS } from '@/lib/types'; +import { newFilterRow, type FilterRow } from './filters'; + +/** + * Two controls in front, three behind a disclosure. Not a query builder. + * + * Every one compiles to a filter Dex can actually index: there is no field here that the Flow did + * not declare, and no operator offered that the index type will reject. + */ +export interface RunQuery { + /** An execution status label, or '' for any. */ + status: string; + /** A relative window, or '' for any. Relative because nobody remembers a timestamp. */ + since: SinceWindow; + /** An exact run id. Its own control because looking one up is not the same as narrowing a list. */ + runId: string; + /** One declared Indexed Attribute, or '' for none. */ + attributeKey: string; + attributeOperator: RunOperator; + attributeValue: string; +} + +/** + * The four comparisons worth offering. + * + * `in` is deliberately absent: it needs a comma convention explained in placeholder text, and a + * second value control the moment anybody wants two ranges. Add it when a real query needs it. + */ +export type RunOperator = 'eq' | 'contains' | 'gte' | 'lte'; + +export type SinceWindow = '' | '1h' | '24h' | '7d' | '30d'; + +export const SINCE_WINDOWS: { value: SinceWindow; label: string; hours: number }[] = [ + { value: '', label: 'Any time', hours: 0 }, + { value: '1h', label: 'Last hour', hours: 1 }, + { value: '24h', label: 'Last 24 hours', hours: 24 }, + { value: '7d', label: 'Last 7 days', hours: 24 * 7 }, + { value: '30d', label: 'Last 30 days', hours: 24 * 30 }, +]; + +export const EMPTY_RUN_QUERY: RunQuery = { + status: '', + since: '', + runId: '', + attributeKey: '', + attributeOperator: 'eq', + attributeValue: '', +}; + +const OPERATOR_LABEL: Record = { + eq: 'equals', + contains: 'contains', + gte: 'at least', + lte: 'at most', +}; + +/** Every status Dex can report, so the picker is a real enum rather than a typed string. */ +export function statusOptions(): string[] { + return Object.values(FLOW_STATUS).filter((label) => label !== 'Unspecified'); +} + +/** Every declared Indexed Attribute. The operator picker keeps each one to comparisons it supports. */ +export function filterableAttributes(definition: FlowV2Definition) { + return definition.indexedAttributes; +} + +/** + * What each index type can actually be asked. + * + * `contains` is rejected server-side unless the index is fulltext, and ordering makes no sense on a + * keyword, so offering the whole set everywhere would hand the reader queries that only fail. + */ +export function operatorsFor(indexType: string): { value: RunOperator; label: string }[] { + const ordered: RunOperator[] = indexType === 'fulltext' + ? ['contains', 'eq'] + : indexType === 'int' || indexType === 'double' || indexType === 'datetime' + ? ['eq', 'gte', 'lte'] + : ['eq']; + return ordered.map((value) => ({ value, label: OPERATOR_LABEL[value] })); +} + +/** The comparison a reader most likely wants for this index type, used when they pick an attribute. */ +export function defaultOperator(indexType: string): RunOperator { + return indexType === 'fulltext' ? 'contains' : 'eq'; +} + +export function attributeByKey(definition: FlowV2Definition, key: string) { + return definition.indexedAttributes.find((attribute) => attribute.attributeKey === key) ?? null; +} + +/** Which input the value deserves, so a bool or a date cannot be typed as free text. */ +export function valueInputKind(valueType: V2ValueType | undefined): 'text' | 'number' | 'datetime' | 'bool' { + if (valueType === 'int64' || valueType === 'double') return 'number'; + if (valueType === 'datetime') return 'datetime'; + if (valueType === 'bool') return 'bool'; + return 'text'; +} + +/** Compile the controls into the filter rows the search endpoint already understands. */ +export function toFilterRows( + query: RunQuery, + definition: FlowV2Definition, + nowMs: number, +): FilterRow[] { + const rows: FilterRow[] = []; + + if (query.status !== '') { + rows.push(newFilterRow('executionStatus', 'eq', query.status)); + } + + const window = SINCE_WINDOWS.find((candidate) => candidate.value === query.since); + if (window !== undefined && window.hours > 0) { + const from = new Date(nowMs - window.hours * 3_600_000); + rows.push(newFilterRow('startTime', 'gte', from.toISOString())); + } + + const runId = query.runId.trim(); + if (runId !== '') { + rows.push(newFilterRow('flowId', 'eq', runId)); + } + + const value = query.attributeValue.trim(); + const attribute = attributeByKey(definition, query.attributeKey); + if (attribute !== null && value !== '') { + const legal = operatorsFor(attribute.indexType).map((operator) => operator.value); + const operator = legal.includes(query.attributeOperator) + ? query.attributeOperator + : defaultOperator(attribute.indexType); + rows.push(newFilterRow(attribute.attributeKey, operator, value)); + } + + return rows; +} + +/** True when nothing is narrowing the list, so a view can say so rather than imply a scope. */ +export function isEmptyQuery(query: RunQuery): boolean { + return activeParts(query) === 0; +} + +/** Whether anything behind the disclosure is set, so a closed panel cannot hide an active filter. */ +export function hasAdvancedQuery(query: RunQuery): boolean { + return query.runId.trim() !== '' + || (query.attributeKey !== '' && query.attributeValue.trim() !== ''); +} + +function activeParts(query: RunQuery): number { + return [ + query.status, + query.since, + query.runId.trim(), + query.attributeKey !== '' ? query.attributeValue.trim() : '', + ].filter((part) => part !== '').length; +} diff --git a/web/app/v2/workspace/searchCopy.ts b/web/app/v2/workspace/searchCopy.ts new file mode 100644 index 000000000..5f6e86ec2 --- /dev/null +++ b/web/app/v2/workspace/searchCopy.ts @@ -0,0 +1,31 @@ +// Copyright (c) 2026 Super Durable, Inc. +// +// Licensed under the Sustainable Use License 1.0. +// You may not use this file except in compliance with the License. +// See the LICENSE file in the repository root. +// +// SPDX-License-Identifier: LicenseRef-Sustainable-Use-1.0 + +/** Control names, not field names: what can be searched comes from the Flow's own contract. */ +export const SEARCH_COPY = { + statusLabel: 'Execution status', + anyStatus: 'Any status', + sinceLabel: 'Started within', + advanced: 'Advanced search', + runIdLabel: 'Run ID', + runIdPlaceholder: 'Exact run id', + attributeLabel: 'Attribute', + anyAttribute: 'Any attribute', + operatorLabel: 'Comparison', + attributeValueLabel: 'Attribute value', + pickAttribute: 'Choose an attribute first', + value: 'Value', + anyValue: 'Any', + boolTrue: 'True', + boolFalse: 'False', + search: 'Search', + searching: 'Searching…', + clear: 'Clear', + findHeading: 'Find runs', + flowTypeLabel: 'Flow type', +} as const; diff --git a/web/app/v2/workspace/useFlowSearch.ts b/web/app/v2/workspace/useFlowSearch.ts new file mode 100644 index 000000000..87f3d2507 --- /dev/null +++ b/web/app/v2/workspace/useFlowSearch.ts @@ -0,0 +1,107 @@ +// Copyright (c) 2026 Super Durable, Inc. +// +// Licensed under the Sustainable Use License 1.0. +// You may not use this file except in compliance with the License. +// See the LICENSE file in the repository root. +// +// SPDX-License-Identifier: LicenseRef-Sustainable-Use-1.0 + +import { useCallback, useEffect, useState } from 'react'; +import type { FlowV2Definition } from '@superdurable/flow-definition-renderer'; +import { readResponseJSON } from '@/lib/http'; +import type { V2Flow, V2SearchResult } from '@/lib/types'; +import { absorb, nothingHeld, readFailureReason, type Liveness } from '../queue/liveness'; +import { filterValueType, parseFilterValues, type FilterRow } from './filters'; + +export interface FlowSearch { + flows: V2Flow[]; + loading: boolean; + searchError: string; + /** What the reader currently knows, which is not what the last request returned. */ + liveness: Liveness; + page: number; + hasNextPage: boolean; + runSearch: () => void; + goToNextPage: () => void; + goToPreviousPage: () => void; +} + +export function useFlowSearch( + flowType: string | undefined, + definition: FlowV2Definition | undefined, + /** Owned by whatever renders the search controls, so there is one source of scope. */ + filters: readonly FilterRow[], +): FlowSearch { + const [held, setHeld] = useState(() => nothingHeld()); + const [loading, setLoading] = useState(false); + const [nextPageToken, setNextPageToken] = useState(''); + const [pageTokens, setPageTokens] = useState(['']); + const [page, setPage] = useState(0); + + const executeSearch = useCallback(async (token = '', nextPage = 0) => { + if (!flowType || !definition) return; + setLoading(true); + try { + const response = await fetch('/api/v2/search', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + flowType, + filters: filters.map((filter) => ({ + field: filter.field, + operator: filter.operator, + values: parseFilterValues(filter.value, filterValueType(filter.field, definition)), + })), + pageSize: 50, + nextPageToken: token, + }), + }); + const result = await readResponseJSON(response); + setHeld((prior) => absorb(prior, { state: 'ok', value: result.flows })); + setNextPageToken(result.nextPageToken); + setPage(nextPage); + } catch (failedSearch) { + // Keep the rows that were true a moment ago; absorb marks them stale. + setHeld((prior) => absorb(prior, { + state: 'unreachable', + reason: readFailureReason(failedSearch), + })); + } finally { + setLoading(false); + } + }, [definition, filters, flowType]); + + // `filters` only changes when the reader submits, so this cannot fire per keystroke. + useEffect(() => { + if (flowType && definition) void executeSearch(); + }, [definition, filters, flowType]); + + const runSearch = useCallback(() => { + setPageTokens(['']); + void executeSearch(); + }, [executeSearch]); + + const goToNextPage = useCallback(() => { + setPageTokens([...pageTokens, nextPageToken]); + void executeSearch(nextPageToken, page + 1); + }, [executeSearch, nextPageToken, page, pageTokens]); + + const goToPreviousPage = useCallback(() => { + const previous = page - 1; + const tokens = pageTokens.slice(0, -1); + setPageTokens(tokens); + void executeSearch(tokens[previous] ?? '', previous); + }, [executeSearch, page, pageTokens]); + + return { + flows: held.value ?? [], + loading, + searchError: held.liveness === 'stale' || held.liveness === 'unreachable' ? held.reason ?? '' : '', + liveness: held.liveness, + page, + hasNextPage: nextPageToken !== '', + runSearch, + goToNextPage, + goToPreviousPage, + }; +} diff --git a/web/app/v2/workspace/useRunQuery.ts b/web/app/v2/workspace/useRunQuery.ts new file mode 100644 index 000000000..78c181657 --- /dev/null +++ b/web/app/v2/workspace/useRunQuery.ts @@ -0,0 +1,39 @@ +// Copyright (c) 2026 Super Durable, Inc. +// +// Licensed under the Sustainable Use License 1.0. +// You may not use this file except in compliance with the License. +// See the LICENSE file in the repository root. +// +// SPDX-License-Identifier: LicenseRef-Sustainable-Use-1.0 + +import { useCallback, useState } from 'react'; +import type { FlowV2Definition } from '@superdurable/flow-definition-renderer'; +import type { FilterRow } from './filters'; +import { EMPTY_RUN_QUERY, toFilterRows, type RunQuery } from './runQuery'; + +/** + * Holds the four controls and compiles them to filter rows on submit. + * + * Compiled on submit rather than on change so a half-typed keyword is not a query, and so the + * rows the list was actually searched with stay stable while somebody edits the next one. + */ +export function useRunQuery(definition: FlowV2Definition | undefined, initial: RunQuery = EMPTY_RUN_QUERY) { + const [query, setQuery] = useState(initial); + const [applied, setApplied] = useState( + () => (definition === undefined ? [] : toFilterRows(initial, definition, Date.now())), + ); + + const apply = useCallback((next: RunQuery) => { + if (definition === undefined) return; + setQuery(next); + setApplied(toFilterRows(next, definition, Date.now())); + }, [definition]); + + return { + query, + setQuery, + appliedFilters: applied, + submit: useCallback(() => apply(query), [apply, query]), + clear: useCallback(() => apply(EMPTY_RUN_QUERY), [apply]), + }; +} diff --git a/web/app/v2/workspace/useStrandedRuns.ts b/web/app/v2/workspace/useStrandedRuns.ts new file mode 100644 index 000000000..5f0ba7102 --- /dev/null +++ b/web/app/v2/workspace/useStrandedRuns.ts @@ -0,0 +1,23 @@ +// Copyright (c) 2026 Super Durable, Inc. +// +// Licensed under the Sustainable Use License 1.0. +// You may not use this file except in compliance with the License. +// See the LICENSE file in the repository root. +// +// SPDX-License-Identifier: LicenseRef-Sustainable-Use-1.0 + +import { useCallback, useState } from 'react'; + +/** + * Learned, never searched: a run reports itself unreachable only when somebody opens it. + * Session-scoped because a worker could in principle come back. + */ +export function useStrandedRuns() { + const [strandedFlowIDs, setStrandedFlowIDs] = useState>(() => new Set()); + const rememberStranded = useCallback((strandedFlowID: string) => { + setStrandedFlowIDs((prior) => ( + prior.has(strandedFlowID) ? prior : new Set([...prior, strandedFlowID]) + )); + }, []); + return { strandedFlowIDs, rememberStranded }; +} diff --git a/web/lib/format.ts b/web/lib/format.ts index 4000ae437..6d60b9f8d 100644 --- a/web/lib/format.ts +++ b/web/lib/format.ts @@ -19,6 +19,17 @@ export function formatDate(value: string | null, timezone: TimezonePreference): }).format(date); } +/** Time only, for a narrow column where the date would not fit and rarely differs. */ +export function formatTimeOfDay(value: string | null, timezone: TimezonePreference): string { + if (!value) return '—'; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return '—'; + return new Intl.DateTimeFormat(undefined, { + timeStyle: 'short', + timeZone: timezone === 'UTC' ? 'UTC' : undefined, + }).format(date); +} + export function formatDuration(start: string | null, close: string | null): string { if (!start) return '—'; const startMs = Date.parse(start); diff --git a/web/lib/http.test.ts b/web/lib/http.test.ts index 78bbd1f13..5669f69da 100644 --- a/web/lib/http.test.ts +++ b/web/lib/http.test.ts @@ -7,7 +7,7 @@ // SPDX-License-Identifier: LicenseRef-Sustainable-Use-1.0 import { describe, expect, it } from 'vitest'; -import { isTransientGatewayResponse, readResponseJSON } from './http'; +import { DexAPIError, isTransientGatewayResponse, readResponseJSON } from './http'; describe('isTransientGatewayResponse', () => { it('recognizes temporary gateway responses', () => { @@ -59,6 +59,32 @@ describe('readResponseJSON', () => { 'Dex API returned a non-JSON response (HTTP 500) for /api/flows/search: Error: connect ECONNREFUSED 127.0.0.1:8802', ); }); + + it('carries the gRPC code and HTTP status a Dex error body reported', async () => { + const response = jsonResponse({ error: 'Flow is not active', grpcCode: 9 }, 409); + const failure = await readResponseJSON(response).catch((error: unknown) => error); + expect(failure).toBeInstanceOf(DexAPIError); + expect(failure).toBeInstanceOf(Error); + const dexFailure = failure as DexAPIError; + expect(dexFailure.message).toBe('Flow is not active'); + expect(dexFailure.grpcCode).toBe(9); + expect(dexFailure.httpStatus).toBe(409); + }); + + it('leaves the gRPC code undefined rather than zero when the body omits it', async () => { + const response = jsonResponse({ error: 'Flow not found' }, 404); + const failure = (await readResponseJSON(response).catch((error: unknown) => error)) as DexAPIError; + expect(failure.grpcCode).toBeUndefined(); + expect(failure.httpStatus).toBe(404); + }); + + it('keeps the generated message when the body carries a code but no error text', async () => { + const response = jsonResponse({ grpcCode: 14 }, 502); + Object.defineProperty(response, 'url', { value: 'http://127.0.0.1:5173/api/v2/display' }); + const failure = (await readResponseJSON(response).catch((error: unknown) => error)) as DexAPIError; + expect(failure.message).toBe('Dex API returned an error (HTTP 502) for /api/v2/display'); + expect(failure.grpcCode).toBe(14); + }); }); function jsonResponse(body: unknown, status: number): Response { diff --git a/web/lib/http.ts b/web/lib/http.ts index 29433b81f..bdc0912e5 100644 --- a/web/lib/http.ts +++ b/web/lib/http.ts @@ -6,10 +6,31 @@ // // SPDX-License-Identifier: LicenseRef-Sustainable-Use-1.0 +/** + * Carries the gRPC code so a caller can tell one unreadable run apart from an + * unreachable server. The code is absent, never 0, when the body omitted it. + */ +export class DexAPIError extends Error { + readonly httpStatus: number; + readonly grpcCode: number | undefined; + + constructor(message: string, httpStatus: number, grpcCode?: number) { + super(message); + this.name = 'DexAPIError'; + this.httpStatus = httpStatus; + this.grpcCode = grpcCode; + Object.setPrototypeOf(this, DexAPIError.prototype); + } +} + export async function readResponseJSON(response: Response): Promise { - const data = await parseResponseJSON(response); + const data = await parseResponseJSON(response); if (!response.ok) { - throw new Error(data.error?.trim() || failedRequestMessage(response)); + throw new DexAPIError( + data.error?.trim() || failedRequestMessage(response), + response.status, + typeof data.grpcCode === 'number' ? data.grpcCode : undefined, + ); } return data; }