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:
2026-07-04 21:30:09 -07:00
parent 5a5cf590de
commit a60626ae9e
18 changed files with 1019 additions and 123 deletions
+37 -20
View File
@@ -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
View File
@@ -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) {
+139
View File
@@ -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
}
+167
View File
@@ -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
View File
@@ -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
View File
@@ -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 -1
View File
@@ -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",
+3
View File
@@ -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,
+9 -4
View File
@@ -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 {
+53
View File
@@ -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;
}
+14 -27
View File
@@ -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.
+1
View File
@@ -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";
+40
View File
@@ -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");
+51 -4
View File
@@ -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();
+71 -5
View File
@@ -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);
});
});
+7 -1
View File
@@ -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 () => {