@pithy-sh/core 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (134) hide show
  1. package/LICENSE +21 -0
  2. package/package.json +47 -0
  3. package/pithy.manifest.json +74 -0
  4. package/src/address/address.ts +83 -0
  5. package/src/audit/auditEvent.ts +130 -0
  6. package/src/audit/recorder.ts +22 -0
  7. package/src/capability/bindings.ts +196 -0
  8. package/src/capability/capability.ts +555 -0
  9. package/src/capability/client.ts +136 -0
  10. package/src/capability/compose.ts +76 -0
  11. package/src/capability/composition.ts +98 -0
  12. package/src/capability/config.ts +19 -0
  13. package/src/capability/devSecret.ts +42 -0
  14. package/src/capability/manifest.ts +580 -0
  15. package/src/capability/secretOrigin.ts +253 -0
  16. package/src/capability/settings.ts +155 -0
  17. package/src/capability/validateBindings.ts +43 -0
  18. package/src/capability/vanishingKey.ts +92 -0
  19. package/src/cloudflare-test.d.ts +20 -0
  20. package/src/controlPlane/audit/actions.ts +81 -0
  21. package/src/controlPlane/capability.ts +228 -0
  22. package/src/controlPlane/config/config.ts +195 -0
  23. package/src/controlPlane/context.ts +63 -0
  24. package/src/controlPlane/data/connection.ts +123 -0
  25. package/src/controlPlane/data/keyLifecycle.ts +159 -0
  26. package/src/controlPlane/data/replay.ts +39 -0
  27. package/src/controlPlane/data/tables.ts +51 -0
  28. package/src/controlPlane/discovery/adminRoute.ts +250 -0
  29. package/src/controlPlane/discovery/configuration.ts +280 -0
  30. package/src/controlPlane/discovery/drift.ts +100 -0
  31. package/src/controlPlane/discovery/health.ts +213 -0
  32. package/src/controlPlane/discovery/healthSummary.ts +486 -0
  33. package/src/controlPlane/error/errors.ts +125 -0
  34. package/src/controlPlane/http/cors.ts +244 -0
  35. package/src/controlPlane/http/guard.ts +223 -0
  36. package/src/controlPlane/http/handlers.ts +346 -0
  37. package/src/controlPlane/http/responses.ts +92 -0
  38. package/src/controlPlane/http/routes.ts +115 -0
  39. package/src/controlPlane/http/schemas.ts +70 -0
  40. package/src/controlPlane/http/verify.ts +198 -0
  41. package/src/controlPlane/migrations/0001_init.ts +105 -0
  42. package/src/controlPlane/replay/d1Guard.ts +87 -0
  43. package/src/controlPlane/replay/guard.ts +55 -0
  44. package/src/controlPlane/replay/kvGuard.ts +143 -0
  45. package/src/controlPlane/scope/scope.ts +102 -0
  46. package/src/controlPlane/token/base64url.ts +65 -0
  47. package/src/controlPlane/token/claims.ts +151 -0
  48. package/src/controlPlane/token/digest.ts +63 -0
  49. package/src/controlPlane/token/jws.ts +112 -0
  50. package/src/controlPlane/token/mint.ts +93 -0
  51. package/src/controlPlane/wire.ts +138 -0
  52. package/src/createBackend.ts +292 -0
  53. package/src/createEntrypoint.ts +125 -0
  54. package/src/data/boundParameters.ts +197 -0
  55. package/src/data/codecs.ts +160 -0
  56. package/src/data/cursor.ts +127 -0
  57. package/src/data/databases.ts +84 -0
  58. package/src/data/db.ts +53 -0
  59. package/src/data/withD1Retry.ts +176 -0
  60. package/src/entitlement/entitlement.ts +191 -0
  61. package/src/entitlement/gateScan.ts +107 -0
  62. package/src/entitlement/require.ts +199 -0
  63. package/src/env/ambient.ts +67 -0
  64. package/src/env/ci.ts +43 -0
  65. package/src/env/stem.ts +34 -0
  66. package/src/error/cause.ts +208 -0
  67. package/src/error/client.ts +43 -0
  68. package/src/error/extend.ts +135 -0
  69. package/src/error/http.ts +92 -0
  70. package/src/error/payload.ts +2195 -0
  71. package/src/error/pithyError.ts +281 -0
  72. package/src/error/terminal.ts +36 -0
  73. package/src/http/authContext.ts +29 -0
  74. package/src/http/routeContract.ts +115 -0
  75. package/src/http/sameOrigin.ts +67 -0
  76. package/src/http/signedWebhook.ts +415 -0
  77. package/src/http/validation.ts +41 -0
  78. package/src/http/verification.ts +25 -0
  79. package/src/i18n/acceptLanguage.ts +70 -0
  80. package/src/i18n/catalog.ts +113 -0
  81. package/src/i18n/locale.ts +153 -0
  82. package/src/i18n/localeMarker.ts +116 -0
  83. package/src/i18n/match.ts +111 -0
  84. package/src/i18n/registry.ts +78 -0
  85. package/src/i18n/translator.ts +168 -0
  86. package/src/index.ts +116 -0
  87. package/src/kv/kv.ts +437 -0
  88. package/src/kv/namespaces.ts +102 -0
  89. package/src/logger/local.ts +91 -0
  90. package/src/logger/logger.ts +145 -0
  91. package/src/logger/record.ts +83 -0
  92. package/src/logger/worker.ts +117 -0
  93. package/src/migrations/batch.ts +226 -0
  94. package/src/migrations/bookkeeping.ts +85 -0
  95. package/src/migrations/owner.ts +166 -0
  96. package/src/migrations/registry.ts +121 -0
  97. package/src/migrations/runner.ts +295 -0
  98. package/src/naming/domains.ts +194 -0
  99. package/src/naming/environment.ts +224 -0
  100. package/src/naming/feature.ts +162 -0
  101. package/src/naming/limits.ts +223 -0
  102. package/src/naming/provisionScope.ts +143 -0
  103. package/src/naming/resource.ts +266 -0
  104. package/src/naming/resourceNames.ts +174 -0
  105. package/src/naming/segment.ts +32 -0
  106. package/src/projection/asRead.ts +211 -0
  107. package/src/projection/published.ts +210 -0
  108. package/src/schema/describedness.ts +250 -0
  109. package/src/seed/compose.ts +94 -0
  110. package/src/seed/devLogin.ts +67 -0
  111. package/src/seed/exampleIdentities.ts +43 -0
  112. package/src/seed/metadata.ts +27 -0
  113. package/src/seed/seed.ts +306 -0
  114. package/src/seed/seededRows.ts +41 -0
  115. package/src/seed/writeD1.ts +103 -0
  116. package/src/seed/writeKv.ts +99 -0
  117. package/src/semver/semver.ts +156 -0
  118. package/src/text/comments.ts +165 -0
  119. package/src/version.generated.ts +16 -0
  120. package/src/worker/health.ts +42 -0
  121. package/src/worker/identity.ts +243 -0
  122. package/src/workflow/bindings.ts +58 -0
  123. package/src/workflow/dispatch.ts +240 -0
  124. package/src/workflow/dispatchRoute.ts +184 -0
  125. package/src/workflow/faults.ts +219 -0
  126. package/src/workflow/host.ts +307 -0
  127. package/src/workflow/hostEntry.ts +71 -0
  128. package/src/workflow/hostEnv.ts +258 -0
  129. package/src/workflow/loopback.ts +149 -0
  130. package/src/workflow/naming.ts +170 -0
  131. package/src/workflow/register.ts +44 -0
  132. package/src/workflow/schemas.ts +84 -0
  133. package/src/workflow/spec.ts +86 -0
  134. package/src/workflow/stepMessage.ts +160 -0
@@ -0,0 +1,84 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { z } from "zod";
5
+
6
+ /**
7
+ * The request and response contract of the host's dispatch route ({@link ./dispatchRoute.ts}).
8
+ *
9
+ * Declared here rather than on the route line for the reason every capability's `http/schemas.ts`
10
+ * exists (CLAUDE.md §HTTP): the route declares *that* it validates, this declares *what*, and the
11
+ * loopback dispatcher on the other end of the wire builds its body against the same object.
12
+ *
13
+ * **The path is here too, and that is deliberate.** An address is as much of the wire contract as a
14
+ * body is, and putting it beside the route would make the dispatcher import the route module to know
15
+ * where to POST — which is the module that imports the dispatcher to resolve a binding. One import
16
+ * cycle, for a template string neither end owns more than the other.
17
+ */
18
+
19
+ /** The reserved namespace the host's dispatch route lives under, beside `/__pithy/dev-login`. */
20
+ export const WORKFLOW_DISPATCH_BASE = "/__pithy/workflows";
21
+
22
+ /** The registered path pattern. `:binding` is the host's own Workflow binding name. */
23
+ export const WORKFLOW_DISPATCH_ROUTE = `${WORKFLOW_DISPATCH_BASE}/:binding`;
24
+
25
+ /** The concrete path for one binding — what the loopback dispatcher POSTs to. */
26
+ export function workflowDispatchPath(binding: string): string {
27
+ return `${WORKFLOW_DISPATCH_BASE}/${encodeURIComponent(binding)}`;
28
+ }
29
+
30
+ /** The path segment naming which of the host's own Workflow bindings to start an instance on. */
31
+ export const WorkflowDispatchParams = z
32
+ .object({
33
+ binding: z
34
+ .string()
35
+ .min(1)
36
+ .max(64)
37
+ .regex(
38
+ /^[A-Za-z_][A-Za-z0-9_]*$/,
39
+ "A binding name is an identifier: letters, digits and underscores, not starting with a digit.",
40
+ )
41
+ .describe(
42
+ "The Workflow binding on this host's own env — `EMAIL_SENDER`, not a `<capability>/<job>` key. The loopback dispatcher stands in for exactly one binding, and a binding is what it knows itself by.",
43
+ ),
44
+ })
45
+ .describe("The path parameters of the host dispatch route.");
46
+ export type WorkflowDispatchParams = z.infer<typeof WorkflowDispatchParams>;
47
+
48
+ /** The body: the instance id and the payload, exactly as `create({ id, params })` takes them. */
49
+ export const WorkflowDispatchRequest = z
50
+ .object({
51
+ id: z
52
+ .string()
53
+ .min(1)
54
+ .max(128)
55
+ .optional()
56
+ .describe(
57
+ "The instance id to start under. Optional because the platform's own `create` allows omitting it — and relayed rather than minted here, because an id a dispatcher did not choose is an instance the caller's row cannot name (pithy-sh/pithy#342). Every Pithy dispatcher passes one.",
58
+ ),
59
+ params: z
60
+ .unknown()
61
+ .describe(
62
+ "The instance payload. Unconstrained on the wire and validated on arrival against the declaring spec's own schema, so a malformed payload fails with the field named rather than inside a running instance.",
63
+ ),
64
+ })
65
+ .describe("A request to start one instance of a Workflow this host hosts.");
66
+ export type WorkflowDispatchRequest = z.infer<typeof WorkflowDispatchRequest>;
67
+
68
+ /**
69
+ * The answer: which binding was started, under which id.
70
+ *
71
+ * A response object rather than an interface, for the reason every admin response is one (CLAUDE.md
72
+ * §HTTP): the caller is across a process boundary, and a TypeScript interface is erased before it can
73
+ * help. Nothing here carries a codec — an answer must parse back to exactly what went in.
74
+ */
75
+ export const WorkflowDispatchResponse = z
76
+ .object({
77
+ binding: z.string().describe("The binding an instance was started on — the one the request named."),
78
+ id: z.string().optional().describe("The instance id, when the request named one."),
79
+ started: z
80
+ .literal(true)
81
+ .describe("Always true. The route answers 202 once Cloudflare has accepted the instance, never on completion."),
82
+ })
83
+ .describe("What the host answers when it has accepted a dispatch.");
84
+ export type WorkflowDispatchResponse = z.infer<typeof WorkflowDispatchResponse>;
@@ -0,0 +1,86 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import type { z } from "zod";
5
+
6
+ /**
7
+ * The durable-job seam. A capability declares the Cloudflare Workflows it owns as a map of
8
+ * job name → {@link WorkflowSpec}; `createBackend` collects every capability's map into one
9
+ * registry, derives each spec's binding so a missing one fails at boot, and serves a typed
10
+ * dispatcher on `c.var.workflows`.
11
+ *
12
+ * The spec is the single description of a job. The CLI reads it to write the host worker's
13
+ * `workflows` array and its cron triggers; the dispatcher reads it to validate params and find
14
+ * the binding; `naming.ts` reads it to resolve the per-environment script name. One declaration,
15
+ * no drift between the wrangler config, the provisioner, and the call site.
16
+ */
17
+
18
+ /**
19
+ * The minimal structural shape of a Cloudflare Workflow binding: enough to start an instance,
20
+ * and nothing more. Typed structurally rather than against `Workflow<P>` from
21
+ * `@cloudflare/workers-types` so core gains no runtime dependency and a test injects a plain
22
+ * object literal — the same posture `@pithy-sh/media` already takes for its AI binding.
23
+ */
24
+ export interface WorkflowBinding {
25
+ /** Start a new instance. `params` is the validated payload the Workflow receives as `event.payload`. */
26
+ create(options?: { id?: string; params?: unknown }): Promise<unknown>;
27
+ }
28
+
29
+ /**
30
+ * One durable job a capability registers. Keyed in {@link WorkflowSpecMap} by its job name, which
31
+ * is also the second segment of the `<capability>/<job>` dispatch key.
32
+ */
33
+ export interface WorkflowSpec<Params extends z.ZodType = z.ZodType> {
34
+ /**
35
+ * The binding name the Workflow is bound to in the Worker env (e.g. `MEDIA_IMAGE_TO_TEXT`).
36
+ * `createBackend` derives a `workflow` {@link BindingSpec} from it, so a job whose binding is
37
+ * absent surfaces as a startup failure rather than a silent no-op at dispatch time.
38
+ */
39
+ binding: string;
40
+ /**
41
+ * The instance parameters, validated **at dispatch** — before the binding is touched. A malformed
42
+ * trigger therefore fails at the call site with `core/invalid_workflow_params`, rather than inside
43
+ * a running instance where the only signal is a failed step and a retry budget burning down.
44
+ */
45
+ params: Params;
46
+ /**
47
+ * A cron expression. Present means the CLI writes a `triggers.crons` entry into the host worker's
48
+ * resolved `wrangler.jsonc` and `createEntrypoint` routes the `scheduled` event here. Absent means
49
+ * dispatch-only. A scheduled job stays manually triggerable either way — a backfill nobody can run
50
+ * on demand is untestable.
51
+ */
52
+ schedule?: string;
53
+ /**
54
+ * The exported `WorkflowEntrypoint` subclass in the host worker that runs this job — the
55
+ * `class_name` the CLI writes into the host's `workflows` array. Omit only for a job whose host
56
+ * config is hand-maintained.
57
+ */
58
+ className?: string;
59
+ /**
60
+ * Treat the derived binding as optional, so a project that has not provisioned the host worker
61
+ * still boots. Defaults to false. `@pithy-sh/media` sets it: its enrichment Workflows appear only
62
+ * once `pithy media provision` has run, and the app must serve every other route until then.
63
+ */
64
+ optional?: boolean;
65
+ }
66
+
67
+ /** A capability's durable jobs: job name → {@link WorkflowSpec}. The peer of `databases` and `kvNamespaces`. */
68
+ export type WorkflowSpecMap = Record<string, WorkflowSpec>;
69
+
70
+ /**
71
+ * A spec resolved against the capability that declared it — what the registry holds and the
72
+ * dispatcher looks up. `key` is the `<capability>/<job>` dispatch key.
73
+ */
74
+ export interface RegisteredWorkflow {
75
+ /** The dispatch key, `<capability>/<job>`. */
76
+ key: string;
77
+ /** The capability that declared the job — the first key segment and the script-name segment. */
78
+ capability: string;
79
+ /** The job name — the second key segment. */
80
+ job: string;
81
+ /** The spec as declared. */
82
+ spec: WorkflowSpec;
83
+ }
84
+
85
+ /** The project-wide registry: dispatch key → {@link RegisteredWorkflow}. */
86
+ export type WorkflowRegistry = Record<string, RegisteredWorkflow>;
@@ -0,0 +1,160 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ /**
5
+ * **The one statement of what a terminal Workflow step writes into its error text, and the one reader of it.**
6
+ *
7
+ * A step's error text is the whole channel. The engine records the throw's message and nothing else —
8
+ * no fields, no cause, no payload — so every operator-facing thing a `PithyError` carries across a
9
+ * durable boundary has to ride inside that one string. `classifiedSteps` used to write
10
+ * `${code}: ${message}`, which meant `action` — the remedy line every other `PithyError` reaching the
11
+ * CLI prints under the problem line — died at the step (pithy-sh/pithy#353).
12
+ *
13
+ * Both ends of that channel live here. {@link encodeWorkflowStepMessage} is called by
14
+ * `classifiedSteps`; {@link decodeWorkflowStepMessage} is called by `@pithy-sh/cloudflare`'s
15
+ * `kitSentence`. **Neither end restates the format.** Two packages that agree about a string by each
16
+ * writing it down is not agreement, it is a coincidence with a maintenance schedule — so the format is
17
+ * the thing being called, not a rule at two call sites, and `stepFailure.test.ts` pins the wire it
18
+ * produces to a hand-written literal so a change to either end has to be a change to both.
19
+ *
20
+ * ## The encoding, and why it is a separator rather than JSON
21
+ *
22
+ * `${code}: ${message}` — unchanged, byte for byte, from what #349 captured — then, when there is one,
23
+ * a separator and the action.
24
+ *
25
+ * A JSON payload was the other candidate, and it is the more obvious answer: unambiguous, extensible,
26
+ * no grammar to get wrong. It loses on the surface nobody controls. **A step's raw text is read by a
27
+ * human, in the Cloudflare dashboard, at three in the morning**, and that reader is the whole reason
28
+ * the message was ever put in the throw. `{"code":"secrets/already_exists","message":"Secret
29
+ * 'api-token' already exists.","action":"Use \`update\` to change an existing secret."}` is strictly
30
+ * worse to read than the two lines it would replace, and it would make every instance already recorded
31
+ * unreadable to the new reader and every instance recorded by the new writer unreadable to the old one.
32
+ * A separator costs a grammar and keeps the sentence.
33
+ *
34
+ * ## Why the separator is a newline
35
+ *
36
+ * A printable delimiter — ` | `, ` :: ` — is ambiguous, and nothing at the reader can resolve it: a
37
+ * message that happens to contain the delimiter splits in the wrong place and the operator reads half
38
+ * a sentence with the other half presented as the remedy. A newline is not ambiguous, because a
39
+ * promoted message may not contain one. That rule is older than this encoding — #349 declined a
40
+ * newline-carrying sentence precisely because it *would forge the CLI's action line* — and it is what
41
+ * makes the split total rather than best-effort. The second line stops being a forgery and becomes the
42
+ * field it always looked like.
43
+ *
44
+ * It also renders. The CLI prints a problem line then an action line, and the dashboard now shows the
45
+ * same two lines the operator's terminal does.
46
+ *
47
+ * **The forgery concern does not disappear; it is bounded, and it was always this size.** A step's text
48
+ * arrives from a Worker whose code we did not write, and the reader promotes it only when it carries a
49
+ * kit throw name and this code grammar. Anyone who can forge that can already forge the problem line.
50
+ * The action line is exactly as trustworthy as the sentence above it — no more, and no less.
51
+ *
52
+ * ## Captured, not assumed
53
+ *
54
+ * The engine embeds the throw's text in its step record raw — it does not JSON-quote it, and it does
55
+ * not escape a newline. Read off a real local Workflows engine (wrangler 4.115.0, 2026-08-14) by
56
+ * driving a Workflow that threw exactly this shape and fetching the instance from the dev session's
57
+ * own Local Explorer instance endpoint:
58
+ *
59
+ * ```text
60
+ * Step threw a NonRetryableError with message "NonRetryableError: secrets/already_exists: Secret
61
+ * 'api-token' already exists.
62
+ * Use `update` to change an existing secret."
63
+ * ```
64
+ *
65
+ * The same run with no action produced #349's captured text byte for byte, which is the compatibility
66
+ * claim stated as a measurement rather than a hope.
67
+ *
68
+ * **`detail` is not here and never will be.** It is the security boundary: client-safe text in
69
+ * `message`, throw-site context in `detail`, and a durable boundary is not a reason to move the line.
70
+ * `action` crosses because `action` is already operator-facing — the CLI prints it and `operatorError`
71
+ * includes it. This stops losing a field that was always meant for this reader.
72
+ */
73
+
74
+ /**
75
+ * The separator between the step's sentence and its action. One newline, stated once.
76
+ *
77
+ * Exported so a test can assert the wire rather than ask the code what it produces — and so the plant
78
+ * that proves the gate can fail is a one-character edit here.
79
+ */
80
+ export const WORKFLOW_STEP_SEPARATOR = "\n";
81
+
82
+ /**
83
+ * The most either half may run to before it stops being treated as one.
84
+ *
85
+ * The text comes from a Worker over an API, and what it says decides how many bytes land in a terminal,
86
+ * a log line, and an audit row. Over the bound a half is not truncated — it is declined, because half a
87
+ * sentence read as the reason is worse than a general one with the reason underneath it.
88
+ */
89
+ export const MAX_WORKFLOW_STEP_TEXT = 512;
90
+
91
+ /**
92
+ * Any line break. The separator is one specific break, so every other one — a bare `\r`, a second
93
+ * newline — is a shape this encoding never produces, and an unrecognized shape is declined.
94
+ */
95
+ const LINE_BREAK = /[\n\r]/;
96
+
97
+ /**
98
+ * The `domain/reason` grammar of every kit and adopter error code, mirroring `codeSegment` in
99
+ * ../error/payload. Anchored at the start, so it is the prefix or it is nothing.
100
+ */
101
+ const CODE_PREFIX = /^([a-z][a-z0-9]*(?:_[a-z0-9]+)*\/[a-z][a-z0-9]*(?:_[a-z0-9]+)*): /;
102
+
103
+ /** What a terminal step's text carries across the boundary: the code, the sentence, and the remedy. */
104
+ export interface WorkflowStepMessage {
105
+ /** The `PithyError` code the step raised — `secrets/already_exists`. */
106
+ code: string;
107
+ /** The public sentence. `PithyError.payload.message`, written to be safe for a client. */
108
+ message: string;
109
+ /** The remedy, when the raising error stated one. The CLI's action line. */
110
+ action?: string;
111
+ }
112
+
113
+ /**
114
+ * Split a leading `<domain>/<reason>: ` off a text.
115
+ *
116
+ * Exported because the code grammar has exactly one statement and two readers: this encoding, and
117
+ * `@pithy-sh/cloudflare`'s handling of a bare `PithyError` throw, whose text is `payload.message` and
118
+ * carries no encoding at all. A second regex over there is how the two would drift.
119
+ */
120
+ export function splitWorkflowStepCode(text: string): { code?: string; rest: string } {
121
+ const matched = CODE_PREFIX.exec(text);
122
+ if (matched === null) return { rest: text };
123
+ return { code: matched[1], rest: text.slice(matched[0].length) };
124
+ }
125
+
126
+ /**
127
+ * The text a terminal step throws. The inverse of {@link decodeWorkflowStepMessage}, and tested as one.
128
+ *
129
+ * An action is appended only when there is one to append and it is a single line. An action carrying a
130
+ * break would encode a shape the reader declines, taking the sentence down with it — so the sentence
131
+ * is kept and the action dropped. Losing the remedy is a worse day; losing both is a worse one still.
132
+ */
133
+ export function encodeWorkflowStepMessage(fields: WorkflowStepMessage): string {
134
+ const stated = `${fields.code}: ${fields.message}`;
135
+ const action = fields.action?.trim();
136
+ if (action === undefined || action === "" || LINE_BREAK.test(action)) return stated;
137
+ return `${stated}${WORKFLOW_STEP_SEPARATOR}${action}`;
138
+ }
139
+
140
+ /**
141
+ * Read a terminal step's text back, or `null` when it is not this encoding.
142
+ *
143
+ * Strict on purpose. No code prefix, an empty sentence, a half over the bound, a stray `\r`, or a
144
+ * second separator means the text is not something {@link encodeWorkflowStepMessage} wrote, and text
145
+ * the kit did not write does not become an operator's sentence — it stays in `detail`, where the raw
146
+ * platform text belongs.
147
+ */
148
+ export function decodeWorkflowStepMessage(text: string): WorkflowStepMessage | null {
149
+ const { code, rest } = splitWorkflowStepCode(text.trim());
150
+ if (code === undefined) return null;
151
+
152
+ const cut = rest.indexOf(WORKFLOW_STEP_SEPARATOR);
153
+ const message = (cut === -1 ? rest : rest.slice(0, cut)).trim();
154
+ const action = cut === -1 ? undefined : rest.slice(cut + WORKFLOW_STEP_SEPARATOR.length).trim();
155
+
156
+ if (message === "" || message.length > MAX_WORKFLOW_STEP_TEXT || LINE_BREAK.test(message)) return null;
157
+ if (action === undefined) return { code, message };
158
+ if (action === "" || action.length > MAX_WORKFLOW_STEP_TEXT || LINE_BREAK.test(action)) return null;
159
+ return { code, message, action };
160
+ }