Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
109 changes: 109 additions & 0 deletions src/email-templates/assignment.email-template.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

import { defineEmailTemplateDefinition } from '@objectstack/spec';

/**
* The assigner-facing text of the fan-out failure handler
* (`src/flows/assignment.flow.ts`, the `try_catch` catch region), as a
* `sys_email_template` bundle.
*
* ── Why this bundle exists at all ────────────────────────────────────────
* A fan-out that quietly drops one of five people is the failure #123 is
* about, and "the run log records it" is not an answer: the run log is an
* operator surface and the assigner never opens it. This is the sentence the
* assigner actually reads, in their inbox, once per assignee who got no task.
*
* ── Why the words are here and not on the notify node ────────────────────
* The same reason as `reminders.email-template.ts`: `NotifyConfigSchema`
* makes inline `title`/`message` and `template` mutually exclusive, and the
* inline path is the one its own `.describe()` calls "not localizable".
* AGENTS.md §8 ("English is the source language … do not hard-code display
* text in a hook or flow") is only satisfiable on the template path. Two rows
* — `en` and `zh-CN` — which is exactly the pair `objectstack.config.ts`
* declares in `i18n.supportedLocales`.
*
* ── `{{{…}}}` in `subject` / `bodyText`, `{{…}}` in `bodyHtml` ───────────
* Measured and pinned by `test/email-templates.test.ts`: `renderTemplate`
* HTML-escapes a `{{hole}}` and leaves a `{{{hole}}}` raw. The inbox channel
* writes the rendered subject into `sys_inbox_message.title` and the rendered
* TEXT into `body_md` — neither is an HTML document — so an assignment whose
* subject carries an apostrophe would otherwise put `'` on the assigner's
* screen. `bodyHtml` IS markup and keeps the escaping form.
*
* ── Why only `subject` is a REQUIRED variable ────────────────────────────
* `required: true` is enforced at render (`requireVars` → `MISSING_VARIABLES`,
* which the inbox channel classifies as PERMANENT — a dead delivery, not a
* retry). So it is declared only where the value is guaranteed:
* `duly_assignment.subject` is `required: true` on the object.
*
* `assignee` is deliberately NOT required, and that is the whole point of this
* notification rather than an oversight. The commonest bad row is an assignee
* entry that is itself blank — the "missing owner" shape — and a hole declared
* required would then dead-letter the one message whose job is to report it.
* The reason line carries the diagnosis in that case, and the click-through
* lands on the assignment where the assignee list can be read directly.
*/

/** Declared render inputs. One place, so the two rows cannot drift. */
const FANOUT_FAILURE_VARIABLES = [
{
name: 'subject',
type: 'string' as const,
required: true,
description: "The assignment's subject — duly_assignment.subject, required on the object.",
},
{
name: 'assignee',
type: 'string' as const,
required: false,
description:
'The assignee handle the fan-out was iterating when it failed. Blank when the '
+ 'assignee entry itself is the defect, which is why this is not required.',
},
{
name: 'reason',
type: 'string' as const,
required: false,
description: "The engine's own failure sentence, naming the flow node that failed.",
},
];

export const AssignmentFanoutFailedEn = defineEmailTemplateDefinition({
name: 'duly.assignment_fanout_failed',
label: 'Assignment fan-out could not reach one assignee',
category: 'notification',
locale: 'en',
subject: 'No task was created for one assignee: {{{subject}}}',
bodyHtml:
'<p>Everyone else on this assignment has their task. This one did not get created,'
+ ' so nobody is holding it.</p>'
+ '<p>Assignee: {{assignee}}<br />Reason: {{reason}}</p>',
bodyText:
'Everyone else on this assignment has their task. This one did not get created, so'
+ ' nobody is holding it.\nAssignee: {{{assignee}}}\nReason: {{{reason}}}',
variables: FANOUT_FAILURE_VARIABLES,
description:
'Sent to the assigner, once per assignee the fan-out could not create a task for. '
+ 'The rest of the fan-out completed.',
});

export const AssignmentFanoutFailedZhCN = defineEmailTemplateDefinition({
name: 'duly.assignment_fanout_failed',
label: '指派分发未能覆盖某位成员',
category: 'notification',
locale: 'zh-CN',
subject: '有一位成员没有生成任务:{{{subject}}}',
bodyHtml:
'<p>这项指派中其他人的任务都已生成,只有这一条没有创建成功,因此目前无人承担。</p>'
+ '<p>成员:{{assignee}}<br />原因:{{reason}}</p>',
bodyText:
'这项指派中其他人的任务都已生成,只有这一条没有创建成功,因此目前无人承担。\n'
+ '成员:{{{assignee}}}\n原因:{{{reason}}}',
variables: FANOUT_FAILURE_VARIABLES,
description: '每有一位成员未能生成任务,就向指派人发送一次;分发的其余部分已正常完成。',
});

export const dulyAssignmentEmailTemplates = [
AssignmentFanoutFailedEn,
AssignmentFanoutFailedZhCN,
];
13 changes: 12 additions & 1 deletion src/email-templates/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,11 @@
// here twice — once as `en`, once as `zh-CN`. Adding a locale is adding an
// entry to this array, never editing an existing one.

import {
AssignmentFanoutFailedEn,
AssignmentFanoutFailedZhCN,
dulyAssignmentEmailTemplates,
} from './assignment.email-template.js';
import {
TaskDueSoonReminderEn,
TaskDueSoonReminderZhCN,
Expand All @@ -38,6 +43,12 @@ export {
TaskOverdueEscalationEn,
TaskOverdueEscalationZhCN,
dulyReminderEmailTemplates,
AssignmentFanoutFailedEn,
AssignmentFanoutFailedZhCN,
dulyAssignmentEmailTemplates,
};

export const dulyEmailTemplates = [...dulyReminderEmailTemplates];
export const dulyEmailTemplates = [
...dulyReminderEmailTemplates,
...dulyAssignmentEmailTemplates,
];
218 changes: 161 additions & 57 deletions src/flows/assignment.flow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,52 @@ import { defineFlow } from '@objectstack/spec';
* so it is a decision and not an accident: an assigner who is also one of the
* assignees already owns a task on this assignment, so `needs_collection` adds
* no second one for them.
*
* ── One bad assignee must not cost the other four (#123) ─────────────────
* The loop body is wrapped in a `try_catch` container, and that wrapper is
* load-bearing rather than defensive dressing. `loop-node.ts` iterates with a
* bare `await engine.runRegion(...)` and has no try/catch of its own, so a
* body node that fails throws straight out of the CONTAINER: the first bad
* item ends the whole run, every later assignee is never processed, and the
* loop never returns its `childSteps` — so the rows it DID write are not even
* counted. Measured on the real engine before this wrapper, with a three-name
* assignment whose middle row was bad:
*
* status: failed · acted: 0 · duly_task rows actually written: 1
*
* Two people were silently dropped and the one task that was created was
* reported as nothing at all. That is worse than either failure on its own,
* which is why "abort the fan-out" is not an acceptable reading of a bad row.
*
* **What the assignment shows afterwards**, decided here so it is a contract
* and not an accident:
*
* 1. **The count is the truth.** `duly_assignment.task_count` is a
* `Field.summary` count over the children, so it reports the tasks that
* actually exist — two, on a three-name assignment with one bad row. The
* run summary now agrees with it (`acted: 2`), because a loop that
* completes returns the `childSteps` an aborted one threw away.
* 2. **The failure is named to the assigner**, in their inbox, one
* notification per failed assignee, carrying the assignee handle, the
* engine's own reason, and a click-through to the assignment. A partial
* fan-out that nobody is told about is the "silent partial success" this
* card exists to remove; the run log alone does not count, because it is
* an operator surface and the assigner never sees it.
*
* The flow still writes NOTHING back onto `duly_assignment` — not a note, not
* a status. It cannot: the start trigger is `record-after-write`, so a write
* back onto the trigger record re-enters this same flow on a row whose status
* is still `dispatched`, and the notification would then be re-sent on every
* re-entry. The inbox is the assigner-visible surface that costs no such loop.
*
* **The handler reads `{fanout_assignee}` and nothing else about the person.**
* The iterator variable is re-bound by the loop before every iteration, so it
* is the one value guaranteed to describe THIS item. `fanout_assignee_user` is
* not: a region that throws at `fanout_find_unit` leaves it holding the
* PREVIOUS iteration's row, and a handler that read a name from it would
* calmly name the wrong colleague. Where the assignee handle itself is the
* defect (a blank entry in `assignees` — the "missing owner" shape), the
* handle renders empty and the engine's reason carries the diagnosis.
*/
export const AssignmentFanout = defineFlow({
name: 'duly_assignment_fanout',
Expand Down Expand Up @@ -131,71 +177,129 @@ export const AssignmentFanout = defineFlow({
// of `sys_user` ids.
collection: '{record.assignees}',
iteratorVariable: 'fanout_assignee',
// The body is ONE node: the try_catch container. Everything that can
// fail for one person lives inside its `try`, so the blast radius of a
// bad row is that row (see the header). A region is single-entry /
// single-exit by construction, which a one-node body trivially is.
body: {
nodes: [
{
id: 'fanout_find_existing',
type: 'get_record',
label: 'Does this assignee already have a task?',
id: 'fanout_attempt',
type: 'try_catch',
label: 'One assignee, isolated from the rest',
config: {
objectName: 'duly_task',
// Per OWNER, not per assignment. `limit` omitted → findOne →
// the variable is set to the row or to null, never to [].
filter: { assignment: '{record.id}', owner: '{fanout_assignee}' },
fields: ['id'],
outputVariable: 'existing_task',
},
},
{
id: 'fanout_find_unit',
type: 'get_record',
label: "Read the assignee's business unit",
config: {
objectName: 'sys_user',
filter: { id: '{fanout_assignee}' },
fields: ['id', 'primary_business_unit_id'],
outputVariable: 'fanout_assignee_user',
},
},
{
id: 'fanout_create_task',
type: 'create_record',
label: 'Create the assignee task',
config: {
objectName: 'duly_task',
fields: {
subject: '{record.subject}',
owner: '{fanout_assignee}',
// Denormalised at dispatch so a later transfer does not
// rewrite history (see duly_task.business_unit).
business_unit: '{fanout_assignee_user.primary_business_unit_id}',
assignment: '{record.id}',
source: 'assigned',
due_date: '{record.due_date}',
// An assignment has no lead time to spread, so the task is
// visible from the day it is due.
visible_from: '{record.due_date}',
status: 'open',
// `period_key` is NOT written. An assignment has no period,
// and the dispatch identity index does not apply to it.
// `errorVariable` is left at its declared default `$error`,
// which is also the name `executeNode` binds a node failure
// to — one spelling, and no second key to keep in step.
try: {
nodes: [
{
id: 'fanout_find_existing',
type: 'get_record',
label: 'Does this assignee already have a task?',
config: {
objectName: 'duly_task',
// Per OWNER, not per assignment. `limit` omitted →
// findOne → the variable is set to the row or to null,
// never to [].
filter: { assignment: '{record.id}', owner: '{fanout_assignee}' },
fields: ['id'],
outputVariable: 'existing_task',
},
},
{
id: 'fanout_find_unit',
type: 'get_record',
label: "Read the assignee's business unit",
config: {
objectName: 'sys_user',
filter: { id: '{fanout_assignee}' },
fields: ['id', 'primary_business_unit_id'],
outputVariable: 'fanout_assignee_user',
},
},
{
id: 'fanout_create_task',
type: 'create_record',
label: 'Create the assignee task',
config: {
objectName: 'duly_task',
fields: {
subject: '{record.subject}',
owner: '{fanout_assignee}',
// Denormalised at dispatch so a later transfer does
// not rewrite history (see duly_task.business_unit).
business_unit: '{fanout_assignee_user.primary_business_unit_id}',
assignment: '{record.id}',
source: 'assigned',
due_date: '{record.due_date}',
// An assignment has no lead time to spread, so the
// task is visible from the day it is due.
visible_from: '{record.due_date}',
status: 'open',
// `period_key` is NOT written. An assignment has no
// period, and the dispatch identity index does not
// apply to it.
},
},
},
],
edges: [
{
id: 'fanout_e_missing',
source: 'fanout_find_existing',
target: 'fanout_find_unit',
type: 'conditional',
label: 'No task yet',
// `isBlank` takes the value itself (`dyn`), so it is
// total over null/undefined/'' /[] — unlike a field
// access through a null root, which aborts the predicate.
condition: P`isBlank(vars.existing_task)`,
},
{ id: 'fanout_e_create', source: 'fanout_find_unit', target: 'fanout_create_task' },
],
},
// The handler is what turns "this person got nothing" from a
// dropped row into something the assigner is told. It must not
// be able to fail the container itself: `notify` degrades to a
// no-op success when no messaging service is mounted, and a
// catch region that threw would put the abort straight back.
catch: {
nodes: [
{
id: 'fanout_report_failure',
type: 'notify',
label: 'Tell the assigner this person got no task',
config: {
recipients: '{record.assigner}',
// The localizable content path (AGENTS.md §8) — the
// words live in src/email-templates/, never inline.
template: 'duly.assignment_fanout_failed',
templateData: {
subject: '{record.subject}',
// The iterator, not `fanout_assignee_user`: see the
// header on why a stale row would name the wrong
// colleague.
assignee: '{fanout_assignee}',
// `$error.message` is the engine's own sentence and
// it names the node that failed, e.g. "Node
// 'fanout_create_task' failed: create_record
// (duly_task) failed: Owner is required".
reason: '{$error.message}',
},
severity: 'warning',
topic: 'duly.assignment_fanout_failed',
sourceObject: 'duly_assignment',
sourceId: '{record.id}',
},
},
],
edges: [],
},
},
},
],
edges: [
{
id: 'fanout_e_missing',
source: 'fanout_find_existing',
target: 'fanout_find_unit',
type: 'conditional',
label: 'No task yet',
// `isBlank` takes the value itself (`dyn`), so it is total over
// null/undefined/'' /[] — unlike a field access through a null
// root, which aborts the predicate.
condition: P`isBlank(vars.existing_task)`,
},
{ id: 'fanout_e_create', source: 'fanout_find_unit', target: 'fanout_create_task' },
],
edges: [],
},
},
},
Expand Down
9 changes: 9 additions & 0 deletions src/translations/authored-text.ts
Original file line number Diff line number Diff line change
Expand Up @@ -653,6 +653,15 @@ const RECORD_MAPS: ReadonlySet<string> = new Set(['object.fields']);
*/
const REENTER: Readonly<Record<string, string>> = {
'flow.nodes[].config.body': 'flow',
// ADR-0031 gives a `try_catch` container two regions of the same shape as a
// `loop` body, so they re-enter for the same reason (#123). Listed only for
// the slots this app actually authors: a `parallel` block's
// `config.branches[]` is NOT here, because a branch is `{name, nodes, edges}`
// and its `name` would land on `flow.name` — a verdict that reads "flow
// name" and would be wrong about it. When a parallel block is first
// authored here, it needs its own verdict for that key, not this shortcut.
'flow.nodes[].config.try': 'flow',
'flow.nodes[].config.catch': 'flow',
};

/** Two words of three-plus letters — the shape machine values do not have. */
Expand Down
Loading
Loading