Skip to content

Commit 364c082

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-24036-ledger-a
2 parents ccc8d7e + b75129f commit 364c082

57 files changed

Lines changed: 5392 additions & 298 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
---
2+
'@objectstack/service-print': minor
3+
'@objectstack/spec': patch
4+
'@objectstack/service-storage': minor
5+
---
6+
7+
feat(service-print): print a record's print page to a PDF in headless Chromium, as the requesting user, and archive it as a file attached to the record
8+
9+
Clause-②: yes (widening)
10+
11+
A new package, `@objectstack/service-print`. `PrintServicePlugin` registers the in-process `print` service (`IPrintService`): `status()` and `printRecordToFile({ app, object, recordId, page, filename? }, context)`. It adds no route, no metadata key and no error code. Compose it explicitly:
12+
13+
```ts
14+
import { PrintServicePlugin } from '@objectstack/service-print';
15+
plugins: [new PrintServicePlugin()]
16+
```
17+
18+
- **One engine, headless Chromium, in one of two placements.** A local executable (`OS_PRINT_CHROMIUM_PATH`, option `chromiumPath`), or a remote browser's DevTools WebSocket endpoint (`OS_PRINT_BROWSER_ENDPOINT`, option `browserEndpoint`). Either one needs `OS_PRINT_CONSOLE_URL` (option `consoleUrl`): the console's URL as that browser reaches it, `/_console` included. Configuring both placements is refused. `playwright-core` is pinned exact on this package only.
19+
- **Loud at startup.** `start()` probes the engine and logs its placement and Chromium version. An engine that is not configured, is misconfigured or does not answer is reported once, with the setting to fix, and `status()` says why. A remote endpoint is never logged beyond its scheme and host.
20+
- **As the requesting user.** The page is rendered through a short-lived session minted for the caller's own principal by the auth service (`mintPrincipalSession`), and revoked in a `finally`. There is no system principal.
21+
- **Refusals first, each with `code` and `status`, before any session is minted or browser started** (an engine that failed its last probe is re-probed only after them all): a caller that is not a human user acting for themselves (`PERMISSION_DENIED` / 403); an engine that is not ready (`SERVICE_UNAVAILABLE` / 503); an unknown page (`RESOURCE_NOT_FOUND` / 404); a page that is not the object's default record page or declares no `print` (`VALIDATION_ERROR` / 400); a record the caller cannot read (`RECORD_NOT_FOUND` / 404); an object without `enable: { files: true }` (`FILES_DISABLED` / 403).
22+
- **The archive runs as the requesting user.** `@objectstack/service-storage`'s `file-attachments` service gains a caller-scoped face, `ICallerFileAttachmentService` (exported type), on the same slot and the same writer: `assertMayAttach(parentObject, parentId, context)` throws the insert's own refusal without writing (`PERMISSION_DENIED` / 403 when the caller may not create attachments, `ATTACHMENT_PARENT_ACCESS` / 403 when the attach rule refuses), and `attachFileAs(input, context)` writes one `sys_file` owned by the caller and one `sys_attachment` inserted under the caller's context, so every insert gate judges the caller and the row is stamped `uploaded_by` the caller. The print service asks `assertMayAttach` in its files step, before any session is minted or browser started. `attachFileAs` acts only in the caller's own organization: an `organizationId` other than the context's `tenantId` (or `null` when it names none) is refused `PERMISSION_DENIED` / 403. `attachFile` (the system verb) is unchanged.
23+
24+
`@objectstack/spec`'s `llms.txt` counts the new package: the workspace publishes 72 packages.
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
---
2+
'@objectstack/metadata-protocol': minor
3+
---
4+
5+
feat(metadata-protocol)!: a flow re-save that newly introduces a retired spelling is refused like a new flow's
6+
7+
Clause-②: yes (narrowing)
8+
9+
**BREAKING (API save door), the re-save case:** a flow saved through `saveMetaItem` (`PUT /api/v1/meta/flow/:name`, the runtime dispatcher, and every caller of the protocol) over a stored row is now judged as authored input when its body introduces a spelling retired from the authoring funnel that the stored row does not carry. Adding a node with an old spelling to a legacy flow, or writing one on a node the stored row holds in the canonical spelling, is no longer rewritten and stored: the save meets the flow parse's refusal, `422 INVALID_METADATA`, with an issue located at the key and the same prescription `os validate` and `os build` print, exactly as a new flow's does. Draft and publish mode alike. The whole body is judged that way, so the refusal also lists the retired spellings the stored row already carries; fixing the added one alone is enough to save. Before, a re-save healed every retired spelling in the body, the new one included, and said nothing.
10+
11+
What counts as introduced: a retired conversion that fires on the request at a site where it does not fire on the stored row. A site is the key's path, with each node (and edge) named by its `id` rather than its position, so moving, adding or removing nodes does not change it. The stored rows compared are the row the save overwrites, and for a draft save the active row the draft overlays as well, so Studio's repeated draft saves of a legacy flow keep saving.
12+
13+
Not changed:
14+
15+
- A re-save whose retired spellings the stored row all carries, such as the served legacy body saved back unchanged, keeps replaying the full chain and is healed as before.
16+
- A new flow with a retired spelling is refused, as it was.
17+
- The server's own writes of bodies at rest keep the full chain: the stored-metadata migration (`os migrate meta --stored`), a package duplicate and an unmanaged package import.
18+
- The automation engine's flow rehydration and the read path, which still serves a stored flow verbatim.
19+
20+
Migration, for a client that edits flows through the API: write the spellings below on any node or key you add or change. The legacy spellings already on the stored row may stay until the row is healed.
21+
22+
- `wait` node loose `config.duration` / `config.timerDuration` → `waitEventConfig.timerDuration`; `config.signal` / `config.signalName` → `waitEventConfig.signalName`; `config.eventType` → `waitEventConfig.eventType`. On the block itself, `timeoutMs` → `timerDuration` and `onTimeout` is removed.
23+
- `connector_action` node loose `config.connectorId` / `config.actionId` / `config.input` → its `connectorConfig` block.
24+
- CRUD node `config.filters` → `config.filter`; `config.object` → `config.objectName`.
25+
- `map` and `subflow` node `config.flow` → `config.flowName`.
26+
- `notify` node `config.to` → `recipients`, `subject` → `title`, `body` → `message`, `url` → `actionUrl`, `source: { object, id }` → `sourceObject` / `sourceId`.
27+
- `script` node `config.functionName` → `function`, `input` → `inputs`; `actionType` / `template` / `recipients` / `variables` / `script` are removed.
28+
- `errorHandling.retryDelayMs` and a `try_catch` node's `retry.retryDelayMs` → `backoffMs`; the flow keys `active` / `template`, a node's `outputSchema` and `errorHandling.fallbackNodeId` are removed.
29+
30+
Each refusal names the key to write.
31+
32+
<!-- adr-0087: not-required (already-registered flow-node-crud-filter-alias, flow-node-crud-object-alias, flow-node-notify-config-aliases, flow-node-wait-event-config-lift, flow-node-map-flow-alias, flow-node-subflow-flow-alias, flow-node-connector-config-lift, flow-node-script-config-aliases, flow-inert-keys-removed, flow-node-wait-timeout-keys-removed, flow-node-script-branch-keys-removed, retry-policy-converged) these D2 entries already record every spelling the save door now refuses when a re-save introduces it; this change only stops the door replaying them for a re-save's new authored input, and their own migrations are the ones above -->
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
'@objectstack/plugin-approvals': patch
3+
---
4+
5+
Clause-②: no
6+
7+
fix(plugin-approvals): an approval request's record title reads the flow's label, not `flow:NAME`
8+
9+
- **`sys_approval_request.display_title` reads `process_label`.** The title is now the flow's label and the record id (`Discount Sign-off · opp_45`), the same label the request lists show. A row with no label stored falls back to `process_name` (`flow:deal_desk · opp_42`), so the title never reads `null · …`. Every surface that draws the request's title through `nameField` shows it: the record page's heading and breadcrumb, and the request cell of the action history list.
10+
- **`titleFormat` still names `{process_name}`.** A placeholder template cannot fall back, and `process_label` can be null after the boot fill (a row it could not label, or a failed fill run). A renderer that reads `titleFormat` ahead of `nameField`, such as the global search palette, still shows the machine source.
11+
12+
A patch: one formula on one platform object's computed title. No export, type, accepted value or stored column changes.

‎.changeset/config.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@
6767
"@objectstack/service-job",
6868
"@objectstack/service-knowledge",
6969
"@objectstack/service-mailbox",
70+
"@objectstack/service-print",
7071
"@objectstack/service-messaging",
7172
"@objectstack/service-package",
7273
"@objectstack/service-queue",

‎.github/workflows/rerun-safety-nightly.yml‎

Lines changed: 76 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,17 @@
4646
# Turbo would replay cached results on the second pass and report green without
4747
# executing anything, which is precisely the wrong answer here. `--force`
4848
# bypasses the cache so the second pass genuinely re-runs.
49+
#
50+
# ## Why `--only` (#23880)
51+
#
52+
# `--force` applies to every task in the run, and `test` depends on `^build`, so
53+
# each pass also re-executed the whole build closure -- 69 `build` tasks and
54+
# `@objectstack/spec#gen:migration-registry` (`turbo run test --dry=json` at
55+
# 98ba1bf1d2) -- that `Build packages` had just built. `--only` runs the 83 `test`
56+
# tasks and nothing they depend on; all 69 of those builds are in `pnpm build`'s
57+
# own plan, and the spec's `test` script regenerates its migration registry
58+
# itself. It also stops pass 2 from rebuilding `dist/` over whatever pass 1 left
59+
# there, which is a working-tree state this job exists to see, not to repair.
4960
name: Rerun Safety
5061

5162
on:
@@ -77,8 +88,36 @@ jobs:
7788
# in 15 minutes and never at this number. Raising it can therefore hide no
7889
# hang; all it stops is a slow-but-live run being reported as a failure of
7990
# the thing under test.
91+
#
92+
# ⚠️ 170 no longer holds two full passes, and raising it again is not the
93+
# fix (#23880). Measured from the job's own step timestamps: pass 1 took 38
94+
# minutes on 2026-09-26 (the last green night), 66 on 10-02, and 97-113 on
95+
# every night from 10-12 to 10-16, while the queue-tier suite's measured
96+
# task time (scripts/test-shard-timings.json) grew from 124 to 194 minutes
97+
# between the 09-28 and 10-13 refreshes, the CLI's alone from 733 to 2206
98+
# seconds. Not a hang: on 10-16 pass 2 was still printing when the job
99+
# timeout cancelled it 67 minutes in. A night finished at all only because
100+
# pass 2 stopped at its first failure; one that did not fail was CANCELLED,
101+
# with no verdict and a conclusion the red-card filer does not read.
102+
#
103+
# So each pass now runs under the time the job has LEFT (JOB_TIMEOUT_MINUTES
104+
# below, which must equal this number, minus PASS_RESERVE_MINUTES for the
105+
# verdict), and a pass that runs out is stopped by `timeout` instead: the
106+
# verdict step still runs, reads the pass's 124/137 exit (or a log with no
107+
# turbo summary) as INCOMPLETE -- NOT MEASURED, and the job concludes
108+
# `failure`, never a silent `cancelled`. That bound decides only WHO reports
109+
# a slow night; how two full passes fit again is a capacity decision
110+
# recorded on #23880.
80111
timeout-minutes: 170
112+
env:
113+
JOB_TIMEOUT_MINUTES: 170
114+
# The verdict and the steps after it take seconds; the reserve also covers
115+
# `timeout --kill-after=2m`, the grace a pass gets between SIGTERM and SIGKILL.
116+
PASS_RESERVE_MINUTES: 5
81117
steps:
118+
- name: Record the job's deadline
119+
run: echo "JOB_DEADLINE_EPOCH=$(( $(date +%s) + JOB_TIMEOUT_MINUTES * 60 ))" >> "$GITHUB_ENV"
120+
82121
- name: Checkout repository
83122
uses: actions/checkout@v7
84123

@@ -142,6 +181,17 @@ jobs:
142181
# comment in ci.yml): grouped log order flushes a task's output only when
143182
# the task ends, so a solo tail task longer than the budget reads as
144183
# frozen while healthy. The guard refuses a turbo run without it.
184+
#
185+
# The time bound (#23880) sits INSIDE the guard, as the command it wraps:
186+
# the guard runs that command in its own process group, so `timeout` is
187+
# that group's leader and its SIGTERM reaches turbo, which stops its tasks.
188+
# Measured locally at 98ba1bf1d2 (guard -> timeout 45s -> turbo over core,
189+
# types and formula): exit 124 came back through the guard, and the log
190+
# ended at "Shutting down Turborepo tasks..." / "Force killed Turborepo
191+
# tasks: @objectstack/core#test" with no `Tasks:` summary. The verdict reads
192+
# 124 (or 137, after --kill-after) as a pass that did not finish. Wrapped
193+
# the other way round, `timeout` would kill only the guard and leave its
194+
# detached group running into the next step.
145195
- name: Test suite — pass 1
146196
run: |
147197
# Bound vitest's INNER worker pool (#11958). A no-op on a runner with
@@ -151,11 +201,23 @@ jobs:
151201
# vitest's own "use the default" signal. Needs turbo.json's
152202
# globalPassThroughEnv entry or turbo strips it — see the script header.
153203
export VITEST_MAX_WORKERS="$(node scripts/vitest-worker-cap.mjs)"
204+
budget=$(( (JOB_DEADLINE_EPOCH - $(date +%s)) / 60 - PASS_RESERVE_MINUTES ))
205+
echo "Pass 1 may run ${budget} minutes before the job's own timeout would end it."
154206
status=0
155-
node scripts/run-with-stall-guard.mjs --log "$RUNNER_TEMP/rerun-pass1.log" --stall-minutes 15 -- \
156-
pnpm turbo run test --concurrency=4 --force --log-order=stream || status=$?
207+
if [ "$budget" -lt 1 ]; then
208+
: > "$RUNNER_TEMP/rerun-pass1.log"
209+
status=124
210+
else
211+
node scripts/run-with-stall-guard.mjs --log "$RUNNER_TEMP/rerun-pass1.log" --stall-minutes 15 -- \
212+
timeout --kill-after=2m "${budget}m" \
213+
pnpm turbo run test --only --concurrency=4 --force --log-order=stream || status=$?
214+
fi
157215
echo "PASS1_EXIT=$status" >> "$GITHUB_ENV"
158-
if [ "$status" -ne 0 ]; then
216+
if [ "$status" -eq 124 ] || [ "$status" -eq 137 ]; then
217+
echo "::warning::Pass 1 ran out of the job's time (exit $status) before it finished."
218+
echo "::warning::That is a capacity reading, not a suite reading; the verdict step"
219+
echo "::warning::reports it as INCOMPLETE and will fail this job."
220+
elif [ "$status" -ne 0 ]; then
159221
echo "::warning::Pass 1 exited $status — the suite is red on main. Recorded as the"
160222
echo "::warning::BASELINE and NOT failed here: pass 2 runs anyway so the rerun"
161223
echo "::warning::property still gets measured tonight. The verdict step owns the"
@@ -197,9 +259,18 @@ jobs:
197259
# vitest's own "use the default" signal. Needs turbo.json's
198260
# globalPassThroughEnv entry or turbo strips it — see the script header.
199261
export VITEST_MAX_WORKERS="$(node scripts/vitest-worker-cap.mjs)"
262+
# Whatever pass 1 left of the job's time; see pass 1 for the bound.
263+
budget=$(( (JOB_DEADLINE_EPOCH - $(date +%s)) / 60 - PASS_RESERVE_MINUTES ))
264+
echo "Pass 2 may run ${budget} minutes before the job's own timeout would end it."
200265
status=0
201-
node scripts/run-with-stall-guard.mjs --log "$RUNNER_TEMP/rerun-pass2.log" --stall-minutes 15 -- \
202-
pnpm turbo run test --concurrency=4 --force --log-order=stream || status=$?
266+
if [ "$budget" -lt 1 ]; then
267+
: > "$RUNNER_TEMP/rerun-pass2.log"
268+
status=124
269+
else
270+
node scripts/run-with-stall-guard.mjs --log "$RUNNER_TEMP/rerun-pass2.log" --stall-minutes 15 -- \
271+
timeout --kill-after=2m "${budget}m" \
272+
pnpm turbo run test --only --concurrency=4 --force --log-order=stream || status=$?
273+
fi
203274
echo "PASS2_EXIT=$status" >> "$GITHUB_ENV"
204275
205276
# ⭐ THE VERDICT, and the job's exit status (#18590).

‎.github/workflows/scheduled-full-run-card.yml‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,14 @@ on:
7272
workflow_run:
7373
# Byte-exact workflow NAMES (the `name:` at the top of each file), not
7474
# paths. Quoted because `Lint & Type Check` starts a YAML alias unquoted.
75-
workflows: ['CI', 'Lint & Type Check']
75+
#
76+
# `Rerun Safety` (rerun-safety-nightly.yml, #23880) runs once a night, and
77+
# its scheduled runs from 2026-09-17 to 10-16 concluded `success` once in
78+
# thirty nights with no filer at all. It gets its own card by the same
79+
# identity rule, in its own cadence's words: every name listed here must
80+
# have an entry in `CARD_CADENCES` (scripts/ci/scheduled-full-run.mjs),
81+
# which refuses a name it does not hold rather than default one.
82+
workflows: ['CI', 'Lint & Type Check', 'Rerun Safety']
7683
types: [completed]
7784

7885
permissions: {}

‎content/docs/deployment/environment-variables.mdx‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,18 @@ read at startup unless noted otherwise. Boolean variables accept `true` / `false
5959

6060
---
6161

62+
## Printing
63+
64+
Read by `@objectstack/service-print` when `PrintServicePlugin` is composed; see [Print Pages](/docs/ui/print-pages). Configure **one** placement plus the console URL; a plugin option of the same purpose wins over its variable.
65+
66+
| Variable | Type | Default | Description |
67+
|:---|:---|:---|:---|
68+
| `OS_PRINT_CHROMIUM_PATH` | path | — | **Local placement**: a Chromium executable on this host, launched per print (plugin option `chromiumPath`). |
69+
| `OS_PRINT_BROWSER_ENDPOINT` | url | — | **Remote placement**: a browser's DevTools WebSocket endpoint, connected per print (option `browserEndpoint`). The minted session cookie travels in its frames, so use `wss://` off this host and `ws://` only on loopback. Never logged beyond its scheme and host. Setting it together with `OS_PRINT_CHROMIUM_PATH` is refused. |
70+
| `OS_PRINT_CONSOLE_URL` | url | — | The console URL that browser loads, `/_console` included (option `consoleUrl`). A URL carrying credentials (`user:password@`) is refused: the browser signs in with the requesting user's own session. |
71+
72+
---
73+
6274
## Secrets & Clustering
6375

6476
| Variable | Type | Default | Description |

0 commit comments

Comments
 (0)