@stackbone/cli 0.4.1 → 0.5.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.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,46 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.5.0] - 2026-09-14
11
+
12
+ ### Added
13
+
14
+ - **`stackbone dev` records a write's echo and answers `isOwnEcho`.** A
15
+ connector call that names an `echoPath` has the value extracted from the
16
+ provider's response and recorded on its `connection` run step, and the
17
+ emulator plants the reader `isOwnEcho` queries on first dispatch, looking one
18
+ hour back across every workflow in the project. Three cases leave an
19
+ unresolved marker instead of a value, and reading one makes `isOwnEcho` throw
20
+ `echo_guard_unavailable` rather than answer a false "not ours": the path
21
+ resolved to nothing (missing, `null` and `''` are one case, because an empty
22
+ echo would match every later delivery whose value renders empty), the call
23
+ failed in a way that may still have landed (`execute_failed`, `timeout`, an
24
+ envelope-less `credential_error`), or the write ran with no workflow owner,
25
+ such as inside an agent turn's tool, which blinds every caller on that
26
+ connector. The runtime warns which write left the marker when it writes the
27
+ row. A call with no invocation context at all still writes to the provider
28
+ and records nothing, so it warns. Both halves need the `@stackbone/sdk`
29
+ released alongside this version: a project on an older SDK never sends
30
+ `echoPath`, and its `isOwnEcho` throws "No echo guard reader is bound".
31
+
32
+ ### Fixed
33
+
34
+ - **`stackbone package --target self-host`: the agent no longer races the
35
+ control plane for the shared connect tables.** The two containers share one
36
+ Postgres and both declared `connection_grant`: db-core's migration with a
37
+ bare `CREATE TABLE`, the box's lazy boot DDL with
38
+ `CREATE TABLE IF NOT EXISTS`. Whichever arrived second lost, and a boot where
39
+ the box won killed the control plane's migration and surfaced as a
40
+ health-check timeout. The generated compose now sets
41
+ `STACKBONE_PLATFORM_SCHEMA_OWNER: control-plane` on the agent service and the
42
+ box stands down. Not overridable from `.env`: it is a fact about that compose
43
+ file, not a preference. `stackbone dev` and BYOC set nothing and keep creating
44
+ their own tables.
45
+
46
+ - **A failed broker-context build is no longer cached for the life of the
47
+ process.** One Postgres hiccup on the first Connect request used to disable
48
+ Connect until the container restarted; the next request now rebuilds it.
49
+
10
50
  ## [0.4.1] - 2026-09-11
11
51
 
12
52
  ### Added
package/main.js CHANGED
@@ -9720,9 +9720,11 @@ var init_connector_invoker = __esmMin((() => {
9720
9720
  * @param connector The connect connector id registered in Studio.
9721
9721
  * @param operation The operation / tool id to run on that connector.
9722
9722
  * @param args The operation arguments (path/query/body params). Defaults `{}`.
9723
- * @param opts Optional `{ principal, idempotent }`. `principal` defaults to
9724
- * `{ type: 'app' }`; `idempotent` is UNSET by default and only
9725
- * an explicit `false` changes how a failure is retried.
9723
+ * @param opts Optional `{ principal, idempotent, echoPath }`. `principal`
9724
+ * defaults to `{ type: 'app' }`; `idempotent` is UNSET by
9725
+ * default and only an explicit `false` changes how a failure is
9726
+ * retried; `echoPath` records what this write returned so a
9727
+ * later delivery of it can be recognised as our own.
9726
9728
  */
9727
9729
  async function callConnector(connector, operation, args, opts) {
9728
9730
  return (await getConnectorInvoker().invoke({
@@ -9730,7 +9732,8 @@ async function callConnector(connector, operation, args, opts) {
9730
9732
  operation,
9731
9733
  ...args !== void 0 ? { args } : {},
9732
9734
  ...opts?.principal !== void 0 ? { principal: opts.principal } : {},
9733
- ...opts?.idempotent !== void 0 ? { idempotent: opts.idempotent } : {}
9735
+ ...opts?.idempotent !== void 0 ? { idempotent: opts.idempotent } : {},
9736
+ ...opts?.echoPath !== void 0 ? { echoPath: opts.echoPath } : {}
9734
9737
  })).output;
9735
9738
  }
9736
9739
  var connectorHandle, connection;
@@ -14368,6 +14371,29 @@ var createInvocationsStore = (opts) => {
14368
14371
  ]);
14369
14372
  return rows[0]?.id ?? null;
14370
14373
  },
14374
+ async findEchoRecord(input) {
14375
+ assertOpen();
14376
+ const args = [
14377
+ input.connector,
14378
+ input.value,
14379
+ input.windowMs
14380
+ ];
14381
+ let markerScope = "";
14382
+ if (input.workflowName !== null) {
14383
+ args.push(input.workflowName);
14384
+ markerScope = ` AND (input->>'workflowName' = $${args.length} OR input->>'workflowName' IS NULL)`;
14385
+ }
14386
+ const { rows } = await readPool.query(`SELECT bool_or(input->>'echo' = $2) AS matched,
14387
+ bool_or(input->>'echoUnresolved' = 'true'${markerScope}) AS unresolved
14388
+ FROM stackbone_platform.run_steps
14389
+ WHERE type = 'connection'
14390
+ AND input->>'connector' = $1
14391
+ AND created_at > now() - ($3::bigint * interval '1 millisecond')`, args);
14392
+ return {
14393
+ matched: rows[0]?.matched === true,
14394
+ unresolved: rows[0]?.unresolved === true
14395
+ };
14396
+ },
14371
14397
  async annotatePromptUse(input) {
14372
14398
  if (!isUuid(input.runId)) return;
14373
14399
  assertOpen();
@@ -15699,7 +15725,7 @@ var emulatorCors = (allowlist) => cors({
15699
15725
  var STACKBONE_CLI_BUILD_VERSION, EMULATOR_CAPABILITY_ROUTES, EMULATOR_CAPABILITIES, buildContractResponse;
15700
15726
  var init_contract = __esmMin((() => {
15701
15727
  init_src$19();
15702
- STACKBONE_CLI_BUILD_VERSION = "0.4.1";
15728
+ STACKBONE_CLI_BUILD_VERSION = "0.5.0";
15703
15729
  EMULATOR_CAPABILITY_ROUTES = {
15704
15730
  "database.postgres_direct": "/api/db",
15705
15731
  "rag.basic": "/api/rag",
@@ -32303,8 +32329,8 @@ var init_github_connector = __esmMin((() => {
32303
32329
  triggers: [{
32304
32330
  id: "commit-pushed",
32305
32331
  displayName: "New commit by date",
32306
- description: "Starts one run per commit whose committer date is newer than this link’s cursor. This follows commit dates, not push times: pushing an older local commit or merging existing commits without changing their committer dates can leave them undetected. A poll reads up to 10,000 commits; a larger window can skip its oldest commits. CAUTION: a workflow that writes a file to the watched branch can create a commit that re-triggers itself. Each new commit has a new SHA, so deduplication cannot stop the loop. Guard inside the workflow body: identify commits your workflow created and return without writing, or write to a separate branch that this link does not watch. A trigger mapping cannot skip an event.",
32307
- docs: "Watches commits on the selected branch, or the repository’s default branch when Branch is blank. Starts one run per commit whose committer date is newer than the saved cursor.\n\nThe cursor starts at the time you turn the link on. Push time is not recorded: commits created earlier and pushed later, including existing commits added by a fast-forward merge, can be skipped. Rebasing or squashing can assign new committer dates.\n\nEach poll reads up to 10,000 commits. If more commits match, the oldest may be skipped.\n\nCAUTION: a workflow that writes a file to the watched branch can create a commit that re-triggers itself. Each new commit has a new SHA, so deduplication cannot stop the loop. Guard inside the workflow body: identify commits your workflow created and return without writing, or write to a separate branch that this link does not watch. A trigger mapping cannot skip an event.",
32332
+ description: "Starts one run per commit whose committer date is newer than this link’s cursor. This follows commit dates, not push times: pushing an older local commit or merging existing commits without changing their committer dates can leave them undetected. A poll reads up to 10,000 commits; a larger window can skip its oldest commits. CAUTION: a workflow that writes a file to the watched branch can create a commit that re-triggers itself. Each new commit has a new SHA, so deduplication cannot stop the loop. THIS CONNECTOR EXPOSES NO WRITE OPERATION, so the `isOwnEcho` guard has nothing to compare against and the workaround is the branch: write to a separate branch that this link does not watch. A trigger mapping cannot skip an event.",
32333
+ docs: "Watches commits on the selected branch, or the repository’s default branch when Branch is blank. Starts one run per commit whose committer date is newer than the saved cursor.\n\nThe cursor starts at the time you turn the link on. Push time is not recorded: commits created earlier and pushed later, including existing commits added by a fast-forward merge, can be skipped. Rebasing or squashing can assign new committer dates.\n\nEach poll reads up to 10,000 commits. If more commits match, the oldest may be skipped.\n\nCAUTION: a workflow that writes a file to the watched branch can create a commit that re-triggers itself. Each new commit has a new SHA, so deduplication cannot stop the loop. This connector exposes no write operation, so the `isOwnEcho` guard has nothing to compare against, and the workaround is the branch: write to a separate branch that this link does not watch. A trigger mapping cannot skip an event.",
32308
32334
  sampleData: {
32309
32335
  sha: "11589d462dc2b29714240e03ce310e7b15aa94c3",
32310
32336
  url: "https://github.com/stackbone/stackbone/commit/11589d462dc2b29714240e03ce310e7b15aa94c3",
@@ -32385,8 +32411,8 @@ var init_github_connector = __esmMin((() => {
32385
32411
  }, {
32386
32412
  id: "issue-updated",
32387
32413
  displayName: "Issue updated",
32388
- description: "Fires on every change to an issue in the repository after the link was turned on, following `updated_at`. CLOSED ISSUES ARE INCLUDED — the link asks for `state=all`, so resolving an issue is a change you receive. PULL REQUESTS ARRIVE HERE TOO: GitHub models every pull request as an issue and this endpoint returns both, and only a `pullRequest` object on the payload tells them apart — it is absent on a real issue. A newly opened issue arrives as well, with `createdAt` equal to `updatedAt`. CAUTION: a workflow that writes back to the issue it received — a comment, a label, a close — bumps `updated_at` and re-triggers itself, and the delivery journal cannot stop it. GitHub sends nothing that says who made a change (`author` is who OPENED the issue), so the guard is for your workflow to remember the `updated_at` its own write returned and skip a delivery carrying that value. A trigger mapping cannot skip an item; every delivered event starts a run.",
32389
- docs: "Starts a run on every change to an issue in the repository, from the moment the link is turned on. Closed issues count: resolving one is a change you receive. Pull requests arrive here too, because GitHub models each one as an issue. A `pullRequest` object on the event is what tells them apart, and it is absent on a real issue. A newly opened issue arrives as well, with `createdAt` equal to `updatedAt`.\n\nCAUTION: a workflow that writes back to the issue it received (a comment, a label, a close) bumps `updatedAt` and starts itself again. Nothing on the platform stops that loop, and GitHub sends no field saying who made a change (`author` is whoever opened the issue). Have your workflow remember the `updatedAt` its own write returned, and skip a delivery carrying that value. A mapping cannot skip an event: every delivery starts a run.",
32414
+ description: "Fires on every change to an issue in the repository after the link was turned on, following `updated_at`. CLOSED ISSUES ARE INCLUDED — the link asks for `state=all`, so resolving an issue is a change you receive. PULL REQUESTS ARRIVE HERE TOO: GitHub models every pull request as an issue and this endpoint returns both, and only a `pullRequest` object on the payload tells them apart — it is absent on a real issue. A newly opened issue arrives as well, with `createdAt` equal to `updatedAt`. CAUTION: a workflow that writes back to the issue it received — a comment, a label, a close — bumps `updated_at` and re-triggers itself, and the delivery journal cannot stop it. GitHub sends nothing that says who made a change (`author` is who OPENED the issue), and THIS CONNECTOR EXPOSES NO WRITE OPERATION, so the `isOwnEcho` guard has nothing to compare against: whatever wrote the issue did not go through here. Until a write lands on this connector the workaround is outside it. A trigger mapping cannot skip an item; every delivered event starts a run.",
32415
+ docs: "Starts a run on every change to an issue in the repository, from the moment the link is turned on. Closed issues count: resolving one is a change you receive. Pull requests arrive here too, because GitHub models each one as an issue. A `pullRequest` object on the event is what tells them apart, and it is absent on a real issue. A newly opened issue arrives as well, with `createdAt` equal to `updatedAt`.\n\nCAUTION: a workflow that writes back to the issue it received (a comment, a label, a close) bumps `updatedAt` and starts itself again. Nothing on the platform stops that loop, and GitHub sends no field saying who made a change (`author` is whoever opened the issue). This connector exposes no write operation, so the `isOwnEcho` guard has nothing to compare against: whatever wrote the issue did not go through here. Until a write lands on this connector, the workaround is outside it. A mapping cannot skip an event: every delivery starts a run.",
32390
32416
  sampleData: {
32391
32417
  issueId: 5320382826,
32392
32418
  number: 14317,
@@ -32782,7 +32808,7 @@ var init_google_drive_connector = __esmMin((() => {
32782
32808
  post: {
32783
32809
  operationId: "drive.files.create",
32784
32810
  summary: "Create a folder, or a file with metadata but no content",
32785
- description: "Metadata only — this sends JSON, never bytes. A folder is `mimeType: \"application/vnd.google-apps.folder\"`. To create a file WITH content use `drive.files.upload`, which sends the bytes from object storage against Google's upload host; this operation creates the file empty. If a workflow of yours also watches the destination folder, ask for `modifiedTime` in `fields` — otherwise the response carries no version and nothing can tell your own file from one a person dropped in.",
32811
+ description: "Metadata only — this sends JSON, never bytes. A folder is `mimeType: \"application/vnd.google-apps.folder\"`. To create a file WITH content use `drive.files.upload`, which sends the bytes from object storage against Google's upload host; this operation creates the file empty. If a workflow of yours also watches the destination folder, ask for `id` in `fields` and declare `echoPath: 'id'` on the call, then open that workflow with `isOwnEcho('google-drive', event.fileId)`. The trigger keys on the file ID, so the ID is the value to compare — a `modifiedTime` echo would never match it.",
32786
32812
  "x-stackbone-version-path": "modifiedTime",
32787
32813
  parameters: [FIELDS_PARAM, SUPPORTS_ALL_DRIVES_PARAM],
32788
32814
  requestBody: {
@@ -32852,7 +32878,7 @@ var init_google_drive_connector = __esmMin((() => {
32852
32878
  patch: {
32853
32879
  operationId: "drive.files.update",
32854
32880
  summary: "Rename a file, move it between folders, or set its metadata",
32855
- description: "The move is the two QUERY parameters, not the body: `addParents` and `removeParents`. `name` goes in the JSON body. Both in one call is how a file gets named and filed at once. If a workflow of yours also watches this folder, ask for `modifiedTime` in `fields` — otherwise the response carries no version and nothing can tell your own change from somebody else's.",
32881
+ description: "The move is the two QUERY parameters, not the body: `addParents` and `removeParents`. `name` goes in the JSON body. Both in one call is how a file gets named and filed at once. This one does NOT re-trigger `file-added-to-folder`: that trigger fires once per file, so an edit to a file it already delivered starts nothing. The self-write loop to guard is the one the CREATE and UPLOAD operations open, not this one.",
32856
32882
  "x-stackbone-version-path": "modifiedTime",
32857
32883
  parameters: [
32858
32884
  {
@@ -33160,8 +33186,8 @@ var init_google_drive_connector = __esmMin((() => {
33160
33186
  triggers: [{
33161
33187
  id: "file-added-to-folder",
33162
33188
  displayName: "File added to folder",
33163
- description: "Starts a run the first time a file in one Drive folder arrives or changes — once per file, so later edits to a file already delivered start nothing. Watches the folder itself, not its subfolders. CAUTION: a file this connector uploads to the watched folder can re-trigger the workflow. Each upload creates a new file id, so deduplication cannot stop the loop. Guard inside the workflow body: identify files your workflow uploaded and return without writing, or use separate input and output folders. A trigger mapping cannot skip an event.",
33164
- docs: "Starts a run the first time a file in one Drive folder arrives or changes. Once per file: a later edit to a file already delivered starts nothing. It watches the folder itself, not its subfolders.\n\nCAUTION: a file this connector uploads to the watched folder can re-trigger the workflow. Each upload creates a new file id, so deduplication cannot stop the loop. Guard inside the workflow body: identify files your workflow uploaded and return without writing, or use separate input and output folders. A trigger mapping cannot skip an event.",
33189
+ description: "Starts a run the first time a file in one Drive folder arrives or changes — once per file, so later edits to a file already delivered start nothing. Watches the folder itself, not its subfolders. CAUTION: a file this connector uploads to the watched folder can re-trigger the workflow. Each upload creates a new file id, so deduplication cannot stop the loop. Guard INSIDE THE WORKFLOW BODY with `isOwnEcho`, for a file the workflow creates by calling `drive.files.create` or `drive.files.upload` ITSELF: pass `fields: 'id'` and `echoPath: 'id'` on that call, then open the workflow with `if (await isOwnEcho('google-drive', event.fileId)) return;`. NAME `fields` FOR WHATEVER THE ECHO PATH POINTS AT, because Drive answers a partial response by default and a path onto a field it left out resolves to nothing, which makes the guard throw rather than answer. THE `drive.file-write` OUTPUT WRITER CANNOT CARRY AN ECHO PATH, so a workflow that files its output through it is not covered and still needs separate input and output folders. A trigger mapping cannot skip an event.",
33190
+ docs: "Starts a run the first time a file in one Drive folder arrives or changes. Once per file: a later edit to a file already delivered starts nothing. It watches the folder itself, not its subfolders.\n\nCAUTION: a file this connector uploads to the watched folder starts the workflow again. Each upload creates a new file id, so deduplication cannot stop the loop. Guard inside the workflow body with `isOwnEcho`, for a file the workflow creates by calling Drive's own `create` or `upload` operation itself: pass `fields: 'id'` and `echoPath: 'id'` on that call, then open the workflow with `if (await isOwnEcho('google-drive', event.fileId)) return;`. Name `fields` for whatever the echo path points at, because Drive answers a partial response by default, and a path onto a field it left out resolves to nothing, which makes the guard throw rather than answer. The `drive.file-write` output writer cannot carry an echo path, so a workflow that files its output through it is not covered and still needs separate input and output folders. A mapping cannot skip an event: every delivery starts a run.",
33165
33191
  sampleData: {
33166
33192
  fileId: "1a2B3cD4eF5gH6iJ7kL8mN9oP",
33167
33193
  name: "Weekly sync 2026-08-25.pdf",
@@ -33381,8 +33407,8 @@ var init_linear_connector = __esmMin((() => {
33381
33407
  {
33382
33408
  id: "issue-created",
33383
33409
  displayName: "Issue created",
33384
- description: "Fires once per issue created in the workspace after the link was turned on, following `createdAt`. CAUTION: a workflow that creates an issue in this workspace can re-trigger itself. Each new issue has a new id, so deduplication cannot stop the loop. Guard inside the workflow body: mark issues you create in their title or description, and return without writing when an incoming issue carries that marker. A trigger mapping cannot skip an event.",
33385
- docs: "Starts one run per issue created in the workspace, from the moment the link is turned on.\n\nCAUTION: a workflow that creates an issue in this workspace can re-trigger itself. Each new issue has a new id, so deduplication cannot stop the loop. Guard inside the workflow body: mark issues you create in their title or description, and return without writing when an incoming issue carries that marker. A trigger mapping cannot skip an event.",
33410
+ description: "Fires once per issue created in the workspace after the link was turned on, following `createdAt`. CAUTION: a workflow that creates an issue in this workspace can re-trigger itself. Each new issue has a new id, so deduplication cannot stop the loop. Guard INSIDE THE WORKFLOW BODY with `isOwnEcho`: have the `issueCreate` mutation select `issue { id }`, declare `echoPath: 'data.issueCreate.issue.id'` on the write, then open the workflow with `if (await isOwnEcho('linear', event.issueId)) return;`. It compares the id your own write returned against the one delivered. The connector id you pass is the one you passed to `connection()`. A trigger mapping cannot skip an event.",
33411
+ docs: "Starts one run per issue created in the workspace, from the moment the link is turned on.\n\nCAUTION: a workflow that creates an issue in this workspace starts itself again. Each new issue has a new id, so deduplication cannot stop the loop. Guard inside the workflow body with `isOwnEcho`. The `issueCreate` mutation has to select `issue { id }`, because the guard can only record a value the response carries. Declare `echoPath: 'data.issueCreate.issue.id'` on the write, then open the workflow with `if (await isOwnEcho('linear', event.issueId)) return;`. That compares the id your own write returned against the one you were handed. The connector id you pass to `isOwnEcho` is the same one you passed to `connection()`. A mapping cannot skip an event: every delivery starts a run.",
33386
33412
  sampleData: {
33387
33413
  issueId: "a1b2c3d4-0000-0000-0000-000000000000",
33388
33414
  identifier: "PLA-812",
@@ -33471,8 +33497,8 @@ var init_linear_connector = __esmMin((() => {
33471
33497
  {
33472
33498
  id: "comment-created",
33473
33499
  displayName: "Comment created",
33474
- description: "Fires once per comment posted on any issue in the workspace after the link was turned on, following `createdAt`. CAUTION: a workflow that replies by posting a comment in this workspace can re-trigger itself. Each new comment has a new id, so deduplication cannot stop the loop. Guard inside the workflow body: mark comments you post in their body, and return without writing when an incoming comment carries that marker. A trigger mapping cannot skip an event.",
33475
- docs: "Starts one run per comment posted on any issue in the workspace, from the moment the link is turned on.\n\nCAUTION: a workflow that replies by posting a comment in this workspace can re-trigger itself. Each new comment has a new id, so deduplication cannot stop the loop. Guard inside the workflow body: mark comments you post in their body, and return without writing when an incoming comment carries that marker. A trigger mapping cannot skip an event.",
33500
+ description: "Fires once per comment posted on any issue in the workspace after the link was turned on, following `createdAt`. CAUTION: a workflow that replies by posting a comment in this workspace can re-trigger itself. Each new comment has a new id, so deduplication cannot stop the loop. Guard INSIDE THE WORKFLOW BODY with `isOwnEcho`: have the `commentCreate` mutation select `comment { id }`, declare `echoPath: 'data.commentCreate.comment.id'` on the write, then open the workflow with `if (await isOwnEcho('linear', event.commentId)) return;`. It compares the id your own write returned against the one delivered. A trigger mapping cannot skip an event.",
33501
+ docs: "Starts one run per comment posted on any issue in the workspace, from the moment the link is turned on.\n\nCAUTION: a workflow that replies by posting a comment in this workspace starts itself again. Each new comment has a new id, so deduplication cannot stop the loop. Guard inside the workflow body with `isOwnEcho`. The `commentCreate` mutation has to select `comment { id }`, because the guard can only record a value the response carries. Declare `echoPath: 'data.commentCreate.comment.id'` on the write, then open the workflow with `if (await isOwnEcho('linear', event.commentId)) return;`. That compares the id your own write returned against the one you were handed. A mapping cannot skip an event: every delivery starts a run.",
33476
33502
  sampleData: {
33477
33503
  commentId: "e5f6a7b8-0000-0000-0000-000000000000",
33478
33504
  body: "Shipped on `next`, closing.",
@@ -33564,8 +33590,8 @@ var init_linear_connector = __esmMin((() => {
33564
33590
  {
33565
33591
  id: "issue-updated",
33566
33592
  displayName: "Issue updated",
33567
- description: "Fires on every change to an issue in the workspace after the link was turned on, following `updatedAt`. A newly created issue arrives here too — compare `createdAt` with `updatedAt` to tell a birth from an edit. `lastActor` / `lastBotActor` carry the actor of the issue's NEWEST HISTORY ENTRY — a `User` when a personal API key made the change, a bot when an OAuth app did. That is the best available signal for who caused a change, not a guarantee that it is: either can arrive null, and an `updatedAt` bump that minted no history entry leaves the previous change's actor in place. CAUTION: a workflow that writes back to the issue it received bumps `updatedAt` and re-triggers itself, and the delivery journal cannot stop it. Guard INSIDE THE WORKFLOW BODY — return without writing when `lastActor` is your own account, and decide deliberately what to do when it is absent rather than dereferencing it. A trigger mapping cannot skip an item; every delivered event starts a run. Write-back can also overwrite a human edit made since the event was read: issueUpdate exposes no version or expected-value precondition. Write only fields reserved for the workflow; reading again before writing does not make the update atomic. Archiving is not delivered, because an archived issue stops matching.",
33568
- docs: "Starts a run on every change to an issue in the workspace, from the moment the link is turned on. A newly created issue arrives here too, so compare `createdAt` with `updatedAt` to tell a birth from an edit. Archiving is not delivered, because an archived issue stops matching.\n\n`lastActor` and `lastBotActor` carry whoever made the newest change: `lastActor` when a personal API key did it, `lastBotActor` when an OAuth app did. Treat them as the best signal available rather than a guarantee. Either can arrive null, and a change that minted no history entry leaves the previous actor in place.\n\nCAUTION: a workflow that writes back to the issue it received bumps `updatedAt` and starts itself again. Nothing on the platform stops that loop. Guard inside the workflow body: return without writing when `lastActor` is your own account, and decide what to do when it is absent instead of reading through it. A mapping cannot skip an event: every delivery starts a run.\n\nWrite-back can also overwrite a human edit made since the event was read. `issueUpdate` exposes no version or expected-value precondition. Write only fields reserved for the workflow; reading again before writing does not make the update atomic.",
33593
+ description: "Fires on every change to an issue in the workspace after the link was turned on, following `updatedAt`. A newly created issue arrives here too — compare `createdAt` with `updatedAt` to tell a birth from an edit. `lastActor` / `lastBotActor` carry the actor of the issue's NEWEST HISTORY ENTRY — a `User` when a personal API key made the change, a bot when an OAuth app did. Use them to answer who touched this, for instance to ignore every bot's edits; they are not a self-write guard, because either can arrive null and an `updatedAt` bump that minted no history entry leaves the previous change's actor in place. CAUTION: a workflow that writes back to the issue it received bumps `updatedAt` and re-triggers itself, and the delivery journal cannot stop it. Guard INSIDE THE WORKFLOW BODY with `isOwnEcho`: declare `echoPath: 'data.issueUpdate.issue.updatedAt'` on the write, then open the workflow with `if (await isOwnEcho('linear', event.updatedAt)) return;`. It compares the value your own write returned against the one delivered, so it does not depend on an actor field being fresh. A trigger mapping cannot skip an item; every delivered event starts a run. Write-back can also overwrite a human edit made since the event was read: issueUpdate exposes no version or expected-value precondition. Write only fields reserved for the workflow; reading again before writing does not make the update atomic. Archiving is not delivered, because an archived issue stops matching.",
33594
+ docs: "Starts a run on every change to an issue in the workspace, from the moment the link is turned on. A newly created issue arrives here too, so compare `createdAt` with `updatedAt` to tell a birth from an edit. Archiving is not delivered, because an archived issue stops matching.\n\n`lastActor` and `lastBotActor` carry whoever made the newest change: `lastActor` when a personal API key did it, `lastBotActor` when an OAuth app did. They answer who touched this, which is useful on its own, for instance to ignore edits made by bots. They are not the self-write guard: either can arrive null, and a change that minted no history entry leaves the previous actor in place.\n\nCAUTION: a workflow that writes back to the issue it received bumps `updatedAt` and starts itself again. Nothing on the platform stops that loop. Guard inside the workflow body with `isOwnEcho`. Declare where your write reports the new version, `echoPath: 'data.issueUpdate.issue.updatedAt'`, then open the workflow with `if (await isOwnEcho('linear', event.updatedAt)) return;`. That compares the value your own write returned against the one you were handed, so it holds whether or not an actor field is fresh. A mapping cannot skip an event: every delivery starts a run.\n\nWrite-back can also overwrite a human edit made since the event was read. `issueUpdate` exposes no version or expected-value precondition. Write only fields reserved for the workflow; reading again before writing does not make the update atomic.",
33569
33595
  sampleData: {
33570
33596
  issueId: "a1b2c3d4-0000-0000-0000-000000000000",
33571
33597
  identifier: "PLA-812",
@@ -33985,8 +34011,8 @@ var init_supabase_connector = __esmMin((() => {
33985
34011
  triggers: [{
33986
34012
  id: "row-inserted",
33987
34013
  displayName: "Row inserted or changed",
33988
- description: "Starts a run for each row that appears past the cursor column, delivered as { table, row }. THE CURSOR COLUMN DECIDES WHAT \"APPEARS\" MEANS. Left at the default `id`, or set to any column written once and never touched again such as `created_at`, this is insert-only: one run per new row, and an edit to an existing row is never delivered. Set to a column your database bumps on every write, such as `updated_at`, this is change detection: EVERY UPDATE TO A ROW STARTS A NEW RUN. CAUTION: on a moving column a workflow that writes back to the row it received bumps that column and re-triggers itself, and the delivery journal cannot stop it — guard INSIDE THE WORKFLOW BODY. The table must expose a unique, non-null `id` column for delivery identity. Rows whose cursor value is NULL are ignored. Rows are read in ascending cursor order: a row that becomes visible after the cursor advances, with a value EQUAL TO OR LOWER THAN the highest seen, is not delivered. Choose values that increase as changes become visible; this also applies to timestamp and date columns. A trigger mapping cannot skip an item; every delivered row starts a run.",
33989
- docs: "Starts a run for each row that appears past the cursor column, delivered as `{ table, row }`.\n\nThe cursor column decides what \"appears\" means, and it is the only choice that matters here. Left at the default `id`, or set to a column written once and never touched again such as `created_at`, this is insert-only: one run per new row, and an edit to an existing row is never delivered. Set to a column your database bumps on every write, such as `updated_at`, it becomes change detection, and every update to a row starts a new run.\n\nThe table must expose a unique, non-null `id` column for delivery identity. Rows whose cursor value is NULL are ignored.\n\nRows are read in ascending cursor order. A row that becomes visible after the cursor advances, with a value equal to or lower than the highest already seen, is not delivered. Choose values that increase as changes become visible; this also applies to timestamp and date columns.\n\nCAUTION: on a moving column, a workflow that writes back to the row it received bumps that column and starts itself again. Nothing on the platform stops that loop, so guard inside the workflow body. A mapping cannot skip an event: every delivered row starts a run.",
34014
+ description: "Starts a run for each row that appears past the cursor column, delivered as { table, row }. THE CURSOR COLUMN DECIDES WHAT \"APPEARS\" MEANS. Left at the default `id`, or set to any column written once and never touched again such as `created_at`, this is insert-only: one run per new row, and an edit to an existing row is never delivered. Set to a column your database bumps on every write, such as `updated_at`, this is change detection: EVERY UPDATE TO A ROW STARTS A NEW RUN. CAUTION: a workflow that writes to this table can re-trigger itself, and the delivery journal cannot stop it: on a moving column a write-back bumps that column, and on the default column a workflow that answers each row by inserting another mints a new id. Guard INSIDE THE WORKFLOW BODY with `isOwnEcho`, and MATCH THE VALUE TO THE CURSOR COLUMN. Insert-only, compare the new row's id: `echoPath: '0.id'` on the write against `event.row.id`. Change detection, compare the cursor column itself: `echoPath: '0.updated_at'` against `event.row.updated_at`, with your own column name in place of `updated_at`. COMPARING THE ID ON A MOVING COLUMN IS WRONG — a row's id never changes, so the guard would read every later delivery of that row as your own echo for the whole window and swallow a human's edit with it. The write must also ask for `Prefer: return=representation`: without it PostgREST answers no body, the echo path resolves to nothing, and the guard throws instead of guarding. The table must expose a unique, non-null `id` column for delivery identity. Rows whose cursor value is NULL are ignored. Rows are read in ascending cursor order: a row that becomes visible after the cursor advances, with a value EQUAL TO OR LOWER THAN the highest seen, is not delivered. Choose values that increase as changes become visible; this also applies to timestamp and date columns. A trigger mapping cannot skip an item; every delivered row starts a run.",
34015
+ docs: "Starts a run for each row that appears past the cursor column, delivered as `{ table, row }`.\n\nThe cursor column decides what \"appears\" means, and it is the only choice that matters here. Left at the default `id`, or set to a column written once and never touched again such as `created_at`, this is insert-only: one run per new row, and an edit to an existing row is never delivered. Set to a column your database bumps on every write, such as `updated_at`, it becomes change detection, and every update to a row starts a new run.\n\nThe table must expose a unique, non-null `id` column for delivery identity. Rows whose cursor value is NULL are ignored.\n\nRows are read in ascending cursor order. A row that becomes visible after the cursor advances, with a value equal to or lower than the highest already seen, is not delivered. Choose values that increase as changes become visible; this also applies to timestamp and date columns.\n\nCAUTION: a workflow that writes to this table starts itself again, and nothing on the platform stops that loop: on a moving column a write-back bumps that column, and on the default column a workflow that answers each row by inserting another row mints a new id. Guard inside the workflow body with `isOwnEcho`, and match the value you compare to the cursor column. Insert-only: compare the `id` the delivery carried, with `echoPath: '0.id'` on the write. Change detection: compare the cursor column the delivery carried, with `echoPath: '0.updated_at'` on the write, putting your own column name in place of `updated_at`. Comparing the id on a moving column is wrong, because a row's id never changes, so the guard would read every later delivery of that row as your own echo for the whole window and swallow a human's edit with it. The write also has to ask for `Prefer: return=representation`: without it PostgREST answers no body, the echo path resolves to nothing, and the guard throws instead of guarding. A mapping cannot skip an event: every delivered row starts a run.",
33990
34016
  sampleData: {
33991
34017
  table: "todos",
33992
34018
  row: {
@@ -51808,8 +51834,27 @@ var CONNECT_DDL = `
51808
51834
  * Ensure the connect tables exist in the emulator Postgres. Idempotent. Exported
51809
51835
  * so the workspace-server can run it once at boot (and the spec can run it before
51810
51836
  * exercising the routes) instead of paying the DDL on every request.
51811
- */
51812
- var ensureConnectTables = async (connectionString) => {
51837
+ *
51838
+ * A NO-OP WHEN THE CONTROL PLANE OWNS THE SCHEMA (PLA-1286). In the self-host
51839
+ * topology the box and the control plane share ONE Postgres, and both declare
51840
+ * `connection_grant`: db-core's `0008_connect.sql` with a bare `CREATE TABLE`,
51841
+ * this module with `CREATE TABLE IF NOT EXISTS`. Only one of the two survives
51842
+ * arriving second — the migration dies on `relation already exists`, the control
51843
+ * plane restarts, and the self-host smoke reports it as a health-check timeout.
51844
+ *
51845
+ * The DDL here runs LAZILY, on the first request that builds the broker context,
51846
+ * so which of the two lands first is a coin flip decided by traffic. Probing the
51847
+ * database for a control plane cannot settle it: the box answers healthy seconds
51848
+ * before the control plane has written anything at all, so the probe would come
51849
+ * back empty and create the table anyway. So the topology DECLARES the owner
51850
+ * instead of the box guessing it, and a declared owner is the same answer on
51851
+ * every boot.
51852
+ *
51853
+ * `stackbone dev` and BYOC set nothing, have no control plane on their database,
51854
+ * and keep creating their own tables exactly as before.
51855
+ */
51856
+ var ensureConnectTables = async (connectionString, opts = {}) => {
51857
+ if ((opts.schemaOwner ?? process.env["STACKBONE_PLATFORM_SCHEMA_OWNER"]) === "control-plane") return;
51813
51858
  const client = new Client({ connectionString });
51814
51859
  await client.connect();
51815
51860
  try {
@@ -51879,47 +51924,53 @@ var deriveBase64Key = (raw) => createHash("sha256").update(raw).digest().toStrin
51879
51924
  var makeContextFactory = (opts) => {
51880
51925
  let cached;
51881
51926
  const get = () => {
51882
- if (!cached) cached = (async () => {
51883
- await ensureConnectTables(opts.connectionString);
51884
- const cipher = await createSecretCipher(deriveBase64Key(opts.encryptionKey ?? DEV_CONNECT_ENCRYPTION_KEY));
51885
- const sql = postgres(opts.connectionString, {
51886
- max: 4,
51887
- onnotice: () => void 0
51888
- });
51889
- const db = drizzle(sql);
51890
- const store = new DrizzleBrokerStore(db, cipher);
51891
- const cache = createInMemoryTokenCache();
51892
- const specCache = createBoxSpecCache(opts.redisUrl);
51893
- const bindings = createInMemoryBindingStore();
51894
- const exchanger = new DefaultOAuthExchanger();
51895
- const brokerCallbackBaseUrl = opts.brokerCallbackBaseUrl ?? "http://127.0.0.1:4242/api/connect/callback";
51896
- const service = new ConnectService({
51897
- store,
51898
- exchanger,
51899
- cache,
51900
- bindings,
51901
- oauth: {
51902
- brokerCallbackUrl: brokerCallbackBaseUrl,
51903
- stateSecret: deriveBase64Key(opts.encryptionKey ?? DEV_CONNECT_ENCRYPTION_KEY)
51904
- }
51927
+ if (!cached) {
51928
+ const building = (async () => {
51929
+ await ensureConnectTables(opts.connectionString);
51930
+ const cipher = await createSecretCipher(deriveBase64Key(opts.encryptionKey ?? DEV_CONNECT_ENCRYPTION_KEY));
51931
+ const sql = postgres(opts.connectionString, {
51932
+ max: 4,
51933
+ onnotice: () => void 0
51934
+ });
51935
+ const db = drizzle(sql);
51936
+ const store = new DrizzleBrokerStore(db, cipher);
51937
+ const cache = createInMemoryTokenCache();
51938
+ const specCache = createBoxSpecCache(opts.redisUrl);
51939
+ const bindings = createInMemoryBindingStore();
51940
+ const exchanger = new DefaultOAuthExchanger();
51941
+ const brokerCallbackBaseUrl = opts.brokerCallbackBaseUrl ?? "http://127.0.0.1:4242/api/connect/callback";
51942
+ const service = new ConnectService({
51943
+ store,
51944
+ exchanger,
51945
+ cache,
51946
+ bindings,
51947
+ oauth: {
51948
+ brokerCallbackUrl: brokerCallbackBaseUrl,
51949
+ stateSecret: deriveBase64Key(opts.encryptionKey ?? DEV_CONNECT_ENCRYPTION_KEY)
51950
+ }
51951
+ });
51952
+ return {
51953
+ db,
51954
+ audit: opts.audit,
51955
+ sql,
51956
+ cipher,
51957
+ store,
51958
+ service,
51959
+ bindings,
51960
+ exchanger,
51961
+ specCache: specCache.cache,
51962
+ brokerCallbackBaseUrl,
51963
+ async close() {
51964
+ await sql.end({ timeout: 5 }).catch(() => void 0);
51965
+ await specCache.close();
51966
+ }
51967
+ };
51968
+ })();
51969
+ cached = building;
51970
+ building.catch(() => {
51971
+ if (cached === building) cached = void 0;
51905
51972
  });
51906
- return {
51907
- db,
51908
- audit: opts.audit,
51909
- sql,
51910
- cipher,
51911
- store,
51912
- service,
51913
- bindings,
51914
- exchanger,
51915
- specCache: specCache.cache,
51916
- brokerCallbackBaseUrl,
51917
- async close() {
51918
- await sql.end({ timeout: 5 }).catch(() => void 0);
51919
- await specCache.close();
51920
- }
51921
- };
51922
- })();
51973
+ }
51923
51974
  return cached;
51924
51975
  };
51925
51976
  const close = async () => {
@@ -65943,6 +65994,7 @@ var init_load_project_runtime = __esmMin((() => {
65943
65994
  let setActiveStepReader;
65944
65995
  let approvalRequest;
65945
65996
  let setConnectorInvoker;
65997
+ let setEchoGuardReader;
65946
65998
  let connectorInvoker;
65947
65999
  let setActionInvoker;
65948
66000
  let actionInvoker;
@@ -65958,6 +66010,7 @@ var init_load_project_runtime = __esmMin((() => {
65958
66010
  setApprovalRecorder = sdk.setApprovalRecorder;
65959
66011
  setActiveStepReader = sdk.setActiveStepReader;
65960
66012
  setConnectorInvoker = sdk.setConnectorInvoker;
66013
+ setEchoGuardReader = sdk.setEchoGuardReader;
65961
66014
  connectorInvoker = sdk.createConnectInvoker?.();
65962
66015
  setActionInvoker = sdk.setActionInvoker;
65963
66016
  actionInvoker = sdk.createActionInvoker?.();
@@ -66015,6 +66068,7 @@ var init_load_project_runtime = __esmMin((() => {
66015
66068
  setActiveStepReader,
66016
66069
  approvalRequest,
66017
66070
  setConnectorInvoker,
66071
+ setEchoGuardReader,
66018
66072
  connectorInvoker,
66019
66073
  setActionInvoker,
66020
66074
  actionInvoker,
@@ -66679,11 +66733,49 @@ var init_runtime_approval_plants = __esmMin((() => {
66679
66733
  }));
66680
66734
  //#endregion
66681
66735
  //#region ../../libs/runtime/workspace-runtime/src/lib/workflow/runtime-connector-plants.ts
66682
- var plantConnectorInvoker, plantActionInvoker;
66736
+ var owningWorkflow, echoOwner, unresolvedEcho, resolveEcho, echoRecordError, plantConnectorInvoker, plantActionInvoker, ECHO_WINDOW_MS, renderEcho, echoGuardUnavailable, plantEchoGuard;
66683
66737
  var init_runtime_connector_plants = __esmMin((() => {
66738
+ init_src$4();
66739
+ init_src$13();
66684
66740
  init_call_ordinal();
66685
66741
  init_monotonic_span();
66686
66742
  init_project_runtime();
66743
+ owningWorkflow = () => {
66744
+ const owner = getExecutionOwner();
66745
+ return owner?.kind === "workflow" ? owner.name : void 0;
66746
+ };
66747
+ echoOwner = () => {
66748
+ const workflowName = owningWorkflow();
66749
+ return workflowName !== void 0 ? { workflowName } : {};
66750
+ };
66751
+ unresolvedEcho = (echoPath) => ({
66752
+ echoPath,
66753
+ echo: null,
66754
+ echoUnresolved: true,
66755
+ ...echoOwner()
66756
+ });
66757
+ resolveEcho = (echoPath, output, label) => {
66758
+ const owner = echoOwner();
66759
+ const echo = getPath(output, echoPath);
66760
+ if (echo !== void 0 && echo !== null && echo !== "") return {
66761
+ echoPath,
66762
+ echo,
66763
+ ...owner
66764
+ };
66765
+ console.warn(`[connector] ${label}: echoPath "${echoPath}" resolved to nothing (missing, null or empty) in the response, so the run step records no echo value and the self-write guard cannot answer for this call`);
66766
+ return {
66767
+ echoPath,
66768
+ echo: null,
66769
+ echoUnresolved: true,
66770
+ ...owner
66771
+ };
66772
+ };
66773
+ echoRecordError = (label, cause) => {
66774
+ const error = /* @__PURE__ */ new Error(`[connector] ${label}: the provider call succeeded but its echo record could not be written (${cause instanceof Error ? cause.message : String(cause)}), so nothing can recognise this write when the trigger delivers it back. The step stops here.`);
66775
+ error.code = "echo_record_failed";
66776
+ error.fatal = true;
66777
+ return error;
66778
+ };
66687
66779
  plantConnectorInvoker = (rt, ctx) => {
66688
66780
  if (!rt.setConnectorInvoker || !rt.connectorInvoker) return;
66689
66781
  const baseInvoker = rt.connectorInvoker;
@@ -66697,14 +66789,15 @@ var init_runtime_connector_plants = __esmMin((() => {
66697
66789
  const span = startSpan();
66698
66790
  const ordinal = stepInfo ? nextConnOrdinal(runId ?? "", stepInfo.stepId, stepInfo.attempt) : 0;
66699
66791
  const externalStepId = stepInfo ? `${stepInfo.stepId}::conn::${ordinal}` : randomUUID();
66700
- const record = (status, extra) => {
66701
- if (!runId) return;
66792
+ const label = `${args.connector}/${args.operation}`;
66793
+ const writeRow = (status, extra, echo) => {
66794
+ if (!runId) return void 0;
66702
66795
  const { completedAt, durationMs } = closeSpan(span);
66703
- recordActivityStep({
66796
+ return recordActivityStep({
66704
66797
  runId,
66705
66798
  externalStepId,
66706
66799
  type: "connection",
66707
- name: `${args.connector}/${args.operation}`,
66800
+ name: label,
66708
66801
  status,
66709
66802
  startedAt: span.startedAt.toISOString(),
66710
66803
  completedAt: completedAt.toISOString(),
@@ -66712,26 +66805,52 @@ var init_runtime_connector_plants = __esmMin((() => {
66712
66805
  input: {
66713
66806
  connector: args.connector,
66714
66807
  operation: args.operation,
66715
- args: args.args
66808
+ args: args.args,
66809
+ ...echo ?? {}
66716
66810
  },
66717
66811
  ...extra,
66718
66812
  enclosingExternalStepId: stepInfo?.stepId ?? null
66719
- }).catch((err) => {
66813
+ });
66814
+ };
66815
+ const forget = (write) => {
66816
+ write?.catch((err) => {
66720
66817
  console.warn(`[connector] run-step write failed for run ${runId}: ${err instanceof Error ? err.message : String(err)}`);
66721
66818
  });
66722
66819
  };
66820
+ let result;
66723
66821
  try {
66724
- const result = await baseInvoker.invoke(args);
66725
- record("completed", { output: result.output });
66726
- return result;
66822
+ result = await baseInvoker.invoke(args);
66727
66823
  } catch (err) {
66728
66824
  const code = err && typeof err === "object" && "code" in err ? err.code : void 0;
66729
- record("failed", { error: {
66825
+ const echo = typeof args.echoPath === "string" ? unresolvedEcho(args.echoPath) : void 0;
66826
+ if (echo) console.warn(`[connector] ${label}: the call failed after naming an echoPath, so it may have landed with nothing recorded — the run step keeps an unresolved marker and the self-write guard will refuse rather than answer for this connector`);
66827
+ const write = writeRow("failed", { error: {
66730
66828
  message: err instanceof Error ? err.message : String(err),
66731
66829
  ...typeof code === "string" ? { code } : {}
66732
- } });
66830
+ } }, echo);
66831
+ if (echo) await write?.catch((cause) => {
66832
+ console.warn(`[connector] ${label}: could not record the unresolved echo marker: ${cause instanceof Error ? cause.message : String(cause)}`);
66833
+ });
66834
+ else forget(write);
66733
66835
  throw err;
66734
66836
  }
66837
+ if (typeof args.echoPath !== "string") {
66838
+ forget(writeRow("completed", { output: result.output }));
66839
+ return result;
66840
+ }
66841
+ const echo = resolveEcho(args.echoPath, result.output, label);
66842
+ const write = writeRow("completed", { output: result.output }, echo);
66843
+ if (write === void 0) console.warn(`[connector] ${label}: this call named an echoPath but runs with no invocation context, so no run step records its echo and the self-write guard cannot recognise the delivery this write causes`);
66844
+ if (write !== void 0) {
66845
+ let rowId;
66846
+ try {
66847
+ rowId = await write;
66848
+ } catch (cause) {
66849
+ throw echoRecordError(label, cause);
66850
+ }
66851
+ if (rowId === null) throw echoRecordError(label, /* @__PURE__ */ new Error(`the store wrote no row for run ${runId}`));
66852
+ }
66853
+ return result;
66735
66854
  } });
66736
66855
  } else rt.setConnectorInvoker(baseInvoker);
66737
66856
  };
@@ -66795,6 +66914,34 @@ var init_runtime_connector_plants = __esmMin((() => {
66795
66914
  }
66796
66915
  } });
66797
66916
  };
66917
+ ECHO_WINDOW_MS = 3600 * 1e3;
66918
+ renderEcho = (value) => String(value);
66919
+ echoGuardUnavailable = (connector, workflowName) => {
66920
+ const scope = workflowName ? `this workflow ("${workflowName}"), or a caller with no workflow owner,` : "some caller on this connector";
66921
+ const error = /* @__PURE__ */ new Error(`isOwnEcho("${connector}"): a write by ${scope} inside the last hour named an echoPath that resolved to nothing, so the value it produced was never recorded. The guard cannot tell your own write from somebody else's change. Find the echoPath that resolved to nothing — the runtime warned which one when it wrote the row.`);
66922
+ error.code = "echo_guard_unavailable";
66923
+ return error;
66924
+ };
66925
+ plantEchoGuard = (rt, ctx) => {
66926
+ if (!rt.setEchoGuardReader) return;
66927
+ const findEchoRecord = ctx.invocations?.findEchoRecord?.bind(ctx.invocations);
66928
+ if (!findEchoRecord) {
66929
+ rt.setEchoGuardReader(void 0);
66930
+ return;
66931
+ }
66932
+ rt.setEchoGuardReader({ isOwnEcho: async ({ connector, value }) => {
66933
+ const workflowName = owningWorkflow();
66934
+ const { matched, unresolved } = await findEchoRecord({
66935
+ connector,
66936
+ value: renderEcho(value),
66937
+ workflowName: workflowName ?? null,
66938
+ windowMs: ECHO_WINDOW_MS
66939
+ });
66940
+ if (matched) return true;
66941
+ if (unresolved) throw echoGuardUnavailable(connector, workflowName);
66942
+ return false;
66943
+ } });
66944
+ };
66798
66945
  }));
66799
66946
  //#endregion
66800
66947
  //#region ../../libs/runtime/workspace-runtime/src/lib/workflow/runtime-sdk-plants.ts
@@ -66858,6 +67005,7 @@ var init_runtime_sdk_plants = __esmMin((() => {
66858
67005
  plantPromptUseReporter(rt, ctx);
66859
67006
  plantApprovalRecorder(rt, ctx);
66860
67007
  plantConnectorInvoker(rt, ctx);
67008
+ plantEchoGuard(rt, ctx);
66861
67009
  plantActionInvoker(rt, ctx);
66862
67010
  return rt;
66863
67011
  };
@@ -66884,6 +67032,7 @@ var init_runtime_loader = __esmMin((() => {
66884
67032
  rt.setPromptUseReporter?.(void 0);
66885
67033
  rt.setApprovalRecorder?.(void 0);
66886
67034
  rt.setConnectorInvoker?.(void 0);
67035
+ rt.setEchoGuardReader?.(void 0);
66887
67036
  rt.setActionInvoker?.(void 0);
66888
67037
  rt.setActiveStepReader?.(void 0);
66889
67038
  planted = void 0;
@@ -71153,7 +71302,7 @@ var calls_workflow_ts_default = "import { z } from \"zod\";\nimport { streamDeep
71153
71302
  var plain_workflow_ts_default = "import { z } from \"zod\";\n\n/**\n * Workflow \"{{name}}\" — a durable pipeline.\n *\n * Each `\"use step\"` is a durable checkpoint: kill the runtime mid-run and it\n * resumes from the last completed step instead of replaying everything.\n */\n\n// THE contract — sibling exports next to the bare workflow function. The build\n// harvests these (by convention) into the manifest + a live validator. The\n// parameter type is DERIVED from `inputSchema` and the RETURN type from\n// `outputSchema` (both `z.infer`), so neither side can drift from the contract\n// the build harvests. Without the return annotation the schema is published to\n// Studio and the generated types and never compared to what the body returns\n// (PLA-1272).\nexport const inputSchema = z.object({\n message: z.string().describe(\"The input to process\"),\n});\n\nexport const outputSchema = z.object({\n message: z.string(),\n processed: z.boolean(),\n});\n\ntype {{Name}}Input = z.infer<typeof inputSchema>;\n\nexport async function {{export}}(input: {{Name}}Input): Promise<z.infer<typeof outputSchema>> {\n \"use workflow\";\n\n const result = await processMessage(input); // step 1 — durable checkpoint\n return result;\n}\n\nasync function processMessage(input: {{Name}}Input) {\n \"use step\";\n // Replace this with the deterministic work of your step (DB write, HTTP call,\n // an LLM call, …). Returning a value records it as the step's checkpoint.\n return { message: input.message, processed: true };\n}\n";
71154
71303
  //#endregion
71155
71304
  //#region src/services/scaffold/template-loader.ts
71156
- var SDK_TEMPLATE_VERSION = "0.4.1";
71305
+ var SDK_TEMPLATE_VERSION = "0.5.0";
71157
71306
  var PREFIX = "./templates/";
71158
71307
  var templates = new Map(Object.entries(/* @__PURE__ */ Object.assign({
71159
71308
  "./templates/deep-agents/index.ts.tmpl": index_ts_default,
@@ -76178,6 +76327,15 @@ ${objectStore}
76178
76327
  # MinIO addresses buckets by path. A managed bucket that only answers to the
76179
76328
  # bucket-as-subdomain form (AWS S3, Railway Buckets) needs this set to false.
76180
76329
  STACKBONE_S3_FORCE_PATH_STYLE: \${STACKBONE_S3_FORCE_PATH_STYLE:-true}
76330
+ # PLA-1286 — WHO OWNS THE PLATFORM SCHEMA IN THE DATABASE BELOW. This is the
76331
+ # only topology where the agent and the control plane share one Postgres,
76332
+ # and both of them declare \`connection_grant\`: the control plane through
76333
+ # its db-core migrations, the agent through its own boot DDL. The control
76334
+ # plane's statement is a bare CREATE TABLE and dies if the table is already
76335
+ # there, so the agent stands down here and lets the migrations create them.
76336
+ # Not overridable from .env on purpose: it is a fact about this compose file,
76337
+ # not a preference, and a box that shares no database never sees this line.
76338
+ STACKBONE_PLATFORM_SCHEMA_OWNER: control-plane
76181
76339
  ${sharedIdentity}
76182
76340
  # REPLACES the agent's built-in origin allowlist — it does not add to it. The
76183
76341
  # dashboard is a different ORIGIN from the agent (different port), so its
@@ -79028,7 +79186,9 @@ function renderOperation(op) {
79028
79186
  * time (`call-connector.ts`), and no generated signature declared it — so
79029
79187
  * `idempotent` was reachable only through the untyped `.call(...)`, which is not
79030
79188
  * the path a creator is steered to. Typed as the whole options object rather than
79031
- * one field, because PLA-1351's `echoPath` lands in exactly the same place.
79189
+ * one field, which is what carried PLA-1351's `echoPath` in with no change here:
79190
+ * the option landed on `CallConnectorOptions` and every generated signature already
79191
+ * accepted it.
79032
79192
  *
79033
79193
  * It is ALWAYS second, and `renderParam` is what guarantees that: an operation
79034
79194
  * with nothing to put in `args` still prints `args?: undefined` ahead of this. The
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stackbone/cli",
3
- "version": "0.4.1",
3
+ "version": "0.5.0",
4
4
  "license": "UNLICENSED",
5
5
  "type": "module",
6
6
  "bin": {
Binary file
Binary file