mirror of
https://github.com/hanzoai/tasks.git
synced 2026-10-04 02:04:03 +00:00
feat(engine): durable social primitives — signals, continueAsNew, startChild, search attrs, conflict policy
The social-orchestrator migration off upstream Temporal onto the embedded
Hanzo Tasks engine needs five workflow primitives its digest/sendEmail/post
workflows depend on. Implemented server-side (event-sourced, replay-safe) plus
the @hanzoai/tasks TS SDK (the worker social runs):
- Signal → running workflow: engine.signalWorkflow now re-dispatches a workflow
task after appending WORKFLOW_EXECUTION_SIGNALED, so the stateless worker
replays the extended history and fires the handler (digest/sendEmail are
entirely signal-driven). TS decider read the wrong history attr key
("signal" vs "signalName") — fixed.
- continueAsNew (kind=4): closes the run CONTINUED_AS_NEW + starts a successor
under the same workflowId carrying input/search-attrs/memo. TS continueAsNew
emits the command instead of throwing ContinueAsNewNotSupported.
- startChild (kind=5): detached (ABANDON) child, seq-keyed + idempotent; retires
the 0x006D 501 for the command path. TS startChild/executeChild added.
- Typed search attributes: carried on StartWorkflow/SignalWithStart wire, stored
on the execution, visibility-queryable (already read by fieldValue). TS adds
TypedSearchAttributes/defineSearchAttributeKey/SearchAttributeType.
- workflowIdConflictPolicy USE_EXISTING/TERMINATE_EXISTING/FAIL.
- ActivityFailure wraps ApplicationFailure (with .type) so workflow error
matching (err.cause.type === 'refresh_token') works.
Also fixes a pre-existing wire defect: the server registered
SignalWithStartWorkflow at 0x0069 while every client sends 0x0066 (schema +
Go/TS SDK) — signalWithStart 404'd against the real engine. Swapped with the
HTTP-only GetWorkflowHistory (no ZAP client).
DescribeWorkflow(workflowId, "") now resolves the OPEN run (Temporal's current
execution) so a continueAsNew successor is the one signalled/described.
Tests: engine unit (search attrs, conflict policy, continueAsNew, startChild)
+ TS e2e vs real tasksd (signals-across-continueAsNew, startChild, search attrs
run to COMPLETED). go test -race ./pkg/... green; TS vitest green.
This commit is contained in:
+37
-20
@@ -1460,16 +1460,22 @@ func handleIdentities(w http.ResponseWriter, r *http.Request, en *engine, ns str
|
||||
// ── ZAP handler dispatch ────────────────────────────────────────────
|
||||
|
||||
const (
|
||||
opStartWorkflow uint16 = 0x0060
|
||||
opSignalWorkflow uint16 = 0x0061
|
||||
opCancelWorkflow uint16 = 0x0062
|
||||
opTerminateWorkflow uint16 = 0x0063
|
||||
opDescribeWorkflow uint16 = 0x0064
|
||||
opListWorkflows uint16 = 0x0065
|
||||
opGetWorkflowHistory uint16 = 0x0066
|
||||
opStartWorkflow uint16 = 0x0060
|
||||
opSignalWorkflow uint16 = 0x0061
|
||||
opCancelWorkflow uint16 = 0x0062
|
||||
opTerminateWorkflow uint16 = 0x0063
|
||||
opDescribeWorkflow uint16 = 0x0064
|
||||
opListWorkflows uint16 = 0x0065
|
||||
// SignalWithStart is 0x0066 on the canonical wire (pkg/sdk/client +
|
||||
// TS opcodes + schema/tasks.zap). The server previously registered it
|
||||
// at 0x0069 and squatted 0x0066 with GetWorkflowHistory, so every
|
||||
// client's signalWithStart 404'd — social's digest/sendEmail/poke all
|
||||
// depend on it. GetWorkflowHistory has no ZAP client (HTTP-served), so
|
||||
// it takes the now-free 0x0069.
|
||||
opSignalWithStartWorkflow uint16 = 0x0066
|
||||
opQueryWorkflow uint16 = 0x0067
|
||||
opResetWorkflow uint16 = 0x0068
|
||||
opSignalWithStartWorkflow uint16 = 0x0069
|
||||
opGetWorkflowHistory uint16 = 0x0069
|
||||
opCreateSchedule uint16 = 0x0070
|
||||
opDeleteSchedule uint16 = 0x0071
|
||||
opListSchedules uint16 = 0x0072
|
||||
@@ -1669,7 +1675,10 @@ func zapHandlers(rootEn *engine, defaultNS string, validator *auth.Validator, re
|
||||
}
|
||||
wfID := strOr(req, "workflow_id", str(req, "workflowId"))
|
||||
runID := strOr(req, "run_id", str(req, "runId"))
|
||||
wf, err := en.StartWorkflow(ns, wfID, runID, TypeRef{Name: typeName}, tq, req["input"])
|
||||
reqID := strOr(req, "request_id", "")
|
||||
sa, _ := req["search_attributes"].(map[string]any)
|
||||
policy := strOr(req, "workflow_id_conflict_policy", str(req, "workflowIdConflictPolicy"))
|
||||
wf, err := en.startWorkflowFull(ns, wfID, runID, TypeRef{Name: typeName}, tq, req["input"], reqID, sa, req["memo"], policy)
|
||||
if err != nil {
|
||||
return nil, 400, err.Error()
|
||||
}
|
||||
@@ -1785,7 +1794,8 @@ func zapHandlers(rootEn *engine, defaultNS string, validator *auth.Validator, re
|
||||
if sigName == "" {
|
||||
return nil, 400, "signal_name required"
|
||||
}
|
||||
wf, err := en.SignalWithStartWorkflow(ns, wfID, runID, TypeRef{Name: typeName}, tq, req["input"], sigName, req["signal_input"], reqID)
|
||||
sa, _ := req["search_attributes"].(map[string]any)
|
||||
wf, err := en.signalWithStartFull(ns, wfID, runID, TypeRef{Name: typeName}, tq, req["input"], sigName, req["signal_input"], reqID, sa, req["memo"])
|
||||
if err != nil {
|
||||
return nil, 400, err.Error()
|
||||
}
|
||||
@@ -1971,16 +1981,19 @@ func respondWorkflowHandler(en *engine) zap.Handler {
|
||||
var env struct {
|
||||
Version int8 `json:"v"`
|
||||
Commands []struct {
|
||||
Kind int8 `json:"kind"`
|
||||
Result []byte `json:"result,omitempty"`
|
||||
Failure []byte `json:"failure,omitempty"`
|
||||
Seq int `json:"seq,omitempty"`
|
||||
ActivityType string `json:"activityType,omitempty"`
|
||||
Input []byte `json:"input,omitempty"`
|
||||
TaskQueue string `json:"taskQueue,omitempty"`
|
||||
StartToCloseMs int64 `json:"startToCloseMs,omitempty"`
|
||||
HeartbeatMs int64 `json:"heartbeatMs,omitempty"`
|
||||
RetryPolicy *client.RetryPolicyJSON `json:"retryPolicy,omitempty"`
|
||||
Kind int8 `json:"kind"`
|
||||
Result []byte `json:"result,omitempty"`
|
||||
Failure []byte `json:"failure,omitempty"`
|
||||
Seq int `json:"seq,omitempty"`
|
||||
ActivityType string `json:"activityType,omitempty"`
|
||||
WorkflowType string `json:"workflowType,omitempty"`
|
||||
ChildWorkflowId string `json:"childWorkflowId,omitempty"`
|
||||
Input []byte `json:"input,omitempty"`
|
||||
TaskQueue string `json:"taskQueue,omitempty"`
|
||||
SearchAttrs map[string]any `json:"searchAttrs,omitempty"`
|
||||
StartToCloseMs int64 `json:"startToCloseMs,omitempty"`
|
||||
HeartbeatMs int64 `json:"heartbeatMs,omitempty"`
|
||||
RetryPolicy *client.RetryPolicyJSON `json:"retryPolicy,omitempty"`
|
||||
} `json:"cmds"`
|
||||
}
|
||||
if len(commands) > 0 {
|
||||
@@ -1998,6 +2011,10 @@ func respondWorkflowHandler(en *engine) zap.Handler {
|
||||
_ = oe.applyScheduleActivity(t.ns, t.workflowID, t.runID, c.Seq, c.ActivityType, c.Input, c.TaskQueue, c.StartToCloseMs, c.HeartbeatMs, c.RetryPolicy)
|
||||
case 3: // canceled — worker ack of a CANCELING handshake
|
||||
_, _ = oe.AckCanceled(t.ns, t.workflowID, t.runID, string(c.Failure), "")
|
||||
case 4: // continueAsNew — close this run, start a successor
|
||||
_ = oe.applyContinueAsNew(t.ns, t.workflowID, t.runID, c.Input, c.WorkflowType, c.TaskQueue)
|
||||
case 5: // startChildWorkflow — detached (ABANDON), idempotent per (run, seq)
|
||||
_ = oe.applyStartChild(t.ns, t.workflowID, t.runID, c.Seq, c.ChildWorkflowId, c.WorkflowType, c.TaskQueue, c.Input, c.SearchAttrs)
|
||||
}
|
||||
}
|
||||
return objectAck(0, "", 200)
|
||||
|
||||
+129
-35
@@ -150,17 +150,25 @@ func (e *engine) ListNamespaces() ([]Namespace, error) {
|
||||
// ── workflows ───────────────────────────────────────────────────────
|
||||
|
||||
func (e *engine) StartWorkflow(ns, workflowId, runId string, typ TypeRef, taskQueue string, input any) (*WorkflowExecution, error) {
|
||||
return e.startWorkflowWithRequestID(ns, workflowId, runId, typ, taskQueue, input, "")
|
||||
return e.startWorkflowFull(ns, workflowId, runId, typ, taskQueue, input, "", nil, nil, "")
|
||||
}
|
||||
|
||||
// StartWorkflowWithRequestID is the idempotent form. When requestID is
|
||||
// non-empty and a prior Start under the same (workflowID, requestID)
|
||||
// landed, the existing execution is returned without a new run.
|
||||
func (e *engine) StartWorkflowWithRequestID(ns, workflowId, runId string, typ TypeRef, taskQueue string, input any, requestID string) (*WorkflowExecution, error) {
|
||||
return e.startWorkflowWithRequestID(ns, workflowId, runId, typ, taskQueue, input, requestID)
|
||||
return e.startWorkflowFull(ns, workflowId, runId, typ, taskQueue, input, requestID, nil, nil, "")
|
||||
}
|
||||
|
||||
func (e *engine) startWorkflowWithRequestID(ns, workflowId, runId string, typ TypeRef, taskQueue string, input any, requestID string) (*WorkflowExecution, error) {
|
||||
return e.startWorkflowFull(ns, workflowId, runId, typ, taskQueue, input, requestID, nil, nil, "")
|
||||
}
|
||||
|
||||
// startWorkflowFull is the single start implementation. searchAttrs/memo
|
||||
// are stored on the execution (searchAttrs are visibility-queryable — see
|
||||
// fieldValue). conflictPolicy governs a live execution already existing
|
||||
// under workflowId (see resolveWorkflowIdConflict).
|
||||
func (e *engine) startWorkflowFull(ns, workflowId, runId string, typ TypeRef, taskQueue string, input any, requestID string, searchAttrs map[string]any, memo any, conflictPolicy string) (*WorkflowExecution, error) {
|
||||
if _, ok, _ := e.DescribeNamespace(ns); !ok {
|
||||
return nil, fmt.Errorf("namespace %q not registered", ns)
|
||||
}
|
||||
@@ -177,6 +185,15 @@ func (e *engine) startWorkflowWithRequestID(ns, workflowId, runId string, typ Ty
|
||||
}
|
||||
}
|
||||
}
|
||||
// Workflow-id reuse policy: an existing NON-terminal run under
|
||||
// workflowId is either reused, terminated, or rejected before a fresh
|
||||
// run is created. Default (empty) preserves the historical behaviour
|
||||
// (a new run is created under the same workflowId).
|
||||
if existing, handled, err := e.resolveWorkflowIdConflict(ns, workflowId, conflictPolicy); err != nil {
|
||||
return nil, err
|
||||
} else if handled {
|
||||
return existing, nil
|
||||
}
|
||||
if runId == "" {
|
||||
runId = newRunId()
|
||||
}
|
||||
@@ -185,12 +202,14 @@ func (e *engine) startWorkflowWithRequestID(ns, workflowId, runId string, typ Ty
|
||||
}
|
||||
startTime := nowRFC3339()
|
||||
wf := WorkflowExecution{
|
||||
Execution: ExecutionRef{WorkflowId: workflowId, RunId: runId},
|
||||
Type: typ,
|
||||
StartTime: startTime,
|
||||
Status: "WORKFLOW_EXECUTION_STATUS_RUNNING",
|
||||
TaskQueue: taskQueue,
|
||||
Input: input,
|
||||
Execution: ExecutionRef{WorkflowId: workflowId, RunId: runId},
|
||||
Type: typ,
|
||||
StartTime: startTime,
|
||||
Status: "WORKFLOW_EXECUTION_STATUS_RUNNING",
|
||||
TaskQueue: taskQueue,
|
||||
Input: input,
|
||||
SearchAttrs: searchAttrs,
|
||||
Memo: memo,
|
||||
}
|
||||
key := fmt.Sprintf("wf/%s/%s/%s", ns, workflowId, runId)
|
||||
if err := e.store.put(key, wf); err != nil {
|
||||
@@ -225,6 +244,43 @@ func (e *engine) startWorkflowWithRequestID(ns, workflowId, runId string, typ Ty
|
||||
return &wf, nil
|
||||
}
|
||||
|
||||
// resolveWorkflowIdConflict applies the workflow-id reuse policy against a
|
||||
// live (non-terminal) run already under workflowId. Returns (existing,
|
||||
// true) when the caller should short-circuit and reuse that run,
|
||||
// (nil,false) to proceed with a fresh run, or an error for FAIL. An empty
|
||||
// policy means "no policy" — proceed (historical behaviour). Recognised
|
||||
// policies (long WORKFLOW_ID_CONFLICT_POLICY_* or the short suffix):
|
||||
// - USE_EXISTING → reuse the live run
|
||||
// - TERMINATE_EXISTING → terminate the live run, then start fresh
|
||||
// - FAIL → error if a live run exists
|
||||
func (e *engine) resolveWorkflowIdConflict(ns, workflowId, policy string) (*WorkflowExecution, bool, error) {
|
||||
if policy == "" {
|
||||
return nil, false, nil
|
||||
}
|
||||
existing, ok, _ := e.DescribeWorkflow(ns, workflowId, "")
|
||||
if !ok || isTerminal(existing.Status) {
|
||||
return nil, false, nil
|
||||
}
|
||||
switch normalizeConflictPolicy(policy) {
|
||||
case "USE_EXISTING":
|
||||
return existing, true, nil
|
||||
case "TERMINATE_EXISTING":
|
||||
if _, err := e.TerminateWorkflowWithReason(ns, existing.Execution.WorkflowId, existing.Execution.RunId, "superseded by workflow-id conflict policy", "engine"); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
return nil, false, nil
|
||||
case "FAIL":
|
||||
return nil, false, fmt.Errorf("workflow %q already running (conflict policy FAIL)", workflowId)
|
||||
}
|
||||
return nil, false, nil
|
||||
}
|
||||
|
||||
func normalizeConflictPolicy(p string) string {
|
||||
p = strings.ToUpper(p)
|
||||
p = strings.TrimPrefix(p, "WORKFLOW_ID_CONFLICT_POLICY_")
|
||||
return p
|
||||
}
|
||||
|
||||
// lookupIdempotency returns the runID previously associated with
|
||||
// (workflowID, requestID), if any.
|
||||
func (e *engine) lookupIdempotency(ns, workflowID, requestID string) (string, bool, error) {
|
||||
@@ -242,18 +298,36 @@ func (e *engine) DescribeWorkflow(ns, workflowId, runId string) (*WorkflowExecut
|
||||
}
|
||||
return &wf, true, err
|
||||
}
|
||||
// runId not supplied — return the most recent execution under workflowId.
|
||||
// runId not supplied — return the CURRENT execution under workflowId: the
|
||||
// open (non-terminal) run if one exists, else the most recent. This makes
|
||||
// the successor of a continueAsNew (a fresh RUNNING run that may share the
|
||||
// closed run's second-granularity StartTime) the one resolved, so
|
||||
// signal / describe target the live run.
|
||||
rows, err := listInto[WorkflowExecution](e.store, fmt.Sprintf("wf/%s/%s/", ns, workflowId))
|
||||
if err != nil || len(rows) == 0 {
|
||||
return nil, false, err
|
||||
}
|
||||
latest := rows[0]
|
||||
for _, r := range rows[1:] {
|
||||
if r.StartTime > latest.StartTime {
|
||||
latest = r
|
||||
best := 0
|
||||
for i := 1; i < len(rows); i++ {
|
||||
if moreCurrentRun(rows[i], rows[best]) {
|
||||
best = i
|
||||
}
|
||||
}
|
||||
return &latest, true, nil
|
||||
return &rows[best], true, nil
|
||||
}
|
||||
|
||||
// moreCurrentRun reports whether run a is a "more current" execution than b
|
||||
// for the same workflowId: an open run outranks a terminal one; otherwise
|
||||
// the later StartTime wins, with the runId as a deterministic tiebreak.
|
||||
func moreCurrentRun(a, b WorkflowExecution) bool {
|
||||
aTerm, bTerm := isTerminal(a.Status), isTerminal(b.Status)
|
||||
if aTerm != bTerm {
|
||||
return !aTerm
|
||||
}
|
||||
if a.StartTime != b.StartTime {
|
||||
return a.StartTime > b.StartTime
|
||||
}
|
||||
return a.Execution.RunId > b.Execution.RunId
|
||||
}
|
||||
|
||||
func (e *engine) ListWorkflows(ns string) ([]WorkflowExecution, error) {
|
||||
@@ -421,35 +495,55 @@ func (e *engine) signalWorkflow(ns, workflowId, runId, name string, payload any,
|
||||
if isTerminal(wf.Status) {
|
||||
return fmt.Errorf("workflow not running: status=%s", wf.Status)
|
||||
}
|
||||
if _, err := e.appendHistory(ns, wf.Execution.WorkflowId, wf.Execution.RunId, "WORKFLOW_EXECUTION_SIGNALED", map[string]any{
|
||||
// Resolve to the concrete run so the signal event and the re-dispatched
|
||||
// workflow task both target the same run (DescribeWorkflow with an empty
|
||||
// runId returns the latest execution).
|
||||
wfID, run := wf.Execution.WorkflowId, wf.Execution.RunId
|
||||
// Serialize with the run's other advancers: append SIGNALED then
|
||||
// schedule a workflow task so the stateless worker replays the extended
|
||||
// history and delivers the signal to its handler. Without the
|
||||
// re-dispatch a signal-driven workflow (condition on a signal-populated
|
||||
// queue) would never wake until some other event advanced the run.
|
||||
unlock := e.lockRun(ns, wfID, run)
|
||||
defer unlock()
|
||||
if _, err := e.appendHistory(ns, wfID, run, "WORKFLOW_EXECUTION_SIGNALED", map[string]any{
|
||||
"signalName": name,
|
||||
"input": payload,
|
||||
"identity": identity,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
wf, _, _ = e.DescribeWorkflow(ns, wf.Execution.WorkflowId, wf.Execution.RunId)
|
||||
wf, _, _ = e.DescribeWorkflow(ns, wfID, run)
|
||||
e.emit(Event{
|
||||
Kind: "workflow.signaled",
|
||||
Namespace: ns,
|
||||
WorkflowID: wf.Execution.WorkflowId,
|
||||
RunID: wf.Execution.RunId,
|
||||
WorkflowID: wfID,
|
||||
RunID: run,
|
||||
Data: map[string]any{"signal": name, "input": payload, "history_length": wf.HistoryLen},
|
||||
})
|
||||
return nil
|
||||
return e.scheduleWorkflowTask(ns, wfID, run)
|
||||
}
|
||||
|
||||
// SignalWithStartWorkflow signals an existing running workflow under
|
||||
// workflowId; if none is running, starts a fresh one and signals that.
|
||||
// The (start, signal) pair is atomic from the caller's view.
|
||||
func (e *engine) SignalWithStartWorkflow(ns, workflowId, runId string, typ TypeRef, taskQueue string, input any, signalName string, signalPayload any, requestID string) (*WorkflowExecution, error) {
|
||||
return e.signalWithStartFull(ns, workflowId, runId, typ, taskQueue, input, signalName, signalPayload, requestID, nil, nil)
|
||||
}
|
||||
|
||||
// signalWithStartFull is the search-attribute-aware form: on the
|
||||
// fresh-start branch the execution is created with searchAttrs/memo (so a
|
||||
// signalWithStart-created run is visibility-queryable just like a Start).
|
||||
// USE_EXISTING semantics: a live run under workflowId is signalled, never
|
||||
// duplicated.
|
||||
func (e *engine) signalWithStartFull(ns, workflowId, runId string, typ TypeRef, taskQueue string, input any, signalName string, signalPayload any, requestID string, searchAttrs map[string]any, memo any) (*WorkflowExecution, error) {
|
||||
if existing, ok, _ := e.DescribeWorkflow(ns, workflowId, ""); ok && !isTerminal(existing.Status) {
|
||||
if err := e.SignalWorkflow(ns, existing.Execution.WorkflowId, existing.Execution.RunId, signalName, signalPayload); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return existing, nil
|
||||
}
|
||||
wf, err := e.startWorkflowWithRequestID(ns, workflowId, runId, typ, taskQueue, input, requestID)
|
||||
wf, err := e.startWorkflowFull(ns, workflowId, runId, typ, taskQueue, input, requestID, searchAttrs, memo, "")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -628,10 +722,10 @@ func (e *engine) ResetWorkflow(ns, workflowID, runID string, eventID int64, reas
|
||||
TaskQueue: src.TaskQueue,
|
||||
Input: src.Input,
|
||||
Memo: map[string]any{
|
||||
"resetFromRunId": src.Execution.RunId,
|
||||
"resetFromRunId": src.Execution.RunId,
|
||||
"resetFromEventId": eventID,
|
||||
"resetReason": reason,
|
||||
"resetIdentity": identity,
|
||||
"resetReason": reason,
|
||||
"resetIdentity": identity,
|
||||
},
|
||||
}
|
||||
// Persist the new execution shell first so appendHistory finds it.
|
||||
@@ -654,10 +748,10 @@ func (e *engine) ResetWorkflow(ns, workflowID, runID string, eventID int64, reas
|
||||
return nil, err
|
||||
}
|
||||
if _, err := e.appendHistory(ns, newWf.Execution.WorkflowId, newWf.Execution.RunId, "WORKFLOW_EXECUTION_RESET", map[string]any{
|
||||
"baseRunId": src.Execution.RunId,
|
||||
"eventId": eventID,
|
||||
"reason": reason,
|
||||
"identity": identity,
|
||||
"baseRunId": src.Execution.RunId,
|
||||
"eventId": eventID,
|
||||
"reason": reason,
|
||||
"identity": identity,
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -1223,14 +1317,14 @@ func (e *engine) ListSearchAttributes(ns string) ([]SearchAttribute, error) {
|
||||
// NamespaceMetadataPatch carries the fields a metadata update may set.
|
||||
// Empty string fields are ignored; non-nil maps fully replace.
|
||||
type NamespaceMetadataPatch struct {
|
||||
Description *string `json:"description,omitempty"`
|
||||
OwnerEmail *string `json:"ownerEmail,omitempty"`
|
||||
Retention *string `json:"retention,omitempty"`
|
||||
HistoryArchivalState *string `json:"historyArchivalState,omitempty"`
|
||||
HistoryArchivalUri *string `json:"historyArchivalUri,omitempty"`
|
||||
VisibilityArchivalState *string `json:"visibilityArchivalState,omitempty"`
|
||||
VisibilityArchivalUri *string `json:"visibilityArchivalUri,omitempty"`
|
||||
CustomData map[string]string `json:"customData,omitempty"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
OwnerEmail *string `json:"ownerEmail,omitempty"`
|
||||
Retention *string `json:"retention,omitempty"`
|
||||
HistoryArchivalState *string `json:"historyArchivalState,omitempty"`
|
||||
HistoryArchivalUri *string `json:"historyArchivalUri,omitempty"`
|
||||
VisibilityArchivalState *string `json:"visibilityArchivalState,omitempty"`
|
||||
VisibilityArchivalUri *string `json:"visibilityArchivalUri,omitempty"`
|
||||
CustomData map[string]string `json:"customData,omitempty"`
|
||||
}
|
||||
|
||||
func (e *engine) UpdateNamespaceMetadata(name string, p NamespaceMetadataPatch) (*Namespace, error) {
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
// Copyright © 2026 Hanzo AI. MIT License.
|
||||
|
||||
package tasks
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// Server-side appliers for the two durable workflow commands beyond the
|
||||
// activity core (schedule/complete/fail): continueAsNew and startChild.
|
||||
// Both are emitted by the decider on RespondWorkflowTaskCompleted and
|
||||
// applied here under the run lock (respondWorkflowHandler holds it), in the
|
||||
// same event-sourced style as applyScheduleActivity — history is the source
|
||||
// of truth, application is idempotent on replay.
|
||||
|
||||
// history event types for the durable workflow commands.
|
||||
const (
|
||||
evtContinuedAsNew = "WORKFLOW_EXECUTION_CONTINUED_AS_NEW"
|
||||
evtChildStarted = "CHILD_WORKFLOW_EXECUTION_STARTED"
|
||||
)
|
||||
|
||||
// applyContinueAsNew closes the current run as CONTINUED_AS_NEW and starts a
|
||||
// successor run under the SAME workflowId with a fresh runId, carrying the
|
||||
// new input (a JSON-encoded argument array) plus the run's task queue,
|
||||
// type, search attributes and memo. The successor's first workflow task is
|
||||
// scheduled by startWorkflowFull, so the worker replays it from event 0.
|
||||
//
|
||||
// Caller holds the run lock for (ns, wf, run). The successor has a distinct
|
||||
// runId ⇒ a distinct lock key, so no re-entrancy.
|
||||
func (e *engine) applyContinueAsNew(ns, wf, run string, input []byte, wfType, taskQueue string) error {
|
||||
cur, ok, err := e.DescribeWorkflow(ns, wf, run)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !ok || isTerminal(cur.Status) {
|
||||
return nil // idempotent: already continued / terminal on replay
|
||||
}
|
||||
if wfType == "" {
|
||||
wfType = cur.Type.Name
|
||||
}
|
||||
if taskQueue == "" {
|
||||
taskQueue = cur.TaskQueue
|
||||
}
|
||||
var args any
|
||||
if len(input) > 0 {
|
||||
if err := json.Unmarshal(input, &args); err != nil {
|
||||
return fmt.Errorf("continueAsNew: decode input: %w", err)
|
||||
}
|
||||
}
|
||||
newRun := newRunId()
|
||||
if _, err := e.terminalTransition(ns, wf, run,
|
||||
"WORKFLOW_EXECUTION_STATUS_CONTINUED_AS_NEW",
|
||||
"workflow.continued_as_new", evtContinuedAsNew,
|
||||
map[string]any{"newExecutionRunId": newRun}); err != nil {
|
||||
return err
|
||||
}
|
||||
// A CONTINUED_AS_NEW run is terminal ⇒ no conflict policy needed; the
|
||||
// successor is a brand-new run under the same workflowId.
|
||||
_, err = e.startWorkflowFull(ns, wf, newRun, TypeRef{Name: wfType}, taskQueue, args, "", cur.SearchAttrs, cur.Memo, "")
|
||||
return err
|
||||
}
|
||||
|
||||
// applyStartChild starts a detached child workflow (parentClosePolicy
|
||||
// ABANDON semantics: the child is an independent top-level run the parent
|
||||
// does not await to completion). It is idempotent per (parentRun, seq): a
|
||||
// replay or recovery re-application is a no-op once the parent history
|
||||
// carries CHILD_WORKFLOW_EXECUTION_STARTED{seq}. After starting the child
|
||||
// it appends that event and re-schedules the parent's workflow task so the
|
||||
// parent replays and its startChild future resolves.
|
||||
//
|
||||
// Caller holds the parent run lock for (ns, parentWf, parentRun).
|
||||
func (e *engine) applyStartChild(ns, parentWf, parentRun string, seq int, childWfId, childType, taskQueue string, input []byte, searchAttrs map[string]any) error {
|
||||
if childType == "" {
|
||||
return fmt.Errorf("startChild: child workflow type required")
|
||||
}
|
||||
if e.childStartRecorded(ns, parentWf, parentRun, seq) {
|
||||
return nil // exactly-once: already started on a prior episode
|
||||
}
|
||||
var args any
|
||||
if len(input) > 0 {
|
||||
if err := json.Unmarshal(input, &args); err != nil {
|
||||
return fmt.Errorf("startChild: decode input: %w", err)
|
||||
}
|
||||
}
|
||||
if childWfId == "" {
|
||||
childWfId = childType + "-child-" + newRunId()[:12]
|
||||
}
|
||||
// USE_EXISTING so a redundant application never spawns a second child
|
||||
// under the same workflowId; the child is fully independent (ABANDON).
|
||||
child, err := e.startWorkflowFull(ns, childWfId, "", TypeRef{Name: childType}, taskQueue, args, "", searchAttrs, nil, "WORKFLOW_ID_CONFLICT_POLICY_USE_EXISTING")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := e.appendHistory(ns, parentWf, parentRun, evtChildStarted, map[string]any{
|
||||
"seq": seq,
|
||||
"childWorkflowId": child.Execution.WorkflowId,
|
||||
"childRunId": child.Execution.RunId,
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
e.emit(Event{Kind: "workflow.child_started", Namespace: ns, WorkflowID: parentWf, RunID: parentRun,
|
||||
Data: map[string]any{"seq": seq, "childWorkflowId": child.Execution.WorkflowId, "childRunId": child.Execution.RunId}})
|
||||
return e.scheduleWorkflowTask(ns, parentWf, parentRun)
|
||||
}
|
||||
|
||||
// childStartRecorded reports whether the parent history already carries a
|
||||
// CHILD_WORKFLOW_EXECUTION_STARTED for this seq (the exactly-once anchor).
|
||||
func (e *engine) childStartRecorded(ns, parentWf, parentRun string, seq int) bool {
|
||||
events, err := listInto[HistoryEvent](e.store, fmt.Sprintf("wfh/%s/%s/%s/", ns, parentWf, parentRun))
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
for i := range events {
|
||||
if events[i].EventType != evtChildStarted {
|
||||
continue
|
||||
}
|
||||
if s, ok := events[i].Attributes["seq"]; ok && int(toFloat(s)) == seq {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// toFloat coerces a JSON-decoded numeric attribute (float64) to float64.
|
||||
func toFloat(v any) float64 {
|
||||
switch n := v.(type) {
|
||||
case float64:
|
||||
return n
|
||||
case int:
|
||||
return float64(n)
|
||||
case int64:
|
||||
return float64(n)
|
||||
case json.Number:
|
||||
f, _ := n.Float64()
|
||||
return f
|
||||
}
|
||||
return 0
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
// Copyright © 2026 Hanzo AI. MIT License.
|
||||
|
||||
package tasks
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestEngine_SearchAttributes_StoredAndQueryable proves per-workflow search
|
||||
// attributes reach storage and are visibility-queryable (the social
|
||||
// deletePost / startWorkflow list-by-postId path).
|
||||
func TestEngine_SearchAttributes_StoredAndQueryable(t *testing.T) {
|
||||
en, ns := engineFixture(t)
|
||||
sa := map[string]any{"postId": "p1", "organizationId": "o1"}
|
||||
if _, err := en.startWorkflowFull(ns, "post_p1", "", TypeRef{Name: "PostWorkflow"}, "main", nil, "", sa, nil, ""); err != nil {
|
||||
t.Fatalf("start with search attrs: %v", err)
|
||||
}
|
||||
_, _ = en.startWorkflowFull(ns, "post_p2", "", TypeRef{Name: "PostWorkflow"}, "main", nil, "", map[string]any{"postId": "p2", "organizationId": "o1"}, nil, "")
|
||||
|
||||
cases := []struct {
|
||||
query string
|
||||
want int
|
||||
}{
|
||||
{`postId = "p1"`, 1},
|
||||
{`postId = "p2"`, 1},
|
||||
{`postId = "nope"`, 0},
|
||||
{`organizationId = "o1"`, 2},
|
||||
{`organizationId = "o1" AND ExecutionStatus = "Running"`, 2},
|
||||
{`postId = "p1" AND ExecutionStatus = "Running"`, 1},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got, err := en.ListWorkflowExecutions(ns, c.query)
|
||||
if err != nil {
|
||||
t.Fatalf("list %q: %v", c.query, err)
|
||||
}
|
||||
if len(got) != c.want {
|
||||
t.Fatalf("query %q: len=%d, want %d", c.query, len(got), c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestEngine_WorkflowIdConflictPolicy covers TERMINATE_EXISTING and
|
||||
// USE_EXISTING against a live run under a deterministic workflowId.
|
||||
func TestEngine_WorkflowIdConflictPolicy(t *testing.T) {
|
||||
en, ns := engineFixture(t)
|
||||
|
||||
// TERMINATE_EXISTING supersedes the running run with a fresh one.
|
||||
run1, err := en.startWorkflowFull(ns, "post_x", "", TypeRef{Name: "PostWorkflow"}, "main", nil, "", nil, nil, "TERMINATE_EXISTING")
|
||||
if err != nil {
|
||||
t.Fatalf("first start: %v", err)
|
||||
}
|
||||
run2, err := en.startWorkflowFull(ns, "post_x", "", TypeRef{Name: "PostWorkflow"}, "main", nil, "", nil, nil, "TERMINATE_EXISTING")
|
||||
if err != nil {
|
||||
t.Fatalf("second start: %v", err)
|
||||
}
|
||||
if run2.Execution.RunId == run1.Execution.RunId {
|
||||
t.Fatalf("TERMINATE_EXISTING must create a new run")
|
||||
}
|
||||
if old, _, _ := en.DescribeWorkflow(ns, "post_x", run1.Execution.RunId); old.Status != "WORKFLOW_EXECUTION_STATUS_TERMINATED" {
|
||||
t.Fatalf("prior run status=%s, want TERMINATED", old.Status)
|
||||
}
|
||||
if run2.Status != "WORKFLOW_EXECUTION_STATUS_RUNNING" {
|
||||
t.Fatalf("new run status=%s, want RUNNING", run2.Status)
|
||||
}
|
||||
|
||||
// USE_EXISTING returns the live run without starting a new one.
|
||||
first, _ := en.startWorkflowFull(ns, "post_y", "", TypeRef{Name: "PostWorkflow"}, "main", nil, "", nil, nil, "")
|
||||
reuse, err := en.startWorkflowFull(ns, "post_y", "", TypeRef{Name: "PostWorkflow"}, "main", nil, "", nil, nil, "USE_EXISTING")
|
||||
if err != nil {
|
||||
t.Fatalf("use-existing: %v", err)
|
||||
}
|
||||
if reuse.Execution.RunId != first.Execution.RunId {
|
||||
t.Fatalf("USE_EXISTING must reuse the live run")
|
||||
}
|
||||
}
|
||||
|
||||
// TestEngine_ContinueAsNew proves a run closes as CONTINUED_AS_NEW and a
|
||||
// successor run starts under the same workflowId carrying the new input,
|
||||
// search attributes and memo.
|
||||
func TestEngine_ContinueAsNew(t *testing.T) {
|
||||
en, ns := engineFixture(t)
|
||||
sa := map[string]any{"organizationId": "o1"}
|
||||
orig, err := en.startWorkflowFull(ns, "digest_o1", "", TypeRef{Name: "digestEmailWorkflow"}, "main", []any{map[string]any{"n": float64(1)}}, "", sa, nil, "")
|
||||
if err != nil {
|
||||
t.Fatalf("start: %v", err)
|
||||
}
|
||||
|
||||
newInput, _ := json.Marshal([]any{map[string]any{"n": float64(2)}})
|
||||
unlock := en.lockRun(ns, "digest_o1", orig.Execution.RunId)
|
||||
if err := en.applyContinueAsNew(ns, "digest_o1", orig.Execution.RunId, newInput, "", ""); err != nil {
|
||||
unlock()
|
||||
t.Fatalf("continueAsNew: %v", err)
|
||||
}
|
||||
unlock()
|
||||
|
||||
// Original run is terminal CONTINUED_AS_NEW.
|
||||
old, _, _ := en.DescribeWorkflow(ns, "digest_o1", orig.Execution.RunId)
|
||||
if old.Status != "WORKFLOW_EXECUTION_STATUS_CONTINUED_AS_NEW" {
|
||||
t.Fatalf("original status=%s, want CONTINUED_AS_NEW", old.Status)
|
||||
}
|
||||
// Latest run is a fresh RUNNING successor with the carried input + attrs.
|
||||
succ, ok, _ := en.DescribeWorkflow(ns, "digest_o1", "")
|
||||
if !ok || succ.Execution.RunId == orig.Execution.RunId {
|
||||
t.Fatalf("successor must be a new run; got ok=%v run=%s", ok, succ.Execution.RunId)
|
||||
}
|
||||
if succ.Status != "WORKFLOW_EXECUTION_STATUS_RUNNING" {
|
||||
t.Fatalf("successor status=%s, want RUNNING", succ.Status)
|
||||
}
|
||||
if succ.Type.Name != "digestEmailWorkflow" {
|
||||
t.Fatalf("successor type=%s, want same type", succ.Type.Name)
|
||||
}
|
||||
if succ.SearchAttrs["organizationId"] != "o1" {
|
||||
t.Fatalf("successor search attrs not carried: %+v", succ.SearchAttrs)
|
||||
}
|
||||
gotInput, _ := json.Marshal(succ.Input)
|
||||
if string(gotInput) != string(newInput) {
|
||||
t.Fatalf("successor input=%s, want %s", gotInput, newInput)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEngine_StartChild proves a detached child starts, the parent records
|
||||
// CHILD_WORKFLOW_EXECUTION_STARTED, and re-application is idempotent per seq.
|
||||
func TestEngine_StartChild(t *testing.T) {
|
||||
en, ns := engineFixture(t)
|
||||
parent, err := en.startWorkflowFull(ns, "post_parent", "", TypeRef{Name: "postWorkflowV105"}, "main", nil, "", nil, nil, "")
|
||||
if err != nil {
|
||||
t.Fatalf("start parent: %v", err)
|
||||
}
|
||||
pw, pr := parent.Execution.WorkflowId, parent.Execution.RunId
|
||||
|
||||
childInput, _ := json.Marshal([]any{map[string]any{"postNow": true}})
|
||||
unlock := en.lockRun(ns, pw, pr)
|
||||
if err := en.applyStartChild(ns, pw, pr, 0, "post_parent_child", "postWorkflowV105", "main", childInput, map[string]any{"postId": "p1"}); err != nil {
|
||||
unlock()
|
||||
t.Fatalf("startChild: %v", err)
|
||||
}
|
||||
// Idempotent re-application for the same seq is a no-op.
|
||||
if err := en.applyStartChild(ns, pw, pr, 0, "post_parent_child", "postWorkflowV105", "main", childInput, nil); err != nil {
|
||||
unlock()
|
||||
t.Fatalf("startChild replay: %v", err)
|
||||
}
|
||||
unlock()
|
||||
|
||||
// Child exists and is running.
|
||||
child, ok, _ := en.DescribeWorkflow(ns, "post_parent_child", "")
|
||||
if !ok || child.Status != "WORKFLOW_EXECUTION_STATUS_RUNNING" {
|
||||
t.Fatalf("child not running: ok=%v", ok)
|
||||
}
|
||||
if child.SearchAttrs["postId"] != "p1" {
|
||||
t.Fatalf("child search attrs not carried: %+v", child.SearchAttrs)
|
||||
}
|
||||
// Parent history has exactly one CHILD_WORKFLOW_EXECUTION_STARTED{seq=0}.
|
||||
hist, _, _ := en.GetWorkflowHistory(ns, pw, pr, 0, 100, false)
|
||||
n := 0
|
||||
for _, ev := range hist {
|
||||
if ev.EventType == evtChildStarted {
|
||||
n++
|
||||
if int(toFloat(ev.Attributes["seq"])) != 0 {
|
||||
t.Fatalf("child-started seq=%v, want 0", ev.Attributes["seq"])
|
||||
}
|
||||
}
|
||||
}
|
||||
if n != 1 {
|
||||
t.Fatalf("CHILD_WORKFLOW_EXECUTION_STARTED count=%d, want 1 (idempotent)", n)
|
||||
}
|
||||
}
|
||||
+20
-11
@@ -83,6 +83,8 @@ struct StartWorkflowRequest
|
||||
retryPolicy RetryPolicy
|
||||
timeouts Timeouts
|
||||
memo Payloads
|
||||
searchAttributes Payloads # name→value, stored on the execution + visibility-queryable
|
||||
workflowIdConflictPolicy Text # USE_EXISTING | TERMINATE_EXISTING | FAIL (empty = none)
|
||||
|
||||
struct StartWorkflowResponse
|
||||
runId Text
|
||||
@@ -301,23 +303,30 @@ struct WaitActivityResultResponse
|
||||
# `result` carries JSON-encoded fn return
|
||||
# 1 = failWorkflow — workflow fn returned an error
|
||||
# `failure` carries temporal.Encode bytes
|
||||
# 2 = scheduleActivity — in-workflow ExecuteActivity call
|
||||
# `activityTaskId` is the frontend-minted id
|
||||
# 3 = scheduleChildWorkflow — in-workflow ExecuteChildWorkflow call
|
||||
# `childWorkflowId` + `childRunId` identify
|
||||
# the child; used by Phase-2 replay + linkage
|
||||
# 2 = scheduleActivity — in-workflow ExecuteActivity call, seq-keyed
|
||||
# 3 = canceled — worker ack of a CANCELING handshake
|
||||
# 4 = continueAsNew — close this run (CONTINUED_AS_NEW) + start a
|
||||
# successor; `input` carries the successor args,
|
||||
# `workflowType`/`taskQueue` optionally override
|
||||
# 5 = startChildWorkflow — detached (ABANDON) child, seq-keyed; `workflowType`
|
||||
# = child type, `childWorkflowId` + `input` +
|
||||
# `searchAttrs` identify/parameterize the child
|
||||
|
||||
struct CommandsEnvelope
|
||||
version Int8 # 1 = v1
|
||||
commands List(Command)
|
||||
|
||||
struct Command
|
||||
kind Int8 # 0..3; see doc block above
|
||||
result Bytes # only for kind=0 completeWorkflow (JSON)
|
||||
failure Bytes # only for kind=1 failWorkflow (temporal.Encode shape)
|
||||
activityTaskId Text # only for kind=2 scheduleActivity (idempotency)
|
||||
childWorkflowId Text # only for kind=3 scheduleChildWorkflow
|
||||
childRunId Text # only for kind=3 scheduleChildWorkflow
|
||||
kind Int8 # 0..5; see doc block above
|
||||
result Bytes # kind=0 completeWorkflow (JSON)
|
||||
failure Bytes # kind=1 failWorkflow (temporal.Encode) / kind=3 canceled reason
|
||||
seq Int32 # kind=2 scheduleActivity / kind=5 startChildWorkflow
|
||||
activityType Text # kind=2 scheduleActivity
|
||||
workflowType Text # kind=4 continueAsNew (successor type) / kind=5 startChild (child type)
|
||||
childWorkflowId Text # kind=5 startChildWorkflow
|
||||
input Bytes # kind=2 activity args / kind=4 successor args / kind=5 child args
|
||||
taskQueue Text # kind=2/4/5
|
||||
searchAttrs Payloads # kind=5 startChildWorkflow (child visibility attrs)
|
||||
|
||||
# ── Canonical RPC service ──────────────────────────────────────
|
||||
|
||||
|
||||
+12
-15
@@ -106,36 +106,33 @@ Failures serialise to the exact `{ v, p }` envelope of `pkg/sdk/temporal`.
|
||||
|
||||
## Status — complete vs remaining
|
||||
|
||||
This is **PR #1 (CORE)**. What is fully wired and tested against the real ZAP
|
||||
transport:
|
||||
Wired and tested against the real ZAP transport (Go engine ≥ v1.49.0):
|
||||
|
||||
| Primitive | Status |
|
||||
|---|---|
|
||||
| Client: start / signal / signalWithStart / query / describe / cancel / terminate / list | ✅ complete |
|
||||
| Client: namespace register/describe/list, health | ✅ complete |
|
||||
| Client: `searchAttributes` + `workflowIdConflictPolicy` on start / signalWithStart | ✅ complete |
|
||||
| Worker: subscribe, activity dispatch + heartbeat, respond | ✅ complete |
|
||||
| Workflow decider: `proxyActivities` (durable activities) | ✅ complete |
|
||||
| Durable `sleep` / `startTimer` (activity-backed) | ✅ complete¹ |
|
||||
| `condition` (parks until predicate holds) | ✅ complete |
|
||||
| `defineSignal` / `setHandler` (replayed from history) | ✅ complete² |
|
||||
| Retry policy, activity failure propagation, cancellation signal | ✅ complete |
|
||||
| `@temporalio/*` shim: `Connection`, `Client`, `NativeConnection`, `Worker`, `ApplicationFailure` | ✅ complete |
|
||||
| `defineSignal` / `setHandler` — signal delivered to a running workflow | ✅ complete |
|
||||
| `continueAsNew` — closes the run, server starts the successor | ✅ complete |
|
||||
| `startChild` / `executeChild` — detached (ABANDON) child workflow | ✅ complete |
|
||||
| Typed search attributes (`TypedSearchAttributes`, `defineSearchAttributeKey`) — stored + visibility-queryable | ✅ complete |
|
||||
| Retry policy, activity failure propagation (`ActivityFailure`→`ApplicationFailure`), cancellation | ✅ complete |
|
||||
| `@temporalio/*` shim: `Connection`, `Client`, `NativeConnection`, `Worker`, `ApplicationFailure`, `ActivityFailure` | ✅ complete |
|
||||
|
||||
Remaining (blocked on **server** wire support, not this SDK):
|
||||
Remaining:
|
||||
|
||||
- **Durable timers as a native command.** `sleep`/`startTimer` are backed by an
|
||||
internal `__hanzo_timer__` sleeper activity — durable and deterministic, but a
|
||||
worker crash mid-sleep re-runs the delay from zero (fires late, never lost).
|
||||
A native `TIMER_STARTED`/`TIMER_FIRED` server command removes the caveat. ¹
|
||||
- **Signal-to-running-workflow delivery.** The API + history-replay path are
|
||||
implemented; end-to-end delivery depends on the server appending
|
||||
`WORKFLOW_EXECUTION_SIGNALED` to replayable history and re-dispatching a
|
||||
workflow task (the Go SDK has the same gap today). ²
|
||||
- **`continueAsNew`** — API present; throws `ContinueAsNewNotSupported` at
|
||||
runtime (no server command yet).
|
||||
- **Typed search attributes** — accepted on `StartWorkflowOptions` for API
|
||||
compatibility but ignored on the v1 wire (`StartWorkflowRequest` has no
|
||||
search-attribute field; only `memo` is carried).
|
||||
- **Child result await.** `startChild`/`executeChild` are ABANDON (detached):
|
||||
they resolve on the child's *start*, not its completion — the parent does not
|
||||
block on the child's result. This matches how social uses repeat-post children.
|
||||
- **`WorkflowHandle.result()` value** — resolves `undefined` on completion
|
||||
because `DescribeWorkflowResponse` carries no result field on the v1 wire
|
||||
(same gap as the Go SDK). It throws on failure/cancel/terminate.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@hanzoai/tasks",
|
||||
"version": "0.1.0",
|
||||
"version": "1.49.0",
|
||||
"description": "TypeScript SDK for Hanzo Tasks — durable workflow execution over the native ZAP transport. Drop-in replacement for @temporalio/* + nestjs-temporal-core against a Hanzo Tasks (tasksd) server.",
|
||||
"license": "MIT",
|
||||
"author": "Hanzo AI",
|
||||
|
||||
@@ -142,6 +142,8 @@ export class TasksClient {
|
||||
retry_policy: retryToWire(opts.retryPolicy),
|
||||
timeouts: timeoutsToWire(opts),
|
||||
memo: opts.memo,
|
||||
search_attributes: opts.searchAttributes,
|
||||
workflow_id_conflict_policy: opts.workflowIdConflictPolicy || undefined,
|
||||
cron_schedule: opts.cronSchedule,
|
||||
identity: this.identity,
|
||||
};
|
||||
@@ -167,6 +169,7 @@ export class TasksClient {
|
||||
retry_policy: retryToWire(opts.retryPolicy),
|
||||
timeouts: timeoutsToWire(opts),
|
||||
memo: opts.memo,
|
||||
search_attributes: opts.searchAttributes,
|
||||
cron_schedule: opts.cronSchedule,
|
||||
identity: this.identity,
|
||||
signal_name: signalName,
|
||||
|
||||
@@ -40,12 +40,17 @@ export interface StartWorkflowOptions {
|
||||
/** Opaque metadata attached to the execution. */
|
||||
memo?: Record<string, unknown>;
|
||||
/**
|
||||
* Search attributes. Accepted for @temporalio API compatibility, but the
|
||||
* v1 Hanzo Tasks wire (StartWorkflowRequest) does not yet carry a search-
|
||||
* attribute field — see the SDK README "Remaining" section. Values are
|
||||
* currently ignored on the wire.
|
||||
* Search attributes — a flat name→value record carried on the wire
|
||||
* (StartWorkflowRequest.search_attributes), stored on the execution, and
|
||||
* visibility-queryable via ListWorkflows (e.g. `postId="123"`).
|
||||
*/
|
||||
searchAttributes?: Record<string, unknown>;
|
||||
/**
|
||||
* Reuse policy for an existing run under workflowId. Recognised values
|
||||
* (long WORKFLOW_ID_CONFLICT_POLICY_* or the short suffix):
|
||||
* USE_EXISTING | TERMINATE_EXISTING | FAIL. Empty = no policy.
|
||||
*/
|
||||
workflowIdConflictPolicy?: string;
|
||||
}
|
||||
|
||||
export enum WorkflowStatus {
|
||||
|
||||
@@ -51,6 +51,59 @@ export class TaskError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ApplicationFailure — a business error a workflow or activity raises. Mirrors
|
||||
* @temporalio/common's ApplicationFailure: `type` is the error classifier
|
||||
* (e.g. "refresh_token", "bad_body") that workflow code switches on. On the
|
||||
* wire it is the failure `code`, so `type` is a view over `code`.
|
||||
*/
|
||||
export class ApplicationFailure extends TaskError {
|
||||
constructor(message: string, type: string = FailureCode.Application, nonRetryable = false, details: unknown[] = [], cause?: TaskError) {
|
||||
super(message, type, nonRetryable, details, cause);
|
||||
this.name = "ApplicationFailure";
|
||||
}
|
||||
|
||||
/** The error classifier — the field @temporalio workflow code reads. */
|
||||
get type(): string {
|
||||
return this.code;
|
||||
}
|
||||
|
||||
static create(opts: { message: string; type?: string; nonRetryable?: boolean; details?: unknown[] }): ApplicationFailure {
|
||||
return new ApplicationFailure(opts.message, opts.type ?? FailureCode.Application, opts.nonRetryable ?? false, opts.details ?? []);
|
||||
}
|
||||
|
||||
static nonRetryable(message: string, type?: string, ...details: unknown[]): ApplicationFailure {
|
||||
return new ApplicationFailure(message, type ?? FailureCode.Application, true, details);
|
||||
}
|
||||
|
||||
static retryable(message: string, type?: string, ...details: unknown[]): ApplicationFailure {
|
||||
return new ApplicationFailure(message, type ?? FailureCode.Application, false, details);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ActivityFailure — wraps the cause raised by a failed activity, mirroring
|
||||
* @temporalio's ActivityFailure. Workflow code pattern-matches
|
||||
* `err instanceof ActivityFailure && err.cause instanceof ApplicationFailure`,
|
||||
* so a proxied activity that fails rejects with an ActivityFailure whose
|
||||
* `cause` is the decoded ApplicationFailure.
|
||||
*/
|
||||
export class ActivityFailure extends TaskError {
|
||||
readonly activityType: string;
|
||||
|
||||
constructor(activityType: string, cause: TaskError) {
|
||||
super(`activity ${activityType} failed: ${cause.message}`, cause.code, cause.nonRetryable, cause.details, cause);
|
||||
this.name = "ActivityFailure";
|
||||
this.activityType = activityType;
|
||||
}
|
||||
}
|
||||
|
||||
/** Coerce a decoded TaskError to an ApplicationFailure (preserving cause). */
|
||||
export function asApplicationFailure(err: TaskError): ApplicationFailure {
|
||||
if (err instanceof ApplicationFailure) return err;
|
||||
return new ApplicationFailure(err.message, err.code, err.nonRetryable, err.details, err.cause);
|
||||
}
|
||||
|
||||
interface FailurePayload {
|
||||
message: string;
|
||||
code: string;
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
// Copyright © 2026 Hanzo AI. MIT License.
|
||||
//
|
||||
// Typed search attributes — the @temporalio/common surface social-orchestrator
|
||||
// imports (defineSearchAttributeKey / SearchAttributeType / TypedSearchAttributes).
|
||||
// On the Hanzo Tasks wire a workflow's search attributes are a flat
|
||||
// name → value record carried on StartWorkflowRequest.search_attributes and
|
||||
// stored on the execution (visibility-queryable via ListWorkflows). This module
|
||||
// is the typed builder that flattens to that record.
|
||||
|
||||
/** Attribute value types. Mirrors @temporalio/common's SearchAttributeType. */
|
||||
export enum SearchAttributeType {
|
||||
TEXT = "Text",
|
||||
KEYWORD = "Keyword",
|
||||
INT = "Int",
|
||||
DOUBLE = "Double",
|
||||
BOOL = "Bool",
|
||||
DATETIME = "Datetime",
|
||||
KEYWORD_LIST = "KeywordList",
|
||||
}
|
||||
|
||||
/** A typed key handle returned by defineSearchAttributeKey. */
|
||||
export interface SearchAttributeKey<_T = unknown> {
|
||||
readonly name: string;
|
||||
readonly type: SearchAttributeType;
|
||||
}
|
||||
|
||||
/** One (key,value) pair used to construct TypedSearchAttributes. */
|
||||
export interface SearchAttributePair<T = unknown> {
|
||||
key: SearchAttributeKey<T>;
|
||||
value: T;
|
||||
}
|
||||
|
||||
/** Define a typed search-attribute key (name + value type). */
|
||||
export function defineSearchAttributeKey<T = unknown>(
|
||||
name: string,
|
||||
type: SearchAttributeType,
|
||||
): SearchAttributeKey<T> {
|
||||
return { name, type };
|
||||
}
|
||||
|
||||
/**
|
||||
* An immutable typed collection of search attributes. Constructed from
|
||||
* (key,value) pairs; `toRecord()` flattens to the name→value record the wire
|
||||
* carries. Mirrors the minimal @temporalio/common TypedSearchAttributes
|
||||
* surface social depends on.
|
||||
*/
|
||||
export class TypedSearchAttributes {
|
||||
private readonly pairs: SearchAttributePair[];
|
||||
|
||||
constructor(pairs: SearchAttributePair[] = []) {
|
||||
this.pairs = pairs.slice();
|
||||
}
|
||||
|
||||
/** All (key,value) pairs. */
|
||||
getAll(): SearchAttributePair[] {
|
||||
return this.pairs.slice();
|
||||
}
|
||||
|
||||
/** Value for a key, or undefined. */
|
||||
get<T>(key: SearchAttributeKey<T>): T | undefined {
|
||||
const hit = this.pairs.find((p) => p.key.name === key.name);
|
||||
return hit ? (hit.value as T) : undefined;
|
||||
}
|
||||
|
||||
/** Flatten to the wire record { name: value }. */
|
||||
toRecord(): Record<string, unknown> {
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const p of this.pairs) out[p.key.name] = p.value;
|
||||
return out;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Coerce a start-option search-attribute input (either a plain record or a
|
||||
* TypedSearchAttributes) to the flat wire record. undefined passes through.
|
||||
*/
|
||||
export function toSearchAttributeRecord(
|
||||
input: Record<string, unknown> | TypedSearchAttributes | undefined,
|
||||
): Record<string, unknown> | undefined {
|
||||
if (input === undefined) return undefined;
|
||||
if (input instanceof TypedSearchAttributes) return input.toRecord();
|
||||
return input;
|
||||
}
|
||||
@@ -16,7 +16,7 @@ import { ZapNode, type Transport } from "./zap/node";
|
||||
import { TasksClient } from "./client/client";
|
||||
import { WorkflowHandle } from "./client/handle";
|
||||
import { toMs, type Duration } from "./common/duration";
|
||||
import { TaskError, FailureCode } from "./common/failure";
|
||||
import { TypedSearchAttributes, toSearchAttributeRecord } from "./common/search-attributes";
|
||||
import type { RetryPolicy, StartWorkflowOptions } from "./client/options";
|
||||
|
||||
function splitHostPort(addr: string): [string, number] {
|
||||
@@ -66,6 +66,10 @@ export interface WorkflowStartOptions {
|
||||
cronSchedule?: string;
|
||||
memo?: Record<string, unknown>;
|
||||
searchAttributes?: Record<string, unknown>;
|
||||
/** Typed search attributes (@temporalio TypedSearchAttributes) — flattened to the wire record. */
|
||||
typedSearchAttributes?: TypedSearchAttributes | Record<string, unknown>;
|
||||
/** Reuse policy for an existing run under workflowId: USE_EXISTING | TERMINATE_EXISTING | FAIL. */
|
||||
workflowIdConflictPolicy?: string;
|
||||
}
|
||||
|
||||
function toRetryPolicy(r?: CompatRetry): RetryPolicy | undefined {
|
||||
@@ -80,6 +84,10 @@ function toRetryPolicy(r?: CompatRetry): RetryPolicy | undefined {
|
||||
}
|
||||
|
||||
function toStartOptions(o: WorkflowStartOptions): StartWorkflowOptions {
|
||||
// typedSearchAttributes wins when both are present; both flatten to a
|
||||
// name→value record on the wire.
|
||||
const searchAttributes =
|
||||
toSearchAttributeRecord(o.typedSearchAttributes) ?? o.searchAttributes;
|
||||
return {
|
||||
taskQueue: o.taskQueue,
|
||||
workflowId: o.workflowId,
|
||||
@@ -90,7 +98,8 @@ function toStartOptions(o: WorkflowStartOptions): StartWorkflowOptions {
|
||||
retryPolicy: toRetryPolicy(o.retry),
|
||||
cronSchedule: o.cronSchedule,
|
||||
memo: o.memo,
|
||||
searchAttributes: o.searchAttributes,
|
||||
searchAttributes,
|
||||
workflowIdConflictPolicy: o.workflowIdConflictPolicy,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -156,28 +165,6 @@ export class Client {
|
||||
// WorkerOptions already accepts `connection`, `address`, or `transport`, so
|
||||
// `Worker.create({ connection, namespace, taskQueue, workflows, activities })`
|
||||
// works exactly like @temporalio/worker.
|
||||
|
||||
/** Mirrors @temporalio/common's ApplicationFailure. */
|
||||
export class ApplicationFailure extends TaskError {
|
||||
static create(opts: {
|
||||
message: string;
|
||||
type?: string;
|
||||
nonRetryable?: boolean;
|
||||
details?: unknown[];
|
||||
}): ApplicationFailure {
|
||||
return new ApplicationFailure(
|
||||
opts.message,
|
||||
opts.type ?? FailureCode.Application,
|
||||
opts.nonRetryable ?? false,
|
||||
opts.details ?? [],
|
||||
);
|
||||
}
|
||||
|
||||
static nonRetryable(message: string, type?: string, ...details: unknown[]): ApplicationFailure {
|
||||
return new ApplicationFailure(message, type ?? FailureCode.Application, true, details);
|
||||
}
|
||||
|
||||
static retryable(message: string, type?: string, ...details: unknown[]): ApplicationFailure {
|
||||
return new ApplicationFailure(message, type ?? FailureCode.Application, false, details);
|
||||
}
|
||||
}
|
||||
//
|
||||
// ApplicationFailure + ActivityFailure are the canonical @temporalio/common
|
||||
// error types; they live in ./common/failure and are re-exported by index.ts.
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
export * from "./common/failure";
|
||||
export * from "./common/converter";
|
||||
export * from "./common/duration";
|
||||
export * from "./common/search-attributes";
|
||||
|
||||
export * from "./client";
|
||||
export * from "./worker";
|
||||
|
||||
@@ -18,6 +18,8 @@ export const EventType = {
|
||||
ActivityCompleted: "ACTIVITY_TASK_COMPLETED",
|
||||
ActivityFailed: "ACTIVITY_TASK_FAILED",
|
||||
WorkflowSignaled: "WORKFLOW_EXECUTION_SIGNALED",
|
||||
WorkflowContinuedAsNew: "WORKFLOW_EXECUTION_CONTINUED_AS_NEW",
|
||||
ChildWorkflowStarted: "CHILD_WORKFLOW_EXECUTION_STARTED",
|
||||
} as const;
|
||||
|
||||
export interface HistoryEvent {
|
||||
@@ -31,6 +33,9 @@ export const CommandKind = {
|
||||
CompleteWorkflow: 0,
|
||||
FailWorkflow: 1,
|
||||
ScheduleActivity: 2,
|
||||
// 3 = canceled (worker cancel-ack) — not emitted by the TS decider.
|
||||
ContinueAsNew: 4,
|
||||
StartChildWorkflow: 5,
|
||||
} as const;
|
||||
|
||||
export interface RawCommand {
|
||||
@@ -39,8 +44,11 @@ export interface RawCommand {
|
||||
failure?: string; // base64
|
||||
seq?: number;
|
||||
activityType?: string;
|
||||
workflowType?: string; // kind=4 continueAsNew (successor type) / kind=5 startChild (child type)
|
||||
childWorkflowId?: string; // kind=5 startChild
|
||||
input?: string; // base64
|
||||
taskQueue?: string;
|
||||
searchAttrs?: Record<string, unknown>; // kind=5 startChild
|
||||
startToCloseMs?: number;
|
||||
heartbeatMs?: number;
|
||||
retryPolicy?: {
|
||||
@@ -120,6 +128,38 @@ export function scheduleActivityCommand(spec: ScheduleActivitySpec): RawCommand
|
||||
};
|
||||
}
|
||||
|
||||
/** continueAsNew command (kind=4): carries the successor run's arguments. */
|
||||
export function continueAsNewCommand(args: unknown[], workflowType?: string, taskQueue?: string): RawCommand {
|
||||
return {
|
||||
kind: CommandKind.ContinueAsNew,
|
||||
input: Buffer.from(JSON.stringify(args ?? []), "utf8").toString("base64"),
|
||||
workflowType: workflowType || undefined,
|
||||
taskQueue: taskQueue || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export interface StartChildSpec {
|
||||
seq: number;
|
||||
workflowType: string;
|
||||
workflowId?: string;
|
||||
args: unknown[];
|
||||
taskQueue?: string;
|
||||
searchAttributes?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** startChild command (kind=5): a detached (ABANDON) child, seq-keyed. */
|
||||
export function startChildCommand(spec: StartChildSpec): RawCommand {
|
||||
return {
|
||||
kind: CommandKind.StartChildWorkflow,
|
||||
seq: spec.seq,
|
||||
workflowType: spec.workflowType,
|
||||
childWorkflowId: spec.workflowId || undefined,
|
||||
input: Buffer.from(JSON.stringify(spec.args ?? []), "utf8").toString("base64"),
|
||||
taskQueue: spec.taskQueue || undefined,
|
||||
searchAttrs: spec.searchAttributes,
|
||||
};
|
||||
}
|
||||
|
||||
export function encodeCommands(cmds: RawCommand[]): Buffer {
|
||||
const env: CommandsEnvelope = { v: 1, cmds };
|
||||
return Buffer.from(JSON.stringify(env), "utf8");
|
||||
|
||||
@@ -7,11 +7,13 @@
|
||||
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { toMs, type Duration } from "../common/duration";
|
||||
import { TypedSearchAttributes } from "../common/search-attributes";
|
||||
import {
|
||||
currentContext,
|
||||
ContinueAsNewNotSupported,
|
||||
type ActivityOptions,
|
||||
type WorkflowInfo,
|
||||
type ChildWorkflowHandle,
|
||||
} from "./runtime";
|
||||
|
||||
export {
|
||||
@@ -19,6 +21,7 @@ export {
|
||||
type ActivityOptions,
|
||||
type WorkflowInfo,
|
||||
type DeciderContext,
|
||||
type ChildWorkflowHandle,
|
||||
} from "./runtime";
|
||||
|
||||
// ── activities ──
|
||||
@@ -150,13 +153,57 @@ export function upsertSearchAttributes(attrs: Record<string, unknown>): void {
|
||||
}
|
||||
|
||||
/**
|
||||
* continueAsNew — API-present, not yet durable on the v1 wire (no server
|
||||
* command). Throws ContinueAsNewNotSupported at runtime. See README.
|
||||
* continueAsNew — close the current run and start a fresh run of the same
|
||||
* workflow type with `args`. Durable on the Hanzo Tasks wire (ContinueAsNew
|
||||
* command, kind=4): the server records WORKFLOW_EXECUTION_CONTINUED_AS_NEW and
|
||||
* starts the successor run. The returned promise never resolves — the run
|
||||
* ends here.
|
||||
*/
|
||||
export function continueAsNew<_F extends (...args: any[]) => any>(..._args: unknown[]): Promise<never> {
|
||||
return Promise.reject(new ContinueAsNewNotSupported(currentContext().info.workflowType));
|
||||
export function continueAsNew<_F extends (...args: any[]) => any>(...args: unknown[]): Promise<never> {
|
||||
return currentContext().continueAsNew(args);
|
||||
}
|
||||
|
||||
// ── child workflows ──
|
||||
|
||||
export interface ChildWorkflowOptions {
|
||||
workflowId?: string;
|
||||
taskQueue?: string;
|
||||
args?: unknown[];
|
||||
/** Only ABANDON is supported: the child is an independent run. */
|
||||
parentClosePolicy?: string;
|
||||
searchAttributes?: Record<string, unknown>;
|
||||
typedSearchAttributes?: TypedSearchAttributes | Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* startChild — start a detached child workflow (parentClosePolicy ABANDON:
|
||||
* the child is an independent top-level run the parent does not await). Durable
|
||||
* on the wire (startChild command, kind=5): the server starts the child and
|
||||
* records CHILD_WORKFLOW_EXECUTION_STARTED on the parent. Resolves with the
|
||||
* child's identity once it is started.
|
||||
*/
|
||||
export function startChild(
|
||||
workflow: string | ((...a: any[]) => any),
|
||||
opts: ChildWorkflowOptions = {},
|
||||
): Promise<ChildWorkflowHandle> {
|
||||
const workflowType = typeof workflow === "string" ? workflow : workflow.name;
|
||||
if (!workflowType) {
|
||||
throw new Error("hanzo/tasks: startChild cannot resolve the child workflow type (anonymous function)");
|
||||
}
|
||||
return currentContext().startChild(workflowType, opts.args ?? [], {
|
||||
workflowId: opts.workflowId,
|
||||
taskQueue: opts.taskQueue,
|
||||
searchAttributes: opts.typedSearchAttributes ?? opts.searchAttributes,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* executeChild — ABANDON-child alias of startChild. The Hanzo Tasks child is
|
||||
* detached, so there is no durable "await child result" path; this resolves on
|
||||
* the child's START, like startChild. Provided for @temporalio API surface.
|
||||
*/
|
||||
export const executeChild = startChild;
|
||||
|
||||
/** Deterministic-friendly UUID (delegates to crypto; document determinism). */
|
||||
export function uuid4(): string {
|
||||
return randomUUID();
|
||||
|
||||
@@ -21,15 +21,18 @@
|
||||
// setTimeout, or perform I/O directly — use sleep()/activities/side-effects.
|
||||
|
||||
import { AsyncLocalStorage } from "node:async_hooks";
|
||||
import { TaskError } from "../common/failure";
|
||||
import { TaskError, ActivityFailure, asApplicationFailure } from "../common/failure";
|
||||
import {
|
||||
EventType,
|
||||
eventSeq,
|
||||
scheduleActivityCommand,
|
||||
continueAsNewCommand,
|
||||
startChildCommand,
|
||||
type HistoryEvent,
|
||||
type RawCommand,
|
||||
} from "../worker/history";
|
||||
import { decodeFailure } from "../common/failure";
|
||||
import { toSearchAttributeRecord, TypedSearchAttributes } from "../common/search-attributes";
|
||||
|
||||
export interface WorkflowInfo {
|
||||
workflowId: string;
|
||||
@@ -91,6 +94,8 @@ export class DeciderContext {
|
||||
/** Registered signal handlers; a matching name drains buffered payloads. */
|
||||
private readonly signalHandlers = new Map<string, (arg: unknown) => void>();
|
||||
readonly queryHandlers = new Map<string, (...args: unknown[]) => unknown>();
|
||||
/** startChild seqs already CHILD_WORKFLOW_EXECUTION_STARTED in history. */
|
||||
private readonly childStartedBySeq = new Map<number, { workflowId: string; runId: string }>();
|
||||
|
||||
/** Local, non-durable (mirrors Go: no wire command yet). */
|
||||
readonly searchAttributes: Record<string, unknown> = {};
|
||||
@@ -104,7 +109,9 @@ export class DeciderContext {
|
||||
loadHistory(events: HistoryEvent[]): void {
|
||||
for (const ev of events) {
|
||||
if (ev.eventType === EventType.WorkflowSignaled) {
|
||||
const name = String(ev.attributes?.["signal"] ?? "");
|
||||
// The engine records the signal name under `signalName` (see
|
||||
// engine.signalWorkflow); tolerate a legacy `signal` alias.
|
||||
const name = String(ev.attributes?.["signalName"] ?? ev.attributes?.["signal"] ?? "");
|
||||
if (name) {
|
||||
const arr = this.signalsByName.get(name) ?? [];
|
||||
arr.push(ev.attributes?.["input"]);
|
||||
@@ -127,6 +134,12 @@ export class DeciderContext {
|
||||
this.activityResults.set(seq, { failed: true, failure });
|
||||
break;
|
||||
}
|
||||
case EventType.ChildWorkflowStarted:
|
||||
this.childStartedBySeq.set(seq, {
|
||||
workflowId: String(ev.attributes?.["childWorkflowId"] ?? ""),
|
||||
runId: String(ev.attributes?.["childRunId"] ?? ""),
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -139,9 +152,15 @@ export class DeciderContext {
|
||||
const seq = this.nextSeq++;
|
||||
const outcome = this.activityResults.get(seq);
|
||||
if (outcome) {
|
||||
return outcome.failed
|
||||
? Promise.reject(outcome.failure ?? new TaskError("activity failed", "ApplicationError", true))
|
||||
: Promise.resolve(outcome.result);
|
||||
if (!outcome.failed) return Promise.resolve(outcome.result);
|
||||
// Present the failure the @temporalio way: an ActivityFailure whose
|
||||
// `cause` is the ApplicationFailure the activity raised — the shape
|
||||
// workflow code pattern-matches (err.cause.type === "refresh_token").
|
||||
const cause = asApplicationFailure(outcome.failure ?? new TaskError("activity failed", "ApplicationError", true));
|
||||
// The internal timer activity is an implementation detail: surface its
|
||||
// failure directly rather than wrapping it as an ActivityFailure.
|
||||
if (activityType === TIMER_ACTIVITY_TYPE) return Promise.reject(cause);
|
||||
return Promise.reject(new ActivityFailure(activityType, cause));
|
||||
}
|
||||
if (!this.scheduledSeqs.has(seq)) {
|
||||
this.newCommands.push(
|
||||
@@ -213,6 +232,53 @@ export class DeciderContext {
|
||||
upsertSearchAttributes(attrs: Record<string, unknown>): void {
|
||||
Object.assign(this.searchAttributes, attrs);
|
||||
}
|
||||
|
||||
/**
|
||||
* continueAsNew — close this run and start a successor of the same type
|
||||
* with fresh arguments. Emits a continueAsNew command and parks: the
|
||||
* function never settles, the episode ends carrying only that command, and
|
||||
* the server closes the run (CONTINUED_AS_NEW) and starts the successor.
|
||||
*/
|
||||
continueAsNew(args: unknown[]): Promise<never> {
|
||||
this.newCommands.push(continueAsNewCommand(args, this.info.workflowType, this.info.taskQueue));
|
||||
return parkForever();
|
||||
}
|
||||
|
||||
/**
|
||||
* startChild — start a detached (ABANDON) child workflow. seq-keyed and
|
||||
* durable: resolves from history once CHILD_WORKFLOW_EXECUTION_STARTED{seq}
|
||||
* lands, otherwise emits a startChild command and parks. Resolves with the
|
||||
* child's identity (the parent does not await the child's result).
|
||||
*/
|
||||
startChild(
|
||||
workflowType: string,
|
||||
args: unknown[],
|
||||
opts: { workflowId?: string; taskQueue?: string; searchAttributes?: Record<string, unknown> | TypedSearchAttributes } = {},
|
||||
): Promise<ChildWorkflowHandle> {
|
||||
const seq = this.nextSeq++;
|
||||
const started = this.childStartedBySeq.get(seq);
|
||||
if (started) {
|
||||
return Promise.resolve({ workflowId: started.workflowId, runId: started.runId, firstExecutionRunId: started.runId });
|
||||
}
|
||||
this.newCommands.push(
|
||||
startChildCommand({
|
||||
seq,
|
||||
workflowType,
|
||||
workflowId: opts.workflowId,
|
||||
args,
|
||||
taskQueue: opts.taskQueue ?? this.info.taskQueue,
|
||||
searchAttributes: toSearchAttributeRecord(opts.searchAttributes),
|
||||
}),
|
||||
);
|
||||
return parkForever();
|
||||
}
|
||||
}
|
||||
|
||||
/** Minimal child-workflow handle returned by startChild (ABANDON). */
|
||||
export interface ChildWorkflowHandle {
|
||||
workflowId: string;
|
||||
runId: string;
|
||||
firstExecutionRunId: string;
|
||||
}
|
||||
|
||||
/** A promise that never settles — parks the async workflow on suspension. */
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
// Copyright © 2026 Hanzo AI. MIT License.
|
||||
//
|
||||
// End-to-end proof of the durable primitives social-orchestrator depends on —
|
||||
// signal-to-running-workflow, continueAsNew, startChild, and typed search
|
||||
// attributes — run through the SAME TS worker social uses, against a real
|
||||
// tasksd/embedded engine. Skipped unless TASKS_ZAP_ADDR is set:
|
||||
//
|
||||
// go build -o /tmp/tasksd ./cmd/tasksd && /tmp/tasksd --allow-no-auth start &
|
||||
// TASKS_ZAP_ADDR=127.0.0.1:9999 npx vitest run test/durable-primitives.integration.test.ts
|
||||
|
||||
import { describe, it, expect, afterAll } from "vitest";
|
||||
import { TasksClient } from "../src/client";
|
||||
import { Worker } from "../src/worker";
|
||||
import {
|
||||
proxyActivities,
|
||||
condition,
|
||||
continueAsNew,
|
||||
startChild,
|
||||
defineSignal,
|
||||
setHandler,
|
||||
} from "../src/workflow";
|
||||
import { WorkflowStatus } from "../src/client/options";
|
||||
|
||||
const ADDR = process.env.TASKS_ZAP_ADDR;
|
||||
const NS = process.env.TASKS_NAMESPACE ?? "default";
|
||||
const suite = ADDR ? describe : describe.skip;
|
||||
|
||||
const itemSignal = defineSignal<[string]>("item");
|
||||
|
||||
const activities = {
|
||||
echo: async (s: string) => `echo:${s}`,
|
||||
record: async (_round: number, _batch: string[]) => true,
|
||||
};
|
||||
|
||||
// digest-like: signal-populated queue + continueAsNew across rounds. Mirrors
|
||||
// social's digestEmailWorkflow / sendEmailWorkflow shape.
|
||||
async function digestLike({ round = 1, seen = [] }: { round?: number; seen?: string[] }): Promise<string> {
|
||||
const acts = proxyActivities<typeof activities>({ startToCloseTimeout: "10s" });
|
||||
const queue: string[] = [];
|
||||
setHandler(itemSignal, (item: string) => {
|
||||
queue.push(item);
|
||||
});
|
||||
await condition(() => queue.length > 0);
|
||||
const batch = queue.splice(0, queue.length);
|
||||
await acts.record(round, batch);
|
||||
const all = [...seen, ...batch];
|
||||
if (round >= 2) return all.join(",");
|
||||
return await continueAsNew({ round: round + 1, seen: all });
|
||||
}
|
||||
|
||||
async function childWf({ tag }: { tag: string }): Promise<string> {
|
||||
const acts = proxyActivities<typeof activities>({ startToCloseTimeout: "10s" });
|
||||
return acts.echo(tag);
|
||||
}
|
||||
|
||||
// parent: starts a detached (ABANDON) child, then completes independently.
|
||||
async function parentWf({ childId }: { childId: string }): Promise<string> {
|
||||
await startChild(childWf, { parentClosePolicy: "ABANDON", workflowId: childId, args: [{ tag: "kid" }] });
|
||||
return "parent-done";
|
||||
}
|
||||
|
||||
// noop: completes immediately (used for search-attr / conflict-policy checks).
|
||||
async function noop(): Promise<string> {
|
||||
return "ok";
|
||||
}
|
||||
|
||||
async function waitStatus(client: TasksClient, workflowId: string, runId: string, want: WorkflowStatus, ms = 30000): Promise<WorkflowStatus> {
|
||||
const deadline = Date.now() + ms;
|
||||
let status = WorkflowStatus.Running;
|
||||
while (Date.now() < deadline) {
|
||||
const info = await client.describeWorkflow(workflowId, runId);
|
||||
status = info.status;
|
||||
if (status === want) return status;
|
||||
if (status !== WorkflowStatus.Running && status !== WorkflowStatus.ContinuedAsNew) break;
|
||||
await new Promise((r) => setTimeout(r, 200));
|
||||
}
|
||||
return status;
|
||||
}
|
||||
|
||||
suite("durable primitives — live tasksd", () => {
|
||||
const cleanup: Array<() => Promise<void>> = [];
|
||||
afterAll(async () => {
|
||||
for (const fn of cleanup) await fn().catch(() => {});
|
||||
});
|
||||
|
||||
async function startWorker(tq: string): Promise<Worker> {
|
||||
const worker = await Worker.create({
|
||||
address: ADDR!,
|
||||
namespace: NS,
|
||||
taskQueue: tq,
|
||||
activities,
|
||||
workflows: { digestLike, childWf, parentWf, noop },
|
||||
});
|
||||
await worker.start();
|
||||
cleanup.push(() => worker.shutdown());
|
||||
return worker;
|
||||
}
|
||||
|
||||
it("delivers signals to a running workflow across continueAsNew", async () => {
|
||||
const tq = `dp-digest-${Date.now()}`;
|
||||
await startWorker(tq);
|
||||
const client = await TasksClient.connect({ address: ADDR!, namespace: NS });
|
||||
cleanup.push(() => client.close());
|
||||
|
||||
const wfId = `digest-${Date.now()}`;
|
||||
// Start + first signal (round 1).
|
||||
const handle = await client.signalWithStart("digestLike", "item", "a", {
|
||||
taskQueue: tq,
|
||||
workflowId: wfId,
|
||||
args: [{ round: 1, seen: [] }],
|
||||
});
|
||||
const round1Run = handle.runId;
|
||||
|
||||
// Wait for round-1 to process + continueAsNew into a fresh RUNNING run.
|
||||
let successorRun = "";
|
||||
const deadline = Date.now() + 20000;
|
||||
while (Date.now() < deadline) {
|
||||
const info = await client.describeWorkflow(wfId, "");
|
||||
if (info.runId && info.runId !== round1Run && info.status === WorkflowStatus.Running) {
|
||||
successorRun = info.runId;
|
||||
break;
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 200));
|
||||
}
|
||||
expect(successorRun).not.toBe("");
|
||||
|
||||
// Signal the running successor (round 2) — proves signal-to-running-wf.
|
||||
await client.signalWorkflow(wfId, "", "item", "b");
|
||||
|
||||
// The workflow returns "a,b" and COMPLETES.
|
||||
const status = await waitStatus(client, wfId, "", WorkflowStatus.Completed);
|
||||
expect(status).toBe(WorkflowStatus.Completed);
|
||||
});
|
||||
|
||||
it("starts a detached child workflow (startChild / ABANDON)", async () => {
|
||||
const tq = `dp-child-${Date.now()}`;
|
||||
await startWorker(tq);
|
||||
const client = await TasksClient.connect({ address: ADDR!, namespace: NS });
|
||||
cleanup.push(() => client.close());
|
||||
|
||||
const childId = `child-${Date.now()}`;
|
||||
const parent = await client.startWorkflow("parentWf", {
|
||||
taskQueue: tq,
|
||||
workflowId: `parent-${Date.now()}`,
|
||||
args: [{ childId }],
|
||||
});
|
||||
|
||||
const parentStatus = await waitStatus(client, parent.workflowId, parent.runId, WorkflowStatus.Completed);
|
||||
expect(parentStatus).toBe(WorkflowStatus.Completed);
|
||||
|
||||
// The detached child runs independently to completion.
|
||||
const childStatus = await waitStatus(client, childId, "", WorkflowStatus.Completed);
|
||||
expect(childStatus).toBe(WorkflowStatus.Completed);
|
||||
});
|
||||
|
||||
it("carries typed search attributes + honors TERMINATE_EXISTING", async () => {
|
||||
const tq = `dp-sa-${Date.now()}`;
|
||||
await startWorker(tq);
|
||||
const client = await TasksClient.connect({ address: ADDR!, namespace: NS });
|
||||
cleanup.push(() => client.close());
|
||||
|
||||
const postId = `sa-${Date.now()}`;
|
||||
const wfId = `post-${postId}`;
|
||||
await client.startWorkflow("noop", {
|
||||
taskQueue: tq,
|
||||
workflowId: wfId,
|
||||
args: [],
|
||||
searchAttributes: { postId, organizationId: "org-e2e" },
|
||||
});
|
||||
|
||||
// Visibility query by the custom attribute finds the run.
|
||||
const deadline = Date.now() + 10000;
|
||||
let found = 0;
|
||||
while (Date.now() < deadline) {
|
||||
const res = await client.listWorkflows(`postId = "${postId}"`);
|
||||
found = res.executions.length;
|
||||
if (found >= 1) break;
|
||||
await new Promise((r) => setTimeout(r, 200));
|
||||
}
|
||||
expect(found).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
});
|
||||
@@ -96,7 +96,13 @@ describe("workflow decider — event-sourced replay produces exact wire commands
|
||||
expect(cmds).toHaveLength(1);
|
||||
expect(cmds[0].kind).toBe(CommandKind.FailWorkflow);
|
||||
const failure = JSON.parse(Buffer.from(cmds[0].failure!, "base64").toString("utf8"));
|
||||
expect(failure.p.message).toBe("kaboom");
|
||||
// The activity error is presented the @temporalio way: an ActivityFailure
|
||||
// whose `cause` is the underlying ApplicationFailure. Workflow code that
|
||||
// does not catch it fails the workflow with that wrapped error, so the raw
|
||||
// activity message survives on the cause and the classifier (code) is kept.
|
||||
expect(failure.p.message).toContain("kaboom");
|
||||
expect(failure.p.code).toBe("ApplicationError");
|
||||
expect(failure.p.cause.message).toBe("kaboom");
|
||||
});
|
||||
|
||||
it("schedules a durable timer via the internal sleeper activity", async () => {
|
||||
|
||||
Reference in New Issue
Block a user