xworkmate-app/docs/architecture/chain-map-artifact-lifecycle.md

14 KiB

Chain Map: Artifact Lifecycle (Prepare → Export → Read → Download)

Repo chain: openclaw-multi-session-plugins ↔ xworkmate-bridge ↔ xworkmate-app

Lifecycle States

[prepare] → [execute] → [collect-and-snapshot] → [export] → [snapshot] → [download] → [sync]

  prepare:  map session + mkdir tasks/<session>/<run>/ (multi-session-plugins)
  execute:  tools write files                      (openclaw.svc.plus)
  collect:  copy media/tmp outputs into task scope (multi-session-plugins)
  export:   scan + manifest + sign                 (multi-session-plugins)
  snapshot: assemble terminal result               (xworkmate-bridge)
  download: signed URL proxy                       (xworkmate-bridge)
  sync:     save to ~/.xworkmate/threads/<session>/ (xworkmate-app)

App terminal rule:

  • completed, failed, cancelled, and canceled snapshots end task execution immediately.
  • Artifact presence controls only lastArtifactSyncStatus; it is not a reason to keep lifecycleStatus=running.

State 1: Prepare

Caller:   xworkmate-bridge → gateway.request('xworkmate.session.prepare')
Handler:  openclaw-multi-session-plugins → recordXWorkmateSessionMapping() + prepareXWorkmateArtifacts()

Inputs:
  schemaVersion:       1
  appThreadKey:        string    // "draft:1780658097668838-1"
  openclawSessionKey:  string    // "agent:main:draft:1780658097668838-1"
  runId:               string    // "20260605-001"
  expectedArtifactDirs?: string[]
  workspaceDir?:       string

Process:
  1. Validate typed mapping metadata.

  2. Patch SessionEntry.pluginExtensions:
     ["openclaw-multi-session-plugins"]["xworkmate.sessionMapping"]
     → schemaVersion, appThreadKey, openclawSessionKey, expectedArtifactDirs

  3. resolveWorkspaceDir({ openclawSessionKey, params, pluginConfig, config })
     → Falls back through: explicit → pluginConfig → agent config
       → profile env → ~/.openclaw/workspace
     → Bridge must pass only a real OpenClaw workspace root here. App/owner
       scoped hints such as `/owners/...` are UI/sync references, not plugin
       workspace roots, and must fall back to the managed OpenClaw workspace.

  4. safeScopeSegment(openclawSessionKey)
     → replace [/\\:*?"<>|] with "-", truncate to 96 chars

  5. safeScopeSegment(runId) → same rules

  6. scopeRoot = <workspace>/tasks/<safeSessionKey>/<safeRunId>/
     → fs.mkdir(scopeRoot, { recursive: true })

  7. Validate: isWithinRoot(workspaceRoot, scopeRoot)

Output:
  artifactScope:  "tasks/<safeSessionKey>/<safeRunId>/"
  artifactDirectory: "<workspace>/tasks/<safeSessionKey>/<safeRunId>/"
  mapping: { appThreadKey, openclawSessionKey, expectedArtifactDirs }

Fragile:
  - workspace resolution chain has 5 ordered sources
  - `remoteWorkingDirectoryHint` may be an app owner-scoped reference; using it
    as `workspaceDir` causes plugin `realpath(workspaceDir)` failures before
    `tasks/<session>/<run>/` can be created
  - session key format must match across bridge and plugin
  - no cleanup of old scope directories

State 2: Execute (GAP)

During agent turn execution, OpenClaw tools write output files.
These go to various locations depending on the tool:

Tool: browser (screenshot / download)
  → screenshot: saveMediaBuffer(buffer, "image/png", "browser")
    → ~/.openclaw/media/browser/<uuid>.png
  → download:  writeExternalFileWithinOutputRoot()
    → requested download path (bounded by output root security)
  → uploads:   /tmp/openclaw/uploads/<file>

Tool: image-generation
  → Returns GeneratedImageAsset { buffer, mimeType } (in-memory)
  → Agent must explicitly write buffer to disk
    → Usually saveMediaBuffer(buffer, mimeType, "browser")
      → ~/.openclaw/media/browser/<uuid>.png

Tool: video-generation / rendering
  → Output to media/ outbound or /tmp/ rendering workspace

Tool: file-write (agent writes files explicitly)
  → May write to workingDirectory (pointed at tasks/<session>/<run>/)
  → OR may write to ~/.openclaw/agents/<id>/workspace/

═══════════════════════════════════════════════════════════
CRITICAL: None of these paths are inside tasks/<session>/<run>/
unless the agent explicitly directs output there.

The artifact export below will NOT find these files.
═══════════════════════════════════════════════════════════

State 3: Collect And Snapshot

Caller:   xworkmate-bridge → gateway.request('xworkmate.artifacts.collect-and-snapshot')
Handler:  openclaw-multi-session-plugins → collectAndSnapshotXWorkmateArtifacts()

Inputs:
  openclawSessionKey: mapped OpenClaw session key
  runId:         OpenClaw run id
  artifactScope: tasks/<session>/<run>/
  sinceUnixMs:   task start timestamp

Process:
  1. Validate artifactScope matches openclawSessionKey/runId.
  2. Scan fixed OpenClaw output roots:
     - ~/.openclaw/media/
     - /tmp/openclaw/
  3. Copy changed regular files into:
     - tasks/<session>/<run>/artifacts/media/...
     - tasks/<session>/<run>/artifacts/tmp-openclaw/...
  4. Skip symlinks and any path that escapes the fixed source roots.

Output:
  copiedFiles: relative paths under the current task scope
  warnings: skipped paths or unavailable source roots

State 4: Export

Caller:   xworkmate-bridge → gateway.request('xworkmate.artifacts.export')
Handler:  openclaw-multi-session-plugins → exportXWorkmateArtifacts()

Inputs:
  artifactScope: "tasks/<session>/<run>/"
  openclawSessionKey?: string
  workspaceDir?: string
  artifactRef?:   string   // alternative: read single artifact
  maxFiles?:      number   // default: 200
  maxInlineBytes?: number  // default: 512KB, files larger are omitted
  expectedArtifactDirs?: string[] // from typed mapping/session.prepare only

Process:
  1. resolveScopeRoot(workspaceRoot, artifactScope)
     → <workspace>/tasks/<session>/<run>/
     → Validate isWithinRoot()

  2. collectCandidates(scopeRoot)
     → Recursive walk of ONLY tasks/<session>/<run>/
     → Skip: .git, .openclaw, .xworkmate, .pi, .dart_tool,
             .next, .turbo, node_modules
     → Skip: symlinks (security measure)
     → Apply: artifact-ignore.md rules

  2b. If the task scope has no candidates and `expectedArtifactDirs` is present:
     → Scan only those explicit workspace-root subdirectories
     → Keep exported entries bound to the current task artifactScope
     → Do not scan the workspace root broadly and do not borrow older task scopes

Protocol boundary:
  `expectedArtifactDirs` is bridge artifact-contract data, not agent execution
  data. Bridge must not put it in `chat.send` params. Bridge must not probe old
  root-level, metadata-root, prompt-text, or `sessionKey` compatibility keys.

  3. For each file under maxFiles limit:
     → Read content (up to maxInlineBytes)
     → Compute SHA-256 hash
     → Determine content-type from extension

  4. Build artifact manifest:
     {
       scope: "tasks/<session>/<run>/",
       sessionKey: "<sessionKey>",
       runId: "<runId>",
       totalCandidates: <N>,
       files: [
         { relativePath, displayPath, size, contentType, sha256, inline }
       ]
     }

  5. Generate signed refs:
     → signArtifactRef(sessionKey, runId, relativePath)
     → HMAC-SHA256(signingSecret, "<key>::<run>::<path>")
     → Valid for 24h

Output:
  manifest:  { scope, sessionKey, runId, totalCandidates, files[] }
  Each file: { relativePath, displayPath, size, contentType, sha256, inline?, ref }

Fragile:
  - Export only scans tasks/<session>/<run>/; collect-and-snapshot must run first for global tool outputs
  - symlinks rejected even if pointing within workspace
  - maxFiles=200, maxInlineBytes=512KB — large files silently omitted
  - signing secret rotation invalidates all existing refs

State 5: Native Task Lookup And Artifact Snapshot

Caller:   App polling/recovery via xworkmate.tasks.get
          Bridge forwards typed appThreadKey/openclawSessionKey/runId to Plugin

Process:
  1. Plugin resolves task state from OpenClaw native task-registry
     → api.runtime.tasks.runs.bindSession({sessionKey: openclawSessionKey})
     → resolve(runId) or findLatest()

  2. Call gateway.request('xworkmate.artifacts.collect-and-snapshot')
     → Copy OpenClaw media/tmp outputs into the task scope

  3. Call gateway.request('xworkmate.artifacts.export')
     → Get manifest from plugin

  4. openClawArtifactExport()
     → Transform manifest files into stable result shape
     → decorateOpenClawArtifactDownloadURLs()
       → Replace each file.ref with signed download URL:
         /artifacts/openclaw/download?ref=<signed>&t=<expiry>

  5. Return task-registry-backed snapshot:
     → Terminal success/failure is not decided by Bridge state
     → Missing native task record returns no_native_task_record
     {
       success: true,
       status: "completed",
       sessionId: "<id>",
       threadId: "<id>",
       turnId: "<id>",
       runId: "<id>",
       text: "<agent final output>",
       artifacts: {
         items: [{ path, url, sha256, size, contentType }],
         scope: "tasks/<session>/<run>/"
       }
     }

  5. Do not store terminal task truth for xworkmate.tasks.get queries.
     The query path forwards to OpenClaw native task-registry through the plugin.

  6. Send SSE session.update to app

Removed compatibility paths:
  - Bridge no longer falls back when xworkmate.session.prepare is unsupported.
  - Bridge no longer reassociates OpenClaw tasks from artifactScope/runId.
  - Bridge no longer treats artifact export as terminal task-state evidence.

Fragile:
  - If export returns empty manifest, snapshot has no artifacts
  - Artifact download URLs expire after 24h
  - Snapshot stored only in memory (lost on bridge restart)
  - App execution state must still transition to ready for any terminal
    snapshot. Empty or incomplete artifact manifests update only artifact sync
    status; they must not keep the task lifecycle running.

State 6: Download (Bridge Proxy)

Endpoint: GET /artifacts/openclaw/download?ref=<signed>&t=<expiry>

Handler: HandleOpenClawArtifactDownload() in openclaw_artifact_download.go

Process:
  1. Parse signed ref: <sessionKey>::<runId>::<relativePath>::<hmac>
  2. Verify HMAC signature against signing secret
  3. Check expiry (24h TTL, t=<unixSeconds>)
  4. Resolve artifact:
     → Build artifactScope: tasks/<session>/<run>/
     → Call gateway.request('xworkmate.artifacts.read',
          { artifactScope, relativePath })
     → Up to 3 retries on failure

  5. Plugin's readXWorkmateArtifact():
     → resolveScopeRoot(workspaceRoot, artifactScope)
     → Verify isWithinRoot()
     → Resolve file path: <workspace>/tasks/<session>/<run>/<relativePath>
     → Check file exists and not a symlink (reject symlinks to outside)
     → Read file content (up to 64MB max)
     → Return { content, contentType, size, sha256 }

  6. Bridge streams response:
     → Content-Type from artifact metadata
     → Content-Length header
     → Supports Range header for partial content
     → Validate SHA-256 of received content

Fragile:
  - Ref expiry: links stale after 24h
  - Secret rotation: all existing refs invalid
  - 64MB size limit
  - Symlink rejection: if agent created a symlink, download fails
  - 3 retry attempts only — persistent gateway failure = permanent 502

State 7: Sync (App)

Location: xworkmate-app
  lib/runtime/desktop_thread_artifact_service.dart
  lib/app/app_controller_desktop_thread_actions.dart

Process:
  1. Receive terminal snapshot from bridge (SSE or task.get)
  2. Check success=true AND artifacts.items[] present
  3. For each artifact item:
     → Download via bridge's artifact download URL
     → Write to local workspace: ~/.xworkmate/threads/<session>/<artifact-relative>
  4. Update TaskThread state:
     → lastArtifactSyncStatus = synced
     → lastTaskArtifactRelativePaths = [downloaded paths]
     → lastResultCode = success

Failure paths:
  - success=false → lastResultCode=failed
  - success=true, no artifacts → lastArtifactSyncStatus=no-exported-artifacts
  - download failed → lastArtifactSyncStatus=failed
  - artifact missing from source → OPENCLAW_REQUIRED_ARTIFACT_MISSING

Fragile:
  - Local workspace may have stale artifacts from previous runs
  - Concurrent writes to same thread directory (multiple turns)
  - Large artifact downloads block UI thread

Full State Machine

stateDiagram-v2
  [*] --> Prepare: bridge calls prepare
  Prepare --> Execute: scope dir created
  Execute --> Execute: agent writes files
  Execute --> Export: task complete
  Export --> Snapshot: manifest returned
  Snapshot --> Download: app requests
  Download --> Sync: file saved locally
  Sync --> [*]: artifact lifecycle complete

  Prepare --> PrepareFailed: gateway error / timeout
  Export --> NoArtifacts: scope dir empty or no matching files
  Download --> DownloadFailed: ref expired / file missing / secret rotated

Path Resolution Reference

Layer What Default Path
Plugin workspace resolveWorkspaceDir() ~/.openclaw/workspace
Plugin scope tasks/// <workspace>/tasks/<s>/<r>/
Plugin export exportXWorkmateArtifacts() Scans only scope dir
Bridge snapshot xworkmate.tasks.get proxy Native task-registry + plugin artifact manifest
Bridge download /artifacts/openclaw/download Proxied from plugin read
App sync syncArtifactsFromBridge() ~/.xworkmate/threads/<s>/
OpenClaw media saveMediaBuffer(subdir) ~/.openclaw/media/<subdir>/
OpenClaw temp resolvePreferredOpenClawTmpDir() /tmp/openclaw/