@stackbone/cli 0.4.0 → 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/main.js CHANGED
@@ -1427,7 +1427,9 @@ var init_errors$1 = __esmMin((() => {
1427
1427
  "connector_installation_required",
1428
1428
  "principal_required",
1429
1429
  "access_denied",
1430
- "invalid_grant"
1430
+ "invalid_grant",
1431
+ "precondition_failed",
1432
+ "cardinality_mismatch"
1431
1433
  ];
1432
1434
  BROKER_ERROR_CODES = z.enum([...CONNECTOR_EXECUTION_ERROR_CODES, ...BROKER_SPECIFIC_ERROR_CODES]);
1433
1435
  z.object({
@@ -1460,6 +1462,19 @@ var init_errors$1 = __esmMin((() => {
1460
1462
  connector_installation_required: false,
1461
1463
  /** The refresh token is dead. Needs a fresh authorization, not another try. */
1462
1464
  invalid_grant: false,
1465
+ /**
1466
+ * A write carrying a precondition matched no rows: somebody moved the row
1467
+ * between the read and the write, so the write was NOT applied (PLA-1403).
1468
+ *
1469
+ * Terminal, and this one is the whole point rather than a classification
1470
+ * detail. The condition is a fact about the provider's current state, so the
1471
+ * identical call refuses identically; and a retry is precisely the wrong move,
1472
+ * because the value the caller is guarding on is the stale one. What has to
1473
+ * happen next is a fresh read, which only the caller can do.
1474
+ */
1475
+ precondition_failed: false,
1476
+ /** A narrower target is needed; repeating the same write exceeds the same limit. */
1477
+ cardinality_mismatch: false,
1463
1478
  /** Transport, or the provider answering 5xx. */
1464
1479
  execute_failed: true,
1465
1480
  /** The deadline passed. The next attempt may fit inside it. */
@@ -1479,7 +1494,6 @@ var init_errors$1 = __esmMin((() => {
1479
1494
  no_token: true,
1480
1495
  user_authorization_required: true
1481
1496
  };
1482
- Object.entries(BROKER_ERROR_RETRYABILITY).filter(([, retryable]) => !retryable).map(([code]) => code);
1483
1497
  REASON_ALIASES = { app_not_installed: "connector_installation_required" };
1484
1498
  }));
1485
1499
  //#endregion
@@ -1969,7 +1983,19 @@ var init_connect_surface = __esmMin((() => {
1969
1983
  error: z.object({
1970
1984
  /** The broker taxonomy code, e.g. `not_found`, `no_token`, `execute_failed`. */
1971
1985
  code: z.string(),
1972
- message: z.string()
1986
+ message: z.string(),
1987
+ /**
1988
+ * The broker's own retry classification, decided one code at a time and
1989
+ * overridable per site. It has to cross the wire (PLA-1349): the workflow
1990
+ * engine re-runs a failed step unless the error is fatal, and the SDK cannot
1991
+ * mark it fatal from a code alone without keeping a second copy of the
1992
+ * taxonomy that would drift from the broker's.
1993
+ *
1994
+ * Optional because an older box may not send it, and an absent value means
1995
+ * "not classified" rather than "retryable" — the SDK leaves such a failure
1996
+ * retryable, which is the safe direction for a transient outage.
1997
+ */
1998
+ retryable: z.boolean().optional()
1973
1999
  })
1974
2000
  });
1975
2001
  connectEvictResponseSchema = z.object({
@@ -9694,14 +9720,20 @@ var init_connector_invoker = __esmMin((() => {
9694
9720
  * @param connector The connect connector id registered in Studio.
9695
9721
  * @param operation The operation / tool id to run on that connector.
9696
9722
  * @param args The operation arguments (path/query/body params). Defaults `{}`.
9697
- * @param opts Optional `{ principal }` — defaults to `{ type: 'app' }`.
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.
9698
9728
  */
9699
9729
  async function callConnector(connector, operation, args, opts) {
9700
9730
  return (await getConnectorInvoker().invoke({
9701
9731
  connector,
9702
9732
  operation,
9703
9733
  ...args !== void 0 ? { args } : {},
9704
- ...opts?.principal !== void 0 ? { principal: opts.principal } : {}
9734
+ ...opts?.principal !== void 0 ? { principal: opts.principal } : {},
9735
+ ...opts?.idempotent !== void 0 ? { idempotent: opts.idempotent } : {},
9736
+ ...opts?.echoPath !== void 0 ? { echoPath: opts.echoPath } : {}
9705
9737
  })).output;
9706
9738
  }
9707
9739
  var connectorHandle, connection;
@@ -14339,6 +14371,29 @@ var createInvocationsStore = (opts) => {
14339
14371
  ]);
14340
14372
  return rows[0]?.id ?? null;
14341
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
+ },
14342
14397
  async annotatePromptUse(input) {
14343
14398
  if (!isUuid(input.runId)) return;
14344
14399
  assertOpen();
@@ -15221,7 +15276,8 @@ var createTriggerSubscriptionsStore = (opts) => {
15221
15276
  assertOpen();
15222
15277
  await pool.query(`UPDATE stackbone_platform.trigger_deliveries
15223
15278
  SET status = 'rejected', issues = $3::jsonb, updated_at = now()
15224
- WHERE subscription_id = $1::uuid AND provider_item_id = $2`, [
15279
+ WHERE subscription_id = $1::uuid AND provider_item_id = $2
15280
+ AND status IN ('claimed', 'starting')`, [
15225
15281
  subscriptionId,
15226
15282
  providerItemId,
15227
15283
  JSON.stringify(issues)
@@ -15669,7 +15725,7 @@ var emulatorCors = (allowlist) => cors({
15669
15725
  var STACKBONE_CLI_BUILD_VERSION, EMULATOR_CAPABILITY_ROUTES, EMULATOR_CAPABILITIES, buildContractResponse;
15670
15726
  var init_contract = __esmMin((() => {
15671
15727
  init_src$19();
15672
- STACKBONE_CLI_BUILD_VERSION = "0.4.0";
15728
+ STACKBONE_CLI_BUILD_VERSION = "0.5.0";
15673
15729
  EMULATOR_CAPABILITY_ROUTES = {
15674
15730
  "database.postgres_direct": "/api/db",
15675
15731
  "rag.basic": "/api/rag",
@@ -30692,6 +30748,8 @@ var init_types$1 = __esmMin((() => {
30692
30748
  description: z.string().optional(),
30693
30749
  parameters: z.array(operationParameterSchema).optional(),
30694
30750
  requestBodySchema: z.unknown().optional(),
30751
+ /** Original Swagger body parameter name; allows body values alongside query filters. */
30752
+ bodyArgName: z.string().optional(),
30695
30753
  responseSchema: z.unknown().optional(),
30696
30754
  /**
30697
30755
  * This operation's body is BYTES, not JSON. The executor then resolves the
@@ -31615,7 +31673,9 @@ var init_types = __esmMin((() => {
31615
31673
  /** A policy denial on a VALID key: no credential eviction, no retry loop. */
31616
31674
  accessDenied: z.array(z.string()).default([]),
31617
31675
  /** A data conflict on a write (unique / FK violation): terminal, handleable. */
31618
- conflict: z.array(z.string()).default([])
31676
+ conflict: z.array(z.string()).default([]),
31677
+ /** The provider rejected a write that exceeded its requested row limit. */
31678
+ cardinalityMismatch: z.array(z.string()).optional()
31619
31679
  }).optional(),
31620
31680
  /**
31621
31681
  * The query parameter that carries "which columns to return" on this
@@ -32269,8 +32329,8 @@ var init_github_connector = __esmMin((() => {
32269
32329
  triggers: [{
32270
32330
  id: "commit-pushed",
32271
32331
  displayName: "New commit by date",
32272
- 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.",
32273
- 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.",
32274
32334
  sampleData: {
32275
32335
  sha: "11589d462dc2b29714240e03ce310e7b15aa94c3",
32276
32336
  url: "https://github.com/stackbone/stackbone/commit/11589d462dc2b29714240e03ce310e7b15aa94c3",
@@ -32351,8 +32411,8 @@ var init_github_connector = __esmMin((() => {
32351
32411
  }, {
32352
32412
  id: "issue-updated",
32353
32413
  displayName: "Issue updated",
32354
- 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.",
32355
- 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.",
32356
32416
  sampleData: {
32357
32417
  issueId: 5320382826,
32358
32418
  number: 14317,
@@ -32748,7 +32808,7 @@ var init_google_drive_connector = __esmMin((() => {
32748
32808
  post: {
32749
32809
  operationId: "drive.files.create",
32750
32810
  summary: "Create a folder, or a file with metadata but no content",
32751
- 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.",
32752
32812
  "x-stackbone-version-path": "modifiedTime",
32753
32813
  parameters: [FIELDS_PARAM, SUPPORTS_ALL_DRIVES_PARAM],
32754
32814
  requestBody: {
@@ -32818,7 +32878,7 @@ var init_google_drive_connector = __esmMin((() => {
32818
32878
  patch: {
32819
32879
  operationId: "drive.files.update",
32820
32880
  summary: "Rename a file, move it between folders, or set its metadata",
32821
- 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.",
32822
32882
  "x-stackbone-version-path": "modifiedTime",
32823
32883
  parameters: [
32824
32884
  {
@@ -33126,8 +33186,8 @@ var init_google_drive_connector = __esmMin((() => {
33126
33186
  triggers: [{
33127
33187
  id: "file-added-to-folder",
33128
33188
  displayName: "File added to folder",
33129
- 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.",
33130
- 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.",
33131
33191
  sampleData: {
33132
33192
  fileId: "1a2B3cD4eF5gH6iJ7kL8mN9oP",
33133
33193
  name: "Weekly sync 2026-08-25.pdf",
@@ -33347,8 +33407,8 @@ var init_linear_connector = __esmMin((() => {
33347
33407
  {
33348
33408
  id: "issue-created",
33349
33409
  displayName: "Issue created",
33350
- 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.",
33351
- 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.",
33352
33412
  sampleData: {
33353
33413
  issueId: "a1b2c3d4-0000-0000-0000-000000000000",
33354
33414
  identifier: "PLA-812",
@@ -33437,8 +33497,8 @@ var init_linear_connector = __esmMin((() => {
33437
33497
  {
33438
33498
  id: "comment-created",
33439
33499
  displayName: "Comment created",
33440
- 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.",
33441
- 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.",
33442
33502
  sampleData: {
33443
33503
  commentId: "e5f6a7b8-0000-0000-0000-000000000000",
33444
33504
  body: "Shipped on `next`, closing.",
@@ -33530,8 +33590,8 @@ var init_linear_connector = __esmMin((() => {
33530
33590
  {
33531
33591
  id: "issue-updated",
33532
33592
  displayName: "Issue updated",
33533
- 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.",
33534
- 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.",
33535
33595
  sampleData: {
33536
33596
  issueId: "a1b2c3d4-0000-0000-0000-000000000000",
33537
33597
  identifier: "PLA-812",
@@ -33944,14 +34004,15 @@ var init_supabase_connector = __esmMin((() => {
33944
34004
  "42"
33945
34005
  ],
33946
34006
  accessDenied: ["42501"],
33947
- conflict: ["23"]
34007
+ conflict: ["23"],
34008
+ cardinalityMismatch: ["PGRST124"]
33948
34009
  },
33949
34010
  scopes: [],
33950
34011
  triggers: [{
33951
34012
  id: "row-inserted",
33952
34013
  displayName: "Row inserted or changed",
33953
- 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.",
33954
- 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.",
33955
34016
  sampleData: {
33956
34017
  table: "todos",
33957
34018
  row: {
@@ -34465,6 +34526,7 @@ function buildOperation(rawPath, resolvedPath, method, operation, pathLevelParam
34465
34526
  const parameters = [];
34466
34527
  const seen = /* @__PURE__ */ new Set();
34467
34528
  let bodyParamSchema;
34529
+ let bodyArgName;
34468
34530
  for (let i = rawParameters.length - 1; i >= 0; i--) {
34469
34531
  const raw = derefParameter(rawParameters[i], root);
34470
34532
  if (raw === void 0) {
@@ -34474,7 +34536,10 @@ function buildOperation(rawPath, resolvedPath, method, operation, pathLevelParam
34474
34536
  if (isRecord$2(raw) && source === "swagger") {
34475
34537
  const location = asString$1(raw["in"]);
34476
34538
  if (location === "body") {
34477
- if (bodyParamSchema === void 0 && raw["schema"] !== void 0) bodyParamSchema = raw["schema"];
34539
+ if (bodyParamSchema === void 0 && raw["schema"] !== void 0) {
34540
+ bodyParamSchema = raw["schema"];
34541
+ bodyArgName = asString$1(raw["name"]);
34542
+ }
34478
34543
  continue;
34479
34544
  }
34480
34545
  if (location === "formData") {
@@ -34507,6 +34572,7 @@ function buildOperation(rawPath, resolvedPath, method, operation, pathLevelParam
34507
34572
  ...description !== void 0 ? { description } : {},
34508
34573
  ...parameters.length > 0 ? { parameters } : {},
34509
34574
  ...requestBodySchema !== void 0 ? { requestBodySchema } : {},
34575
+ ...bodyArgName !== void 0 ? { bodyArgName } : {},
34510
34576
  ...responseSchema !== void 0 ? { responseSchema } : {},
34511
34577
  ...rawBody ? { rawBody } : {},
34512
34578
  ...rawResponse !== void 0 ? { rawResponse } : {},
@@ -36148,17 +36214,18 @@ function narrows(value, narrowingOperators) {
36148
36214
  return !(operator === "is" && NEGATED_IS.has(rest.toLowerCase()));
36149
36215
  }
36150
36216
  /**
36151
- * Should this call be refused as an unfiltered delete?
36217
+ * Should this call be refused as an unfiltered DELETE or PATCH?
36152
36218
  *
36153
36219
  * Returns the refusal — `invalid_args`, not retryable, naming the columns that
36154
- * would narrow it — or `null` when the call may proceed. Applies to DELETE
36155
- * only: a read with no filter is a legitimate "select everything", and an
36156
- * unfiltered PATCH, while sweeping, writes the caller's own values rather than
36157
- * destroying data it never saw.
36220
+ * would narrow it — or `null` when the call may proceed. DELETE and PATCH
36221
+ * both affect existing rows. Reads and inserts keep their existing behavior.
36222
+ * Only declared query arguments count; values inside the named body do not.
36158
36223
  */
36159
36224
  function unfilteredDeleteError(def, operation, args) {
36160
36225
  const guard = def.unfilteredDeleteGuard;
36161
- if (guard === void 0 || operation.method.toUpperCase() !== "DELETE") return null;
36226
+ const method = operation.method.toUpperCase();
36227
+ if (guard === void 0 || method !== "DELETE" && method !== "PATCH") return null;
36228
+ const effect = method === "PATCH" ? "update" : "remove";
36162
36229
  const nonFilter = new Set(guard.nonFilterParams);
36163
36230
  const filterParams = (operation.parameters ?? []).filter((param) => param.in === "query" && !nonFilter.has(param.name)).map((param) => param.name);
36164
36231
  const present = filterParams.filter((name) => {
@@ -36169,9 +36236,9 @@ function unfilteredDeleteError(def, operation, args) {
36169
36236
  if (narrowingOperators === void 0) {
36170
36237
  if (present.length > 0) return null;
36171
36238
  } else if (present.some((name) => narrows(args[name], narrowingOperators))) return null;
36172
- if (narrowingOperators !== void 0 && present.length > 0) return refusal(operation, `no filter it carries narrows which rows go, so it would remove every row in the table.`, `Filters read: ${present.join(", ")}. Narrow it with one of: ${narrowingOperators.join(", ")}.`);
36173
- if (filterParams.length === 0) return refusal(operation, "a delete with no row filter would remove every row in the table.", "The operation declares no filterable parameter, so nothing could narrow it.");
36174
- return refusal(operation, "a delete with no row filter would remove every row in the table.", `Add at least one column filter: ${filterParams.join(", ")}.`);
36239
+ if (narrowingOperators !== void 0 && present.length > 0) return refusal(operation, `no filter it carries narrows which rows go, so it would ${effect} every row in the table.`, `Filters read: ${present.join(", ")}. Narrow it with one of: ${narrowingOperators.join(", ")}.`);
36240
+ if (filterParams.length === 0) return refusal(operation, `a ${method.toLowerCase()} with no row filter would ${effect} every row in the table.`, "The operation declares no filterable parameter, so nothing could narrow it.");
36241
+ return refusal(operation, `a ${method.toLowerCase()} with no row filter would ${effect} every row in the table.`, `Add at least one column filter: ${filterParams.join(", ")}.`);
36175
36242
  }
36176
36243
  /** The one refusal shape, so every path names the operation the same way. */
36177
36244
  function refusal(operation, reason, remedy) {
@@ -36261,6 +36328,7 @@ function classifyProviderError(def, status, body, operationId) {
36261
36328
  if (matched !== null && provider !== null) {
36262
36329
  const said = providerSaid(provider);
36263
36330
  switch (matched) {
36331
+ case "cardinalityMismatch": return makeBrokerError("cardinality_mismatch", `operation "${operationId}" exceeded the provider's requested row limit: ${said}`);
36264
36332
  case "invalidArgs": return makeBrokerError("invalid_args", `operation "${operationId}" was refused by the provider as a bad request: ${said}`);
36265
36333
  case "accessDenied": return makeBrokerError("access_denied", `operation "${operationId}" was denied by the provider's own policy — the credential is valid, the policy said no: ${said}`, false);
36266
36334
  case "conflict": return makeBrokerError("execute_failed", `operation "${operationId}" hit a constraint conflict: ${said}`, false);
@@ -36295,7 +36363,8 @@ function longestMatch(classes, code) {
36295
36363
  const lists = [
36296
36364
  ["invalidArgs", classes.invalidArgs],
36297
36365
  ["accessDenied", classes.accessDenied],
36298
- ["conflict", classes.conflict]
36366
+ ["conflict", classes.conflict],
36367
+ ["cardinalityMismatch", classes.cardinalityMismatch ?? []]
36299
36368
  ];
36300
36369
  for (const [kind, prefixes] of lists) for (const prefix of prefixes) if (code.startsWith(prefix) && (best === null || prefix.length > best.prefix.length)) best = {
36301
36370
  prefix,
@@ -36578,14 +36647,66 @@ async function runOperation(def, operation, args, token, http, timeoutMs, opts =
36578
36647
  headerValue: responseHeader(response, continuation.urlHeader)
36579
36648
  }) : void 0;
36580
36649
  const output = response.ok && wantsRawResponse(operation, args) ? await storeRawResponse(operation, args, response, opts) : await parseResponse(response);
36650
+ const matchedRows = matchedRowTotal(response);
36581
36651
  return {
36582
36652
  status: response.status,
36583
36653
  ok: response.ok,
36584
36654
  output,
36655
+ ...matchedRows !== void 0 ? { matchedRows } : {},
36585
36656
  ...continuationUrl !== void 0 ? { continuationUrl } : {}
36586
36657
  };
36587
36658
  }
36588
36659
  /**
36660
+ * The TOTAL out of a `Content-Range` response header — how many rows the call
36661
+ * matched — or `undefined` when the provider did not count.
36662
+ *
36663
+ * `Content-Range` is `<first>-<last>/<total>`, and either half may be `*`. The
36664
+ * range says which rows came back in THIS response and the total says how many
36665
+ * exist; only the total answers "did my filter hit anything", because a write
36666
+ * that asks for no representation returns no rows either way. Measured against
36667
+ * PostgREST v12.2.3: a matched update answers `0-0/1`, one that matched nothing
36668
+ * answers a star range over a zero total, and one that asked for no count
36669
+ * answers a star over a star (`docs/investigations/pla-1345/README-live.md`).
36670
+ *
36671
+ * A `*` total is dropped rather than read as zero. That distinction is the guard
36672
+ * on the whole feature: see `matchedRows`.
36673
+ */
36674
+ function matchedRowTotal(response) {
36675
+ const total = responseHeader(response, "Content-Range")?.split("/")[1];
36676
+ if (total === void 0 || total === "*") return void 0;
36677
+ const parsed = Number(total);
36678
+ return Number.isInteger(parsed) && parsed >= 0 ? parsed : void 0;
36679
+ }
36680
+ /**
36681
+ * Did a write carrying a precondition match nothing (PLA-1403)?
36682
+ *
36683
+ * Returns the terminal refusal, or `null` when the call may be reported as the
36684
+ * success it looks like. A conditional write sends the value it read as part of
36685
+ * the row filter, so a row somebody else moved matches nothing and is left
36686
+ * alone — the protection working. Without this the caller cannot tell, because
36687
+ * `execute` returns `{ ok: true, output }` and the count never leaves the
36688
+ * broker.
36689
+ *
36690
+ * TWO conditions, and both are load-bearing:
36691
+ *
36692
+ * * The method changes EXISTING rows: `PATCH` and `DELETE`, the same pair
36693
+ * `unfilteredDeleteError` guards. A `GET` that matches nothing is a correct
36694
+ * answer to a question and must stay a success — the one case this must
36695
+ * never break. `POST` is out because an insert matches nothing by
36696
+ * definition, and `PUT` is out because PostgREST upserts on it: a PUT that
36697
+ * matches nothing CREATES the row, so zero matched is not a refusal there.
36698
+ * Unmeasured on our side, which is the second reason to leave it out.
36699
+ * * The provider reported a total of exactly zero, which it only does when the
36700
+ * caller asked for the count. Asking IS the declaration, so a write that did
36701
+ * not ask keeps the behaviour it has always had. An empty body is NOT the
36702
+ * trigger.
36703
+ */
36704
+ function unmatchedWriteError(operation, matchedRows, operationId) {
36705
+ const method = operation.method.toUpperCase();
36706
+ if (matchedRows !== 0 || method !== "PATCH" && method !== "DELETE") return null;
36707
+ return makeBrokerError("precondition_failed", `operation "${operationId}" matched no rows, so it changed nothing — the row moved since it was read. Re-read it and decide.`);
36708
+ }
36709
+ /**
36589
36710
  * Render a parsed operation-call body to a short single-line string for an error
36590
36711
  * message. Shared by the action executor (here) and the inbound-trigger poll
36591
36712
  * context, so the two never drift. `empty` is returned for null/undefined (the
@@ -36599,13 +36720,36 @@ function formatOperationBody(output, opts = {}) {
36599
36720
  return max > 0 && text.length > max ? `${text.slice(0, max)}…` : text;
36600
36721
  }
36601
36722
  /**
36723
+ * Fallback classification after provider-specific body codes (PLA-1321).
36724
+ * Known input, access and resource refusals are terminal. Unclassified statuses
36725
+ * retain the existing unknown-outcome policy, including 409: conflict vocabulary
36726
+ * is outside this slice. The caller's idempotency option still governs that path.
36727
+ */
36728
+ function brokerCodeForProviderStatus(status) {
36729
+ switch (status) {
36730
+ case 400:
36731
+ case 405:
36732
+ case 422: return "invalid_args";
36733
+ case 403: return "access_denied";
36734
+ case 404:
36735
+ case 410: return "not_found";
36736
+ default: return "execute_failed";
36737
+ }
36738
+ }
36739
+ /**
36602
36740
  * Build the JSON request body for an OpenAPI operation. When the operation
36603
- * declares a request body, every payload key that is NOT a declared path/query/
36741
+ * names a Swagger body argument containing an object, send that object directly.
36742
+ * Otherwise, every payload key that is NOT a declared path/query/
36604
36743
  * header param becomes the body. Sets `content-type` when a body is produced.
36605
36744
  * Returns `undefined` for body-less operations (e.g. GET).
36606
36745
  */
36607
36746
  function buildRequestBody(operation, args, headers) {
36608
36747
  if (operation.requestBodySchema === void 0) return void 0;
36748
+ const namedBody = operation.bodyArgName === void 0 ? void 0 : args[operation.bodyArgName];
36749
+ if (namedBody !== null && typeof namedBody === "object") {
36750
+ headers["content-type"] = "application/json";
36751
+ return JSON.stringify(namedBody);
36752
+ }
36609
36753
  const consumed = /* @__PURE__ */ new Set();
36610
36754
  for (const param of operation.parameters ?? []) if (param.in === "path" || param.in === "query" || param.in === "header") consumed.add(param.name);
36611
36755
  for (const arg of brokerConsumedArgs(operation)) consumed.add(arg.name);
@@ -36786,6 +36930,41 @@ function fail(error) {
36786
36930
  error
36787
36931
  };
36788
36932
  }
36933
+ /**
36934
+ * A PROVIDER ROUND-TRIP that failed with an outcome nobody here can determine,
36935
+ * classified against the caller's own idempotency assertion (PLA-1349).
36936
+ *
36937
+ * `execute_failed` and `timeout` are the two retryable codes in
36938
+ * `BROKER_ERROR_RETRYABILITY`, and that default is right: a transport failure or
36939
+ * a gateway 5xx usually did not land. But they are also the only two where it
36940
+ * MIGHT have — the provider committed and the response was lost, or the deadline
36941
+ * fired after the write was accepted and the abort only closed the socket. A
36942
+ * durable step is retried whole, so retrying one of these re-sends the write and
36943
+ * one run creates two objects.
36944
+ *
36945
+ * Which of the two it is cannot be read off the code, the status or the connector
36946
+ * definition: `commentCreate` and `listComments` fail identically, and an
36947
+ * operation the provider deduplicates only deduplicates when the CALLER minted
36948
+ * the key. So the caller states it and this decides accordingly:
36949
+ *
36950
+ * - `false` → `retryable: false`. The SDK turns that into `fatal: true`, the
36951
+ * engine stops the step on its first failure, and a human reconciles. Same
36952
+ * trade the World already made for a dispatch past `httpTimeoutMs`.
36953
+ * - `true`, or NOT STATED → the code's own default, untouched. Passing
36954
+ * `undefined` as the override is byte-identical to omitting the argument, which
36955
+ * is what keeps an unstated option a true no-op.
36956
+ *
36957
+ * ONLY these two codes go through here. A code that was already terminal stays
36958
+ * terminal with no second decision, and a retryable code whose outcome is KNOWN —
36959
+ * a 401 the provider refused before doing anything, a stale-spec refusal, a 429
36960
+ * a rate limiter answered in front of the operation, a 408 for a request the
36961
+ * provider never received whole — keeps its retry, because there is nothing to
36962
+ * reconcile. Callers pass `undefined` for those rather than the assertion; see
36963
+ * the provider non-2xx exit.
36964
+ */
36965
+ function unknownOutcome(code, message, idempotent) {
36966
+ return makeBrokerError(code, message, idempotent === false ? false : void 0);
36967
+ }
36789
36968
  function messageOf$6(err) {
36790
36969
  return err instanceof Error ? err.message : String(err);
36791
36970
  }
@@ -36840,7 +37019,7 @@ var init_connect_action_executor = __esmMin((() => {
36840
37019
  this.resumableStallTimeoutMs = deps.resumableStallTimeoutMs ?? 45e3;
36841
37020
  }
36842
37021
  async execute(input) {
36843
- const { orgId, runId, connectConnectorId, connectOperationId, payload } = input;
37022
+ const { orgId, runId, connectConnectorId, connectOperationId, payload, idempotent } = input;
36844
37023
  const runDef = runId !== void 0 ? await this.deps.runConnectors?.resolve(runId, orgId, connectConnectorId) ?? null : null;
36845
37024
  const record = await this.deps.connectorRows?.resolve(orgId, connectConnectorId) ?? null;
36846
37025
  let def;
@@ -36903,13 +37082,13 @@ var init_connect_action_executor = __esmMin((() => {
36903
37082
  if (followUp === void 0) return this.missingFromSpec(specEntry, cached, `operation "${connectOperationId}" continues as "${declared.operationId}", which is not on connector "${connectConnectorId}"`);
36904
37083
  if (declared.operationId === operation.operationId) return fail(makeBrokerError("invalid_args", `operation "${connectOperationId}" declares itself as its own continuation; a two-phase call needs a second, different operation`));
36905
37084
  }
36906
- return this.runOpenApi(def, operation, payload, tokenResponse, caller, specEntry, cached, followUp);
37085
+ return this.runOpenApi(def, operation, payload, tokenResponse, caller, specEntry, cached, followUp, idempotent);
36907
37086
  }
36908
37087
  const tool = findTool(introspection.tools, connectOperationId);
36909
37088
  if (tool === void 0) return this.missingFromSpec(specEntry, cached, `tool "${connectOperationId}" not found on connector "${connectConnectorId}"`);
36910
- return this.runMcp(def, tool, payload, tokenResponse, caller);
37089
+ return this.runMcp(def, tool, payload, tokenResponse, caller, idempotent);
36911
37090
  }
36912
- async runOpenApi(def, operation, payload, token, caller, specEntry, cached, followUp) {
37091
+ async runOpenApi(def, operation, payload, token, caller, specEntry, cached, followUp, idempotent) {
36913
37092
  const args = isRecord$1(payload) ? payload : {};
36914
37093
  for (const phase of followUp === void 0 ? [operation] : [operation, followUp]) {
36915
37094
  const refused = unfilteredDeleteError(def, phase, args);
@@ -36933,11 +37112,12 @@ var init_connect_action_executor = __esmMin((() => {
36933
37112
  });
36934
37113
  }
36935
37114
  } catch (err) {
36936
- if (err instanceof ActionTimeoutError) return fail(makeBrokerError("timeout", `operation "${reported.operationId}" timed out`));
36937
- if (isTransferStalled(err)) return fail(makeBrokerError("timeout", `operation "${reported.operationId}" stalled mid-transfer: ${messageOf$6(err)}`));
36938
- if (err instanceof ContinuationError) return fail(makeBrokerError(err.code, err.message, err.retryable));
36939
- if (err instanceof RawBodyError || err instanceof RawResponseError) return fail(makeBrokerError(err.code, err.message));
36940
- return fail(makeBrokerError("execute_failed", messageOf$6(err)));
37115
+ if (err instanceof ActionTimeoutError) return fail(unknownOutcome("timeout", `operation "${reported.operationId}" timed out`, idempotent));
37116
+ if (isTransferStalled(err)) return fail(unknownOutcome("timeout", `operation "${reported.operationId}" stalled mid-transfer: ${messageOf$6(err)}`, idempotent));
37117
+ if (err instanceof ContinuationError) return fail(makeBrokerError(err.code, err.message, idempotent === false ? false : err.retryable));
37118
+ if (err instanceof RawResponseError) return fail(makeBrokerError(err.code, err.message, idempotent === false ? false : void 0));
37119
+ if (err instanceof RawBodyError) return fail(makeBrokerError(err.code, err.message));
37120
+ return fail(unknownOutcome("execute_failed", messageOf$6(err), idempotent));
36941
37121
  }
36942
37122
  const classified = classifyProviderError(def, result.status, result.output, reported.operationId);
36943
37123
  if (classified?.code === "access_denied") return fail(classified);
@@ -36948,8 +37128,13 @@ var init_connect_action_executor = __esmMin((() => {
36948
37128
  const body = formatOperationBody(result.output);
36949
37129
  if (specEntry !== null && cached?.servedFromCache() === true && isStaleSpecError(def, result.output)) return this.onStaleSpec(specEntry, `operation "${reported.operationId}" failed with status ${result.status} against a stale spec${body ? `: ${body}` : ""}`);
36950
37130
  if (classified !== null) return fail(classified);
36951
- return fail(makeBrokerError("execute_failed", `operation "${reported.operationId}" failed with status ${result.status}${body ? `: ${body}` : ""}`));
37131
+ const code = brokerCodeForProviderStatus(result.status);
37132
+ const message = `operation "${reported.operationId}" failed with status ${result.status}${body ? `: ${body}` : ""}`;
37133
+ if (code !== "execute_failed") return fail(makeBrokerError(code, message));
37134
+ return fail(unknownOutcome("execute_failed", message, result.status === 429 || result.status === 408 ? void 0 : idempotent));
36952
37135
  }
37136
+ const unmatched = unmatchedWriteError(operation, result.matchedRows, reported.operationId);
37137
+ if (unmatched !== null) return fail(unmatched);
36953
37138
  return {
36954
37139
  ok: true,
36955
37140
  output: result.output
@@ -36974,7 +37159,7 @@ var init_connect_action_executor = __esmMin((() => {
36974
37159
  ...this.deps.maxRawResponseBytes !== void 0 ? { maxRawResponseBytes: this.deps.maxRawResponseBytes } : {}
36975
37160
  };
36976
37161
  }
36977
- async runMcp(def, tool, payload, token, caller) {
37162
+ async runMcp(def, tool, payload, token, caller, idempotent) {
36978
37163
  if (this.deps.mcp === void 0) return fail(makeBrokerError("execute_failed", `connector "${def.id}" is an MCP connector but no MCP caller was wired`));
36979
37164
  let output;
36980
37165
  try {
@@ -36985,9 +37170,9 @@ var init_connect_action_executor = __esmMin((() => {
36985
37170
  token: token.token
36986
37171
  }), this.timeoutMs);
36987
37172
  } catch (err) {
36988
- if (err instanceof ActionTimeoutError) return fail(makeBrokerError("timeout", `tool "${tool.name}" timed out`));
37173
+ if (err instanceof ActionTimeoutError) return fail(unknownOutcome("timeout", `tool "${tool.name}" timed out`, idempotent));
36989
37174
  if (statusOf(err) === 401) return this.on401(def, caller, `tool "${tool.name}" returned 401`);
36990
- return fail(makeBrokerError("execute_failed", messageOf$6(err)));
37175
+ return fail(unknownOutcome("execute_failed", messageOf$6(err), idempotent));
36991
37176
  }
36992
37177
  return {
36993
37178
  ok: true,
@@ -51649,8 +51834,27 @@ var CONNECT_DDL = `
51649
51834
  * Ensure the connect tables exist in the emulator Postgres. Idempotent. Exported
51650
51835
  * so the workspace-server can run it once at boot (and the spec can run it before
51651
51836
  * exercising the routes) instead of paying the DDL on every request.
51652
- */
51653
- 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;
51654
51858
  const client = new Client({ connectionString });
51655
51859
  await client.connect();
51656
51860
  try {
@@ -51720,47 +51924,53 @@ var deriveBase64Key = (raw) => createHash("sha256").update(raw).digest().toStrin
51720
51924
  var makeContextFactory = (opts) => {
51721
51925
  let cached;
51722
51926
  const get = () => {
51723
- if (!cached) cached = (async () => {
51724
- await ensureConnectTables(opts.connectionString);
51725
- const cipher = await createSecretCipher(deriveBase64Key(opts.encryptionKey ?? DEV_CONNECT_ENCRYPTION_KEY));
51726
- const sql = postgres(opts.connectionString, {
51727
- max: 4,
51728
- onnotice: () => void 0
51729
- });
51730
- const db = drizzle(sql);
51731
- const store = new DrizzleBrokerStore(db, cipher);
51732
- const cache = createInMemoryTokenCache();
51733
- const specCache = createBoxSpecCache(opts.redisUrl);
51734
- const bindings = createInMemoryBindingStore();
51735
- const exchanger = new DefaultOAuthExchanger();
51736
- const brokerCallbackBaseUrl = opts.brokerCallbackBaseUrl ?? "http://127.0.0.1:4242/api/connect/callback";
51737
- const service = new ConnectService({
51738
- store,
51739
- exchanger,
51740
- cache,
51741
- bindings,
51742
- oauth: {
51743
- brokerCallbackUrl: brokerCallbackBaseUrl,
51744
- stateSecret: deriveBase64Key(opts.encryptionKey ?? DEV_CONNECT_ENCRYPTION_KEY)
51745
- }
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;
51746
51972
  });
51747
- return {
51748
- db,
51749
- audit: opts.audit,
51750
- sql,
51751
- cipher,
51752
- store,
51753
- service,
51754
- bindings,
51755
- exchanger,
51756
- specCache: specCache.cache,
51757
- brokerCallbackBaseUrl,
51758
- async close() {
51759
- await sql.end({ timeout: 5 }).catch(() => void 0);
51760
- await specCache.close();
51761
- }
51762
- };
51763
- })();
51973
+ }
51764
51974
  return cached;
51765
51975
  };
51766
51976
  const close = async () => {
@@ -51785,11 +51995,12 @@ var init_broker_refusal = __esmMin((() => {
51785
51995
  case "access_denied": return 401;
51786
51996
  case "invalid_args": return 400;
51787
51997
  case "invalid_grant": return 400;
51998
+ case "precondition_failed":
51999
+ case "cardinality_mismatch": return 409;
51788
52000
  case "credential_error":
51789
52001
  case "execute_failed":
51790
52002
  case "timeout":
51791
52003
  case "invalid_output": return 502;
51792
- default: return 502;
51793
52004
  }
51794
52005
  };
51795
52006
  brokerRefusal = (err) => {
@@ -53105,7 +53316,8 @@ var init_support = __esmMin((() => {
53105
53316
  args: z.record(z.string(), z.unknown()).default({}),
53106
53317
  principal: principalSchema.default({ type: "app" }),
53107
53318
  installationId: z.string().min(1).optional(),
53108
- runId: z.string().min(1).optional()
53319
+ runId: z.string().min(1).optional(),
53320
+ idempotent: z.boolean().optional()
53109
53321
  });
53110
53322
  requireBrokerSignature = (secret) => async (c, next) => {
53111
53323
  if (!verifyTokenRequestSignature(c, secret)) return c.json({
@@ -53123,13 +53335,15 @@ var init_support = __esmMin((() => {
53123
53335
  ok: false,
53124
53336
  error: {
53125
53337
  code: error.code,
53126
- message: error.message
53338
+ message: error.message,
53339
+ ...error.retryable !== void 0 ? { retryable: error.retryable } : {}
53127
53340
  }
53128
53341
  };
53129
53342
  switch (statusForBrokerCode(error.code)) {
53130
53343
  case 400: return c.json(body, 400);
53131
53344
  case 401: return c.json(body, 401);
53132
53345
  case 404: return c.json(body, 404);
53346
+ case 409: return c.json(body, 409);
53133
53347
  default: return c.json(body, 502);
53134
53348
  }
53135
53349
  };
@@ -53269,13 +53483,14 @@ var createConnectTokenRouter = (opts, sharedContext) => {
53269
53483
  security: HMAC_ROUTE_SECURITY,
53270
53484
  middleware: [requireBrokerSignature(opts.hmacSecret)],
53271
53485
  summary: "Run one connector operation",
53272
- description: "Mints the credential, applies it in the connector’s declared placement, calls the operation and hands back ONLY its output. Nothing about the credential — not the token, not its placement — crosses this boundary, which is the whole reason a workflow step calls the box instead of the provider.\n\n`operation` names an operation id (OpenAPI) or a tool (MCP) as `GET /api/connect/connectors/{connectorId}/operations` reports it. `runId` is the one field that changes WHICH connector answers: a run wired to a stand-in reaches the stand-in and never leaves the box, which is how an eval exercises a send without sending anything.\n\nA refusal comes back in TWO shapes on the same statuses: `{ ok: false, error }` when the executor completed and the provider or the broker said no, and the box’s `{ code, message }` envelope when the request never got that far.",
53486
+ description: "Mints the credential, applies it in the connector’s declared placement, calls the operation and hands back ONLY its output. Nothing about the credential — not the token, not its placement — crosses this boundary, which is the whole reason a workflow step calls the box instead of the provider.\n\n`operation` names an operation id (OpenAPI) or a tool (MCP) as `GET /api/connect/connectors/{connectorId}/operations` reports it. `runId` is the one field that changes WHICH connector answers: a run wired to a stand-in reaches the stand-in and never leaves the box, which is how an eval exercises a send without sending anything.\n\nA refusal comes back in TWO shapes on the same statuses: `{ ok: false, error }` when the executor completed and the provider or the broker said no, including a broker error thrown before execution completes. Signature and payload validation failures use the box’s `{ code, message }` envelope.",
53273
53487
  request: { body: jsonBody(named("ConnectExecuteRequest", executeRequestSchema), "The connector, the operation, and the args to run it with.") },
53274
53488
  responses: {
53275
53489
  200: jsonResponse(named("ConnectExecuteSuccess", connectExecuteSuccessSchema), "The operation’s own output, passed through untouched."),
53276
53490
  400: executeRefusal("The body failed its schema (`invalid_payload`), or the broker refused the arguments (`invalid_args`, `invalid_grant`)."),
53277
53491
  401: executeRefusal("The signature is missing or wrong (`access_denied`), or the connector has no usable authorization (`no_token`, `user_authorization_required`, `principal_required`)."),
53278
53492
  404: executeRefusal("No such connector on this box, or no such operation on it (`not_found`, `connector_installation_required`)."),
53493
+ 409: executeRefusal("The provider refused a guarded write: no rows matched (`precondition_failed`), or the requested row limit was exceeded (`cardinality_mismatch`). Terminal: change the guard or narrow the target before retrying."),
53279
53494
  502: executeRefusal("The provider call failed (`execute_failed`, `timeout`, `credential_error`, `invalid_output`). Upstream, not the box.")
53280
53495
  }
53281
53496
  }), async (c) => {
@@ -53291,7 +53506,8 @@ var createConnectTokenRouter = (opts, sharedContext) => {
53291
53506
  connectConnectorId: request.connector,
53292
53507
  connectOperationId: request.operation,
53293
53508
  payload: request.args,
53294
- ...request.runId !== void 0 ? { runId: request.runId } : {}
53509
+ ...request.runId !== void 0 ? { runId: request.runId } : {},
53510
+ ...request.idempotent !== void 0 ? { idempotent: request.idempotent } : {}
53295
53511
  });
53296
53512
  if (result.ok) return c.json({
53297
53513
  ok: true,
@@ -53299,7 +53515,8 @@ var createConnectTokenRouter = (opts, sharedContext) => {
53299
53515
  }, 200);
53300
53516
  return answerExecuteFailure(c, result.error);
53301
53517
  } catch (err) {
53302
- return answerBrokerRefusal(c, err);
53518
+ if (isBrokerError(err)) return answerExecuteFailure(c, err);
53519
+ throw err;
53303
53520
  }
53304
53521
  });
53305
53522
  app.openapi(createRoute({
@@ -65380,6 +65597,30 @@ var init_serial_execution_coordinator = __esmMin((() => {
65380
65597
  createSerialExecutionCoordinator = (gate) => {
65381
65598
  const held = /* @__PURE__ */ new Map();
65382
65599
  return {
65600
+ async beginStart(workflowName) {
65601
+ const owner = held.get(workflowName);
65602
+ if (!owner) return null;
65603
+ const abandon = () => {
65604
+ if (held.get(workflowName) === owner) held.delete(workflowName);
65605
+ };
65606
+ try {
65607
+ if (!await gate.markAttempt(workflowName, owner.token)) {
65608
+ abandon();
65609
+ return null;
65610
+ }
65611
+ } catch (err) {
65612
+ abandon();
65613
+ throw err;
65614
+ }
65615
+ return {
65616
+ noteStarted(runId) {
65617
+ if (held.get(workflowName) === owner) owner.runId = runId;
65618
+ },
65619
+ acknowledge: () => gate.acknowledge(workflowName, owner.token),
65620
+ requeue: (trigger) => gate.requeue(workflowName, trigger, owner.token),
65621
+ abandon
65622
+ };
65623
+ },
65383
65624
  async tryAdmit(workflowName, trigger) {
65384
65625
  const verdict = await gate.tryAdmit(workflowName, trigger);
65385
65626
  if (verdict.admitted) {
@@ -65420,26 +65661,34 @@ var init_serial_execution_coordinator = __esmMin((() => {
65420
65661
  held.delete(workflowName);
65421
65662
  },
65422
65663
  async requeue(workflowName, trigger) {
65423
- await gate.requeue(workflowName, trigger);
65664
+ const owner = held.get(workflowName);
65665
+ if (owner) await gate.requeue(workflowName, trigger, owner.token);
65424
65666
  }
65425
65667
  };
65426
65668
  };
65427
65669
  createSerialSweepSupport = (deps) => {
65428
65670
  const startAdmitted = async (name, trigger) => {
65671
+ const attempt = await deps.coordinator.beginStart(name);
65672
+ if (!attempt) return;
65429
65673
  let started;
65430
65674
  try {
65431
65675
  started = await deps.startNext(name, trigger);
65432
65676
  } catch (err) {
65433
65677
  try {
65434
- await deps.coordinator.requeue(name, trigger);
65678
+ await attempt.requeue(trigger);
65435
65679
  } catch (requeueErr) {
65436
65680
  console.warn(`[serial-execution] could not re-enqueue an unstarted trigger for "${name}": ${requeueErr instanceof Error ? requeueErr.message : String(requeueErr)}`);
65437
65681
  }
65438
- deps.coordinator.abandon(name);
65439
- console.warn(`[serial-execution] failed to start admitted trigger for "${name}", re-enqueued it and released its lock: ${err instanceof Error ? err.message : String(err)}`);
65682
+ attempt.abandon();
65683
+ console.warn(`[serial-execution] failed to start admitted trigger for "${name}": ${err instanceof Error ? err.message : String(err)}`);
65440
65684
  return;
65441
65685
  }
65442
- deps.coordinator.noteStarted(name, started?.worldRunId);
65686
+ attempt.noteStarted(started?.worldRunId);
65687
+ try {
65688
+ await attempt.acknowledge();
65689
+ } catch (err) {
65690
+ console.warn(`[serial-execution] could not acknowledge started trigger for "${name}": ${err instanceof Error ? err.message : String(err)}`);
65691
+ }
65443
65692
  await deps.clearWaiting?.(name, trigger.pendingId);
65444
65693
  };
65445
65694
  return {
@@ -65745,6 +65994,7 @@ var init_load_project_runtime = __esmMin((() => {
65745
65994
  let setActiveStepReader;
65746
65995
  let approvalRequest;
65747
65996
  let setConnectorInvoker;
65997
+ let setEchoGuardReader;
65748
65998
  let connectorInvoker;
65749
65999
  let setActionInvoker;
65750
66000
  let actionInvoker;
@@ -65760,6 +66010,7 @@ var init_load_project_runtime = __esmMin((() => {
65760
66010
  setApprovalRecorder = sdk.setApprovalRecorder;
65761
66011
  setActiveStepReader = sdk.setActiveStepReader;
65762
66012
  setConnectorInvoker = sdk.setConnectorInvoker;
66013
+ setEchoGuardReader = sdk.setEchoGuardReader;
65763
66014
  connectorInvoker = sdk.createConnectInvoker?.();
65764
66015
  setActionInvoker = sdk.setActionInvoker;
65765
66016
  actionInvoker = sdk.createActionInvoker?.();
@@ -65817,6 +66068,7 @@ var init_load_project_runtime = __esmMin((() => {
65817
66068
  setActiveStepReader,
65818
66069
  approvalRequest,
65819
66070
  setConnectorInvoker,
66071
+ setEchoGuardReader,
65820
66072
  connectorInvoker,
65821
66073
  setActionInvoker,
65822
66074
  actionInvoker,
@@ -66481,11 +66733,49 @@ var init_runtime_approval_plants = __esmMin((() => {
66481
66733
  }));
66482
66734
  //#endregion
66483
66735
  //#region ../../libs/runtime/workspace-runtime/src/lib/workflow/runtime-connector-plants.ts
66484
- var plantConnectorInvoker, plantActionInvoker;
66736
+ var owningWorkflow, echoOwner, unresolvedEcho, resolveEcho, echoRecordError, plantConnectorInvoker, plantActionInvoker, ECHO_WINDOW_MS, renderEcho, echoGuardUnavailable, plantEchoGuard;
66485
66737
  var init_runtime_connector_plants = __esmMin((() => {
66738
+ init_src$4();
66739
+ init_src$13();
66486
66740
  init_call_ordinal();
66487
66741
  init_monotonic_span();
66488
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
+ };
66489
66779
  plantConnectorInvoker = (rt, ctx) => {
66490
66780
  if (!rt.setConnectorInvoker || !rt.connectorInvoker) return;
66491
66781
  const baseInvoker = rt.connectorInvoker;
@@ -66499,14 +66789,15 @@ var init_runtime_connector_plants = __esmMin((() => {
66499
66789
  const span = startSpan();
66500
66790
  const ordinal = stepInfo ? nextConnOrdinal(runId ?? "", stepInfo.stepId, stepInfo.attempt) : 0;
66501
66791
  const externalStepId = stepInfo ? `${stepInfo.stepId}::conn::${ordinal}` : randomUUID();
66502
- const record = (status, extra) => {
66503
- if (!runId) return;
66792
+ const label = `${args.connector}/${args.operation}`;
66793
+ const writeRow = (status, extra, echo) => {
66794
+ if (!runId) return void 0;
66504
66795
  const { completedAt, durationMs } = closeSpan(span);
66505
- recordActivityStep({
66796
+ return recordActivityStep({
66506
66797
  runId,
66507
66798
  externalStepId,
66508
66799
  type: "connection",
66509
- name: `${args.connector}/${args.operation}`,
66800
+ name: label,
66510
66801
  status,
66511
66802
  startedAt: span.startedAt.toISOString(),
66512
66803
  completedAt: completedAt.toISOString(),
@@ -66514,26 +66805,52 @@ var init_runtime_connector_plants = __esmMin((() => {
66514
66805
  input: {
66515
66806
  connector: args.connector,
66516
66807
  operation: args.operation,
66517
- args: args.args
66808
+ args: args.args,
66809
+ ...echo ?? {}
66518
66810
  },
66519
66811
  ...extra,
66520
66812
  enclosingExternalStepId: stepInfo?.stepId ?? null
66521
- }).catch((err) => {
66813
+ });
66814
+ };
66815
+ const forget = (write) => {
66816
+ write?.catch((err) => {
66522
66817
  console.warn(`[connector] run-step write failed for run ${runId}: ${err instanceof Error ? err.message : String(err)}`);
66523
66818
  });
66524
66819
  };
66820
+ let result;
66525
66821
  try {
66526
- const result = await baseInvoker.invoke(args);
66527
- record("completed", { output: result.output });
66528
- return result;
66822
+ result = await baseInvoker.invoke(args);
66529
66823
  } catch (err) {
66530
66824
  const code = err && typeof err === "object" && "code" in err ? err.code : void 0;
66531
- 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: {
66532
66828
  message: err instanceof Error ? err.message : String(err),
66533
66829
  ...typeof code === "string" ? { code } : {}
66534
- } });
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);
66535
66835
  throw err;
66536
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;
66537
66854
  } });
66538
66855
  } else rt.setConnectorInvoker(baseInvoker);
66539
66856
  };
@@ -66597,6 +66914,34 @@ var init_runtime_connector_plants = __esmMin((() => {
66597
66914
  }
66598
66915
  } });
66599
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
+ };
66600
66945
  }));
66601
66946
  //#endregion
66602
66947
  //#region ../../libs/runtime/workspace-runtime/src/lib/workflow/runtime-sdk-plants.ts
@@ -66660,6 +67005,7 @@ var init_runtime_sdk_plants = __esmMin((() => {
66660
67005
  plantPromptUseReporter(rt, ctx);
66661
67006
  plantApprovalRecorder(rt, ctx);
66662
67007
  plantConnectorInvoker(rt, ctx);
67008
+ plantEchoGuard(rt, ctx);
66663
67009
  plantActionInvoker(rt, ctx);
66664
67010
  return rt;
66665
67011
  };
@@ -66686,6 +67032,7 @@ var init_runtime_loader = __esmMin((() => {
66686
67032
  rt.setPromptUseReporter?.(void 0);
66687
67033
  rt.setApprovalRecorder?.(void 0);
66688
67034
  rt.setConnectorInvoker?.(void 0);
67035
+ rt.setEchoGuardReader?.(void 0);
66689
67036
  rt.setActionInvoker?.(void 0);
66690
67037
  rt.setActiveStepReader?.(void 0);
66691
67038
  planted = void 0;
@@ -67254,9 +67601,12 @@ var init_event_forwarder = __esmMin((() => {
67254
67601
  * the row `claimed` while the attempt went ahead; a later swallowed `markStarted`
67255
67602
  * would then leave a `claimed` row with a real run behind it, the sweep would
67256
67603
  * release it, and the customer's workflow would run a second time. Failing here
67257
- * is instead the ordinary transient path the start and resume catches already
67258
- * take: nothing was attempted, so release the claim (best-effort) and bubble —
67259
- * the tick does not advance its cursor and the next one retries the item.
67604
+ * instead bubbles without releasing: a failed marker cannot establish ownership,
67605
+ * and deleting by pair could erase another writer's delivery. The tick holds its
67606
+ * cursor. If the row remains `claimed`, recovery waits for the takeover window
67607
+ * and consumes takeover budget, even for a transient marker failure. An applied
67608
+ * update whose response is lost instead leaves `starting` without an attempt;
67609
+ * this slice cannot resolve that unknown outcome (PLA-1395 investigation).
67260
67610
  *
67261
67611
  * Written at most once: a resume that misses falls through to the start below.
67262
67612
  */
@@ -67265,13 +67615,7 @@ var init_event_forwarder = __esmMin((() => {
67265
67615
  * one must give up where it stands. */
67266
67616
  const markAttempt = async () => {
67267
67617
  if (attemptMarked) return true;
67268
- let matched;
67269
- try {
67270
- matched = await deps.deliveries.markStarting(subscription.id, event.id);
67271
- } catch (err) {
67272
- await bestEffort(() => deps.deliveries.release(subscription.id, event.id), "release");
67273
- throw err;
67274
- }
67618
+ const matched = await deps.deliveries.markStarting(subscription.id, event.id);
67275
67619
  attemptMarked = matched;
67276
67620
  return matched;
67277
67621
  };
@@ -70958,7 +71302,7 @@ var calls_workflow_ts_default = "import { z } from \"zod\";\nimport { streamDeep
70958
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";
70959
71303
  //#endregion
70960
71304
  //#region src/services/scaffold/template-loader.ts
70961
- var SDK_TEMPLATE_VERSION = "0.4.0";
71305
+ var SDK_TEMPLATE_VERSION = "0.5.0";
70962
71306
  var PREFIX = "./templates/";
70963
71307
  var templates = new Map(Object.entries(/* @__PURE__ */ Object.assign({
70964
71308
  "./templates/deep-agents/index.ts.tmpl": index_ts_default,
@@ -75983,6 +76327,15 @@ ${objectStore}
75983
76327
  # MinIO addresses buckets by path. A managed bucket that only answers to the
75984
76328
  # bucket-as-subdomain form (AWS S3, Railway Buckets) needs this set to false.
75985
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
75986
76339
  ${sharedIdentity}
75987
76340
  # REPLACES the agent's built-in origin allowlist — it does not add to it. The
75988
76341
  # dashboard is a different ORIGIN from the agent (different port), so its
@@ -78802,6 +79155,7 @@ var generateConnectorTypes = (models) => {
78802
79155
  HEADER,
78803
79156
  "",
78804
79157
  ...AUGMENTED_MODULES.map((mod) => `import ${JSON.stringify(mod)};`),
79158
+ `import type { CallConnectorOptions } from ${JSON.stringify(AUGMENTED_MODULES[0])};`,
78805
79159
  "",
78806
79160
  augmentations.join("\n"),
78807
79161
  ""
@@ -78817,15 +79171,32 @@ function renderConnectorEntry(model) {
78817
79171
  ].join("\n");
78818
79172
  }
78819
79173
  /**
78820
- * One ` <op>(args: <type>): Promise<<out>>;` line (indent level 3) — or the
78821
- * THREE overload lines a `'when-requested'` raw response takes, because one
78822
- * signature there has two reachable outcomes.
79174
+ * One ` <op>(args: <type>, opts?: CallConnectorOptions): Promise<<out>>;` line
79175
+ * (indent level 3) — or the THREE overload lines a `'when-requested'` raw response
79176
+ * takes, because one signature there has two reachable outcomes.
78823
79177
  */
78824
79178
  function renderOperation(op) {
78825
79179
  if (takesEitherOutcome(op)) return renderEitherOutcome(op);
78826
- return ` ${propertyKey(op.name)}(${renderParam(op)}): Promise<${renderReturn(op)}>;`;
79180
+ return ` ${propertyKey(op.name)}(${renderParam(op)}${OPTS_PARAM}): Promise<${renderReturn(op)}>;`;
78827
79181
  }
78828
79182
  /**
79183
+ * The second parameter every generated signature carries (PLA-1349).
79184
+ *
79185
+ * `connection(id).<op>(args, opts)` has always forwarded a second argument at run
79186
+ * time (`call-connector.ts`), and no generated signature declared it — so
79187
+ * `idempotent` was reachable only through the untyped `.call(...)`, which is not
79188
+ * the path a creator is steered to. Typed as the whole options object rather than
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.
79192
+ *
79193
+ * It is ALWAYS second, and `renderParam` is what guarantees that: an operation
79194
+ * with nothing to put in `args` still prints `args?: undefined` ahead of this. The
79195
+ * proxy is `(args?, opts?)` for every operation, so a signature that let `opts`
79196
+ * come first would hand the caller's options to the provider as arguments.
79197
+ */
79198
+ var OPTS_PARAM = ", opts?: CallConnectorOptions";
79199
+ /**
78829
79200
  * Does ONE signature of this operation have two reachable outcomes?
78830
79201
  *
78831
79202
  * Only `'when-requested'`, and only while the switch is actually IN its arguments
@@ -78865,7 +79236,7 @@ function takesEitherOutcome(op) {
78865
79236
  function renderEitherOutcome(op) {
78866
79237
  const receipt = printType(rawResponseReceiptJsonSchema(), 3);
78867
79238
  const response = op.outputSchema === void 0 ? "unknown" : printType(op.outputSchema, 3);
78868
- const signature = (presence, out) => ` ${propertyKey(op.name)}(args: ${printType(withKeyPresence(op.argsSchema, presence), 3)}): Promise<${out}>;`;
79239
+ const signature = (presence, out) => ` ${propertyKey(op.name)}(args: ${printType(withKeyPresence(op.argsSchema, presence), 3)}${OPTS_PARAM}): Promise<${out}>;`;
78869
79240
  return [
78870
79241
  signature("required", receipt),
78871
79242
  signature("forbidden", response),
@@ -78881,6 +79252,8 @@ function renderEitherOutcome(op) {
78881
79252
  */
78882
79253
  function withKeyPresence(schema, presence) {
78883
79254
  if (!isObject(schema)) return schema;
79255
+ const union = schema["anyOf"] ?? schema["oneOf"];
79256
+ if (Array.isArray(union)) return { anyOf: union.map((branch) => withKeyPresence(branch, presence)) };
78884
79257
  const properties = { ...isObject(schema["properties"]) ? schema["properties"] : {} };
78885
79258
  const required = new Set(Array.isArray(schema["required"]) ? schema["required"].filter((k) => typeof k === "string") : []);
78886
79259
  if (presence === "required") required.add(RAW_RESPONSE_KEY_ARG);
@@ -78896,7 +79269,7 @@ function withKeyPresence(schema, presence) {
78896
79269
  }
78897
79270
  /** `args: <type>` for an operation with arguments, and nothing for one without. */
78898
79271
  function renderParam(op) {
78899
- return schemaHasProperties(op.argsSchema) ? `args: ${printType(op.argsSchema, 3)}` : "";
79272
+ return schemaHasProperties(op.argsSchema) ? `args: ${printType(op.argsSchema, 3)}` : "args?: undefined";
78900
79273
  }
78901
79274
  /**
78902
79275
  * What the operation ACTUALLY answers (PLA-1203).
@@ -79142,8 +79515,9 @@ function answeredResponseSchema(op, byId) {
79142
79515
  * BROKER-consumed arguments into ONE flat args object schema — exactly how the
79143
79516
  * broker executor reads the payload (`args[param.name]` for path/query, the
79144
79517
  * broker's own keys off the raw flags, the rest as the JSON body). A non-object
79145
- * request body (rare) is dropped from the flat shape; the dynamic form still sends
79146
- * it.
79518
+ * request body (rare) is dropped from the flat shape. Swagger's named body is
79519
+ * also offered as an optional argument for writes whose column names overlap
79520
+ * query filters.
79147
79521
  *
79148
79522
  * The three sources are merged in the order the executor reads them, and the
79149
79523
  * broker's come LAST because they are the only ones it will not put on the wire.
@@ -79167,19 +79541,44 @@ function operationArgsSchema(op, trustBodyRequired) {
79167
79541
  Object.assign(properties, bodyProperties);
79168
79542
  if (trustBodyRequired && Array.isArray(body["required"])) required.push(...body["required"].filter((k) => typeof k === "string" && !isServerGenerated(bodyProperties[k])));
79169
79543
  }
79544
+ const bodyRequired = [...required];
79545
+ const sharedRequired = [];
79546
+ const consumedNames = /* @__PURE__ */ new Set();
79547
+ if (op.bodyArgName !== void 0 && isObject(body)) {
79548
+ const namedBody = { ...body };
79549
+ if (!trustBodyRequired) delete namedBody["required"];
79550
+ else if (Array.isArray(body["required"]) && isObject(body["properties"])) {
79551
+ const bodyProperties = body["properties"];
79552
+ namedBody["required"] = body["required"].filter((key) => typeof key === "string" && !isServerGenerated(bodyProperties[key]));
79553
+ }
79554
+ properties[op.bodyArgName] = namedBody;
79555
+ }
79170
79556
  for (const param of op.parameters ?? []) {
79171
79557
  properties[param.name] = param.schema ?? {};
79172
- if (param.required) required.push(param.name);
79558
+ consumedNames.add(param.name);
79559
+ if (param.required) {
79560
+ required.push(param.name);
79561
+ sharedRequired.push(param.name);
79562
+ }
79173
79563
  }
79174
79564
  for (const arg of brokerConsumedArgs(op)) {
79175
79565
  properties[arg.name] = arg.schema;
79176
- if (arg.required) required.push(arg.name);
79566
+ consumedNames.add(arg.name);
79567
+ if (arg.required) {
79568
+ required.push(arg.name);
79569
+ sharedRequired.push(arg.name);
79570
+ }
79177
79571
  }
79178
- return {
79572
+ const flat = {
79179
79573
  type: "object",
79180
79574
  properties,
79181
79575
  ...required.length > 0 ? { required: [...new Set(required)] } : {}
79182
79576
  };
79577
+ if (op.bodyArgName === void 0 || bodyRequired.length === 0 || consumedNames.has(op.bodyArgName)) return flat;
79578
+ return { anyOf: [flat, {
79579
+ ...flat,
79580
+ required: [...new Set([...sharedRequired, op.bodyArgName])]
79581
+ }] };
79183
79582
  }
79184
79583
  /**
79185
79584
  * Whether a body property is one the SERVER produces rather than one the caller
@@ -79202,11 +79601,17 @@ function isServerGenerated(property) {
79202
79601
  }
79203
79602
  /** Whether an object schema declares ONE named property. */
79204
79603
  function schemaHasProperty(schema, name) {
79205
- return isObject(schema) && isObject(schema["properties"]) && name in schema["properties"];
79604
+ if (!isObject(schema)) return false;
79605
+ const union = schema["anyOf"] ?? schema["oneOf"];
79606
+ if (Array.isArray(union)) return union.some((branch) => schemaHasProperty(branch, name));
79607
+ return isObject(schema["properties"]) && name in schema["properties"];
79206
79608
  }
79207
79609
  /** Whether an object schema declares at least one property (else the op takes no args). */
79208
79610
  function schemaHasProperties(schema) {
79209
- return isObject(schema) && isObject(schema["properties"]) && Object.keys(schema["properties"]).length > 0;
79611
+ if (!isObject(schema)) return false;
79612
+ const union = schema["anyOf"] ?? schema["oneOf"];
79613
+ if (Array.isArray(union)) return union.some(schemaHasProperties);
79614
+ return isObject(schema["properties"]) && Object.keys(schema["properties"]).length > 0;
79210
79615
  }
79211
79616
  /**
79212
79617
  * Print a TypeScript type literal for a JSON Schema node. Handles the subset
@@ -81823,6 +82228,64 @@ var createDevCommand = (ctx) => defineCommand({
81823
82228
  })(args)
81824
82229
  });
81825
82230
  //#endregion
82231
+ //#region ../../libs/shared/format/src/lib/coding-agent-prompt.ts
82232
+ /**
82233
+ * The pasteable prompt, and nothing around it, with no "open your coding agent"
82234
+ * preamble. Each surface draws its own instruction: the CLI as a `note`, the
82235
+ * web as the label above a copy button.
82236
+ *
82237
+ * `null` is the empty workspace, which is what the web always passes: the
82238
+ * browser cannot see which piece the reader picked at `init`'s own prompt. That
82239
+ * costs nothing, because `stackbone-coder` detects the workspace on disk by
82240
+ * itself and adds to it rather than re-scaffolding.
82241
+ */
82242
+ function codingAgentPrompt(piece) {
82243
+ if (!piece) return "Use the stackbone-coder skill to build my first piece in this workspace: interview me about what it should do, then wire it up.";
82244
+ return `Use the stackbone-coder skill. The "${piece.name}" ${piece.kind} is already scaffolded in this workspace, so skip the scaffolding: interview me about what it should do, then wire it up.`;
82245
+ }
82246
+ //#endregion
82247
+ //#region ../../libs/shared/format/src/lib/coding-agents.ts
82248
+ var CODING_AGENT_LABELS = [
82249
+ {
82250
+ id: "claude-code",
82251
+ label: "Claude Code"
82252
+ },
82253
+ {
82254
+ id: "cursor",
82255
+ label: "Cursor"
82256
+ },
82257
+ {
82258
+ id: "github-copilot",
82259
+ label: "GitHub Copilot (VS Code)"
82260
+ },
82261
+ {
82262
+ id: "gemini-cli",
82263
+ label: "Gemini CLI"
82264
+ },
82265
+ {
82266
+ id: "windsurf",
82267
+ label: "Windsurf"
82268
+ },
82269
+ {
82270
+ id: "cline",
82271
+ label: "Cline"
82272
+ },
82273
+ {
82274
+ id: "codex",
82275
+ label: "Codex CLI"
82276
+ }
82277
+ ];
82278
+ /**
82279
+ * Throws rather than falling back to the id: a registry row whose label is
82280
+ * missing here is a mistake to fix at build time, not a menu entry reading
82281
+ * `github-copilot`.
82282
+ */
82283
+ var codingAgentLabel = (id) => {
82284
+ const found = CODING_AGENT_LABELS.find((agent) => agent.id === id);
82285
+ if (!found) throw new Error(`no label for coding agent "${id}"`);
82286
+ return found.label;
82287
+ };
82288
+ //#endregion
81826
82289
  //#region src/services/coding-agents/registry.ts
81827
82290
  /**
81828
82291
  * The one place that knows which coding agents Stackbone supports and what
@@ -81831,6 +82294,10 @@ var createDevCommand = (ctx) => defineCommand({
81831
82294
  * that pre-ticks the menu, and the MCP config writer — so adding an agent is a
81832
82295
  * row here and nothing else.
81833
82296
  *
82297
+ * The one thing a row does NOT own is its label: that is in `@stackbone/format`,
82298
+ * because the browser renders the same list and cannot import this file — it
82299
+ * resolves paths against `homedir()` and reads `process.platform`.
82300
+ *
81834
82301
  * The four DIFFERENT MCP entry shapes below are the reason this is data rather
81835
82302
  * than a switch. There is no convention: `mcpServers` vs `servers`, `url` vs
81836
82303
  * `httpUrl` vs `serverUrl`, `http` vs `streamableHttp`, JSON vs TOML. Each was
@@ -81870,7 +82337,7 @@ var CODEX_MCP = {
81870
82337
  var CODING_AGENTS = [
81871
82338
  {
81872
82339
  id: "claude-code",
81873
- label: "Claude Code",
82340
+ label: codingAgentLabel("claude-code"),
81874
82341
  skillsId: "claude-code",
81875
82342
  skillsDir: ".claude",
81876
82343
  detect: [".claude", "~/.claude"],
@@ -81884,7 +82351,7 @@ var CODING_AGENTS = [
81884
82351
  },
81885
82352
  {
81886
82353
  id: "cursor",
81887
- label: "Cursor",
82354
+ label: codingAgentLabel("cursor"),
81888
82355
  skillsId: "cursor",
81889
82356
  skillsDir: ".agents",
81890
82357
  detect: [".cursor", "~/.cursor"],
@@ -81898,7 +82365,7 @@ var CODING_AGENTS = [
81898
82365
  },
81899
82366
  {
81900
82367
  id: "github-copilot",
81901
- label: "GitHub Copilot (VS Code)",
82368
+ label: codingAgentLabel("github-copilot"),
81902
82369
  skillsId: "github-copilot",
81903
82370
  skillsDir: ".agents",
81904
82371
  detect: [".vscode"],
@@ -81912,7 +82379,7 @@ var CODING_AGENTS = [
81912
82379
  },
81913
82380
  {
81914
82381
  id: "gemini-cli",
81915
- label: "Gemini CLI",
82382
+ label: codingAgentLabel("gemini-cli"),
81916
82383
  skillsId: "gemini-cli",
81917
82384
  skillsDir: ".agents",
81918
82385
  detect: [".gemini", "~/.gemini"],
@@ -81926,7 +82393,7 @@ var CODING_AGENTS = [
81926
82393
  },
81927
82394
  {
81928
82395
  id: "windsurf",
81929
- label: "Windsurf",
82396
+ label: codingAgentLabel("windsurf"),
81930
82397
  skillsId: "windsurf",
81931
82398
  skillsDir: ".windsurf",
81932
82399
  detect: [".windsurf", "~/.codeium"],
@@ -81944,7 +82411,7 @@ var CODING_AGENTS = [
81944
82411
  },
81945
82412
  {
81946
82413
  id: "cline",
81947
- label: "Cline",
82414
+ label: codingAgentLabel("cline"),
81948
82415
  skillsId: "cline",
81949
82416
  skillsDir: ".agents",
81950
82417
  detect: [
@@ -81965,7 +82432,7 @@ var CODING_AGENTS = [
81965
82432
  },
81966
82433
  {
81967
82434
  id: "codex",
81968
- label: "Codex CLI",
82435
+ label: codingAgentLabel("codex"),
81969
82436
  skillsId: "codex",
81970
82437
  skillsDir: ".agents",
81971
82438
  detect: [".codex", "~/.codex"],
@@ -82642,22 +83109,6 @@ var createHitlCommand = defineGroup({
82642
83109
  }
82643
83110
  });
82644
83111
  //#endregion
82645
- //#region ../../libs/shared/format/src/lib/coding-agent-prompt.ts
82646
- /**
82647
- * The pasteable prompt, and nothing around it, with no "open your coding agent"
82648
- * preamble. Each surface draws its own instruction: the CLI as a `note`, the
82649
- * web as the label above a copy button.
82650
- *
82651
- * `null` is the empty workspace, which is what the web always passes: the
82652
- * browser cannot see which piece the reader picked at `init`'s own prompt. That
82653
- * costs nothing, because `stackbone-coder` detects the workspace on disk by
82654
- * itself and adds to it rather than re-scaffolding.
82655
- */
82656
- function codingAgentPrompt(piece) {
82657
- if (!piece) return "Use the stackbone-coder skill to build my first piece in this workspace: interview me about what it should do, then wire it up.";
82658
- return `Use the stackbone-coder skill. The "${piece.name}" ${piece.kind} is already scaffolded in this workspace, so skip the scaffolding: interview me about what it should do, then wire it up.`;
82659
- }
82660
- //#endregion
82661
83112
  //#region src/core/npm-exec.ts
82662
83113
  var execFileAsync = promisify(execFile);
82663
83114
  var NEEDS_SHELL = process.platform === "win32";