@gadmin2n/schematics 0.0.87 → 0.0.89

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. package/dist/lib/application/files/gadmin2-game-angle-demo/.dockerignore +16 -2
  2. package/dist/lib/application/files/gadmin2-game-angle-demo/Dockerfile.codegen +40 -0
  3. package/dist/lib/application/files/gadmin2-game-angle-demo/Dockerfile.server +76 -0
  4. package/dist/lib/application/files/gadmin2-game-angle-demo/Dockerfile.web +53 -0
  5. package/dist/lib/application/files/gadmin2-game-angle-demo/Jenkinsfile +219 -33
  6. package/dist/lib/application/files/gadmin2-game-angle-demo/compose-ctl.sh +250 -0
  7. package/dist/lib/application/files/gadmin2-game-angle-demo/config/prisma/workflow.prisma +4 -1
  8. package/dist/lib/application/files/gadmin2-game-angle-demo/dev/postgres/init.sql +12 -0
  9. package/dist/lib/application/files/gadmin2-game-angle-demo/docker-compose.md +170 -0
  10. package/dist/lib/application/files/gadmin2-game-angle-demo/docker-compose.yml +254 -0
  11. package/dist/lib/application/files/gadmin2-game-angle-demo/server/package.json +8 -7
  12. package/dist/lib/application/files/gadmin2-game-angle-demo/server/scripts/lib/page-helpers.ts +1 -1
  13. package/dist/lib/application/files/gadmin2-game-angle-demo/server/scripts/prismaModels.ts +1 -1
  14. package/dist/lib/application/files/gadmin2-game-angle-demo/server/seed/agenda.seed.ts +39 -0
  15. package/dist/lib/application/files/gadmin2-game-angle-demo/server/seed/audit.seed.ts +40 -0
  16. package/dist/lib/application/files/gadmin2-game-angle-demo/server/seed/bootstrap.ts +56 -0
  17. package/dist/lib/application/files/gadmin2-game-angle-demo/server/seed/canvas.seed.ts +39 -0
  18. package/dist/lib/application/files/gadmin2-game-angle-demo/server/{scripts/sync-data-mngt-pages.ts → seed/data-mngt.seed.ts} +36 -20
  19. package/dist/lib/application/files/gadmin2-game-angle-demo/server/seed/game.seed.ts +44 -0
  20. package/dist/lib/application/files/gadmin2-game-angle-demo/server/seed/index.ts +30 -6
  21. package/dist/lib/application/files/gadmin2-game-angle-demo/server/seed/permission.seed.ts +130 -0
  22. package/dist/lib/application/files/gadmin2-game-angle-demo/server/seed/workflow-event-trigger.ts +60 -0
  23. package/dist/lib/application/files/gadmin2-game-angle-demo/server/seed/workflow-node-types.ts +11 -25
  24. package/dist/lib/application/files/gadmin2-game-angle-demo/server/seed/workflow.seed.ts +108 -0
  25. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/main.ts +1 -0
  26. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/agendaJob/agendaJob.controller.spec.ts +31 -2
  27. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/audit/audit.controller.spec.ts +31 -2
  28. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/audit/audit.service.spec.ts +41 -57
  29. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/game/game.controller.spec.ts +31 -2
  30. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/game/game.service.spec.ts +309 -1
  31. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/page/page.controller.spec.ts +31 -2
  32. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/page/page.service.spec.ts +315 -1
  33. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/pageResource/pageResource.controller.spec.ts +31 -2
  34. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/pageResource/pageResource.service.spec.ts +312 -2
  35. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/resource/resource.controller.spec.ts +31 -2
  36. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/resource/resource.service.spec.ts +317 -1
  37. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/role/role.controller.spec.ts +31 -2
  38. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/role/role.service.spec.ts +309 -1
  39. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/rolePages/rolePages.controller.spec.ts +31 -2
  40. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/rolePages/rolePages.service.spec.ts +299 -1
  41. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/roleResource/roleResource.controller.spec.ts +31 -2
  42. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/roleResource/roleResource.service.spec.ts +307 -1
  43. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/user/user.controller.spec.ts +31 -2
  44. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/user/user.service.spec.ts +309 -1
  45. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/dsl-validate.util.spec.ts +205 -0
  46. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/dsl-validate.util.ts +116 -0
  47. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/temporal.service.spec.ts +158 -0
  48. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/temporal.service.ts +110 -1
  49. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/webhook-signature.util.spec.ts +79 -0
  50. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/webhook-signature.util.ts +54 -0
  51. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow.controller.ts +34 -0
  52. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow.service.spec.ts +457 -0
  53. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflow/workflow.service.ts +241 -4
  54. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflowEventOutbox/workflowEventOutbox.controller.spec.ts +34 -2
  55. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflowEventOutbox/workflowEventOutbox.service.spec.ts +24 -30
  56. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflowNodeInstance/workflowNodeInstance.controller.spec.ts +34 -2
  57. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflowNodeInstance/workflowNodeInstance.service.spec.ts +36 -36
  58. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflowNodeType/workflowNodeType.controller.spec.ts +34 -2
  59. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/workflowNodeType/workflowNodeType.service.spec.ts +48 -24
  60. package/dist/lib/application/files/gadmin2-game-angle-demo/temporal/README.md +312 -3
  61. package/dist/lib/application/files/gadmin2-game-angle-demo/temporal/TODO.md +152 -0
  62. package/dist/lib/application/files/gadmin2-game-angle-demo/temporal/worker/.dockerignore +12 -0
  63. package/dist/lib/application/files/gadmin2-game-angle-demo/temporal/worker/Dockerfile +79 -0
  64. package/dist/lib/application/files/gadmin2-game-angle-demo/temporal/worker/GRACEFUL-DEPLOYMENT.md +270 -0
  65. package/dist/lib/application/files/gadmin2-game-angle-demo/temporal/worker/src/activities/index.ts +1 -1
  66. package/dist/lib/application/files/gadmin2-game-angle-demo/temporal/worker/src/activities/reporting.ts +23 -0
  67. package/dist/lib/application/files/gadmin2-game-angle-demo/temporal/worker/src/index.ts +70 -5
  68. package/dist/lib/application/files/gadmin2-game-angle-demo/temporal/worker/src/outbox-poller.ts +246 -90
  69. package/dist/lib/application/files/gadmin2-game-angle-demo/temporal/worker/src/tests/cron-trigger-workflow.test.ts +20 -0
  70. package/dist/lib/application/files/gadmin2-game-angle-demo/temporal/worker/src/workflows/dsl-workflow.ts +96 -8
  71. package/dist/lib/application/files/gadmin2-game-angle-demo/web/nginx.conf +74 -0
  72. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/components/agentPanel/ElementInspector.tsx +18 -0
  73. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/components/agentPanel/promptGenerator.ts +1 -1
  74. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/helpers/form.tsx +1 -1
  75. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/locales/en/common.json +3 -3
  76. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/locales/zh_CN/common.json +3 -3
  77. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/plugins/devShellPlugin.ts +4 -1
  78. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/canvas/CanvasEditPage.tsx +9 -0
  79. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/canvas/CanvasListPage.tsx +156 -139
  80. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/canvas/CanvasPage.tsx +14 -2
  81. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/canvas/CanvasToolbar.tsx +62 -0
  82. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/canvas/PublishModal.tsx +4 -6
  83. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/canvas/canvasApi.ts +18 -27
  84. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/canvas/canvasDefaults.ts +32 -11
  85. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/canvas/demos.ts +48 -61
  86. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/canvas-page/index.tsx +3 -6
  87. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/components/DslView.tsx +16 -16
  88. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/editor.tsx +28 -35
  89. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/instance-detail.tsx +34 -3
  90. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/show.tsx +1 -1
  91. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/routes/workflow/types.ts +1 -1
  92. package/dist/lib/application/files/gadmin2-game-angle-demo/web/src/styles/antd.css +6 -0
  93. package/package.json +1 -1
  94. package/dist/lib/application/files/gadmin2-game-angle-demo/Dockerfile +0 -63
  95. package/dist/lib/application/files/gadmin2-game-angle-demo/server/scripts/sync-resources.ts +0 -100
  96. package/dist/lib/application/files/gadmin2-game-angle-demo/server/seed/permissions.ts +0 -302
  97. package/dist/lib/application/files/gadmin2-game-angle-demo/server/src/modules/canvas/canvas.controller.spec.ts +0 -20
  98. package/dist/lib/application/files/gadmin2-game-angle-demo/temporal/sql/create-event-trigger.sql +0 -87
  99. /package/dist/lib/application/files/gadmin2-game-angle-demo/{GRACEFUL-DEPLOYMENT.md → server/GRACEFUL-DEPLOYMENT.md} +0 -0
@@ -1,11 +1,25 @@
1
1
  import { Client as PgClient } from "pg";
2
- import { Client, Connection } from "@temporalio/client";
2
+ import {
3
+ Client,
4
+ Connection,
5
+ WorkflowExecutionAlreadyStartedError,
6
+ } from "@temporalio/client";
3
7
  import { config } from "./config";
4
8
 
5
9
  const POLL_INTERVAL = Number(process.env.EVENT_POLL_INTERVAL_MS) || 10_000; // 默认 10 秒
6
10
  const BATCH_SIZE = 50;
7
11
  const MAX_RETRIES = Number(process.env.OUTBOX_MAX_RETRIES) || 5;
8
12
 
13
+ /**
14
+ * Outbox Poller 控制句柄。stop() 用于优雅关闭:
15
+ * - 不再调度下一次 poll
16
+ * - 等待当前 in-flight poll 完成
17
+ * - 释放 PG 与 Temporal 连接
18
+ */
19
+ export interface OutboxPollerHandle {
20
+ stop(): Promise<void>;
21
+ }
22
+
9
23
  /**
10
24
  * Outbox Poller — 定时扫描 t_workflow_event_outbox 未处理事件,匹配并触发 workflow。
11
25
  *
@@ -14,8 +28,14 @@ const MAX_RETRIES = Number(process.env.OUTBOX_MAX_RETRIES) || 5;
14
28
  * 2. 本轮询器每 N 秒扫描未处理行
15
29
  * 3. 匹配 event_trigger workflow → 启动 Temporal 执行
16
30
  * 4. 标记行为已处理
31
+ *
32
+ * 事务边界(重要):
33
+ * - 每个 outbox 行用一个独立的 PG 事务包裹:BEGIN → SELECT FOR UPDATE → 触发 workflow → UPDATE processed → COMMIT
34
+ * - workflowId 由 outbox.id 派生为确定性值(`wf-outbox-{id}-w{workflowId}`),
35
+ * 即使在 workflow.start 与 COMMIT 之间崩溃,下次 poll 重试时 Temporal 会抛
36
+ * WorkflowExecutionAlreadyStartedError,被本模块识别为幂等成功,不会丢事件、不会重复启动 workflow。
17
37
  */
18
- export async function startOutboxPoller() {
38
+ export async function startOutboxPoller(): Promise<OutboxPollerHandle> {
19
39
  const pg = new PgClient({ connectionString: config.database.url });
20
40
  await pg.connect();
21
41
 
@@ -27,68 +47,27 @@ export async function startOutboxPoller() {
27
47
  namespace: config.temporal.namespace,
28
48
  });
29
49
 
30
- console.log(`[OutboxPoller] Started. Polling every ${POLL_INTERVAL}ms, max retries: ${MAX_RETRIES}`);
50
+ console.log(
51
+ `[OutboxPoller] Started. Polling every ${POLL_INTERVAL}ms, max retries: ${MAX_RETRIES}`,
52
+ );
31
53
 
32
54
  const poll = async () => {
33
55
  try {
34
- // FOR UPDATE SKIP LOCKED 保证多实例不重复消费
35
- const { rows } = await pg.query(
36
- `UPDATE t_workflow_event_outbox
37
- SET processed = TRUE, processed_at = NOW()
38
- WHERE id IN (
39
- SELECT id FROM t_workflow_event_outbox
40
- WHERE processed = FALSE
41
- ORDER BY created_at
42
- LIMIT $1
43
- FOR UPDATE SKIP LOCKED
44
- )
45
- RETURNING id, event_name, payload, retry_count`,
56
+ // 1. SELECT 候选 id(不锁),减少长事务持锁时间
57
+ const { rows: candidates } = await pg.query(
58
+ `SELECT id FROM t_workflow_event_outbox
59
+ WHERE processed = FALSE
60
+ ORDER BY created_at
61
+ LIMIT $1`,
46
62
  [BATCH_SIZE],
47
63
  );
48
64
 
49
- if (rows.length === 0) return;
50
- console.log(`[OutboxPoller] Processing ${rows.length} events`);
65
+ if (candidates.length === 0) return;
66
+ console.log(`[OutboxPoller] Found ${candidates.length} candidate events`);
51
67
 
52
- for (const row of rows) {
53
- try {
54
- const matched = await matchAndTrigger(
55
- pg,
56
- temporalClient,
57
- row.event_name,
58
- row.payload,
59
- );
60
- if (matched === 0) {
61
- console.warn(
62
- `[OutboxPoller] No workflow matched event "${row.event_name}" (id=${row.id}). Event consumed without action.`,
63
- );
64
- }
65
- } catch (err: any) {
66
- const nextRetry = (row.retry_count ?? 0) + 1;
67
- const errMsg = String(err?.message ?? err);
68
- if (nextRetry >= MAX_RETRIES) {
69
- // 终态失败:保持 processed=true,仅记录 retry_count + last_error,不再重试
70
- console.error(
71
- `[OutboxPoller] Event ${row.id} (${row.event_name}) failed ${nextRetry} times. Marking as terminal failure: ${errMsg}`,
72
- );
73
- await pg.query(
74
- `UPDATE t_workflow_event_outbox
75
- SET retry_count = $1, last_error = $2
76
- WHERE id = $3`,
77
- [nextRetry, errMsg, row.id],
78
- );
79
- } else {
80
- console.error(
81
- `[OutboxPoller] Event ${row.id} (${row.event_name}) failed (retry ${nextRetry}/${MAX_RETRIES}): ${errMsg}`,
82
- );
83
- // 标记回未处理,下次重试
84
- await pg.query(
85
- `UPDATE t_workflow_event_outbox
86
- SET processed = FALSE, processed_at = NULL, retry_count = $1, last_error = $2
87
- WHERE id = $3`,
88
- [nextRetry, errMsg, row.id],
89
- );
90
- }
91
- }
68
+ // 2. 逐行处理:每行一个独立事务,保证 workflow.start outbox UPDATE 的原子性
69
+ for (const { id } of candidates) {
70
+ await processOneEvent(pg, temporalClient, Number(id));
92
71
  }
93
72
  } catch (err: any) {
94
73
  console.error("[OutboxPoller] Poll error:", err.message);
@@ -96,24 +75,180 @@ export async function startOutboxPoller() {
96
75
  };
97
76
 
98
77
  // 自调度循环:每次 poll 完成后再排下一次,避免 setInterval 在 poll 慢于 POLL_INTERVAL 时重叠调用
78
+ let stopped = false;
79
+ let timer: NodeJS.Timeout | null = null;
80
+ let inFlight: Promise<void> | null = null;
81
+
99
82
  const loop = async () => {
83
+ if (stopped) return;
84
+ inFlight = poll();
100
85
  try {
101
- await poll();
86
+ await inFlight;
102
87
  } finally {
103
- setTimeout(loop, POLL_INTERVAL);
88
+ inFlight = null;
89
+ if (!stopped) {
90
+ timer = setTimeout(loop, POLL_INTERVAL);
91
+ }
104
92
  }
105
93
  };
106
- loop();
94
+ void loop();
95
+
96
+ return {
97
+ async stop() {
98
+ stopped = true;
99
+ if (timer) {
100
+ clearTimeout(timer);
101
+ timer = null;
102
+ }
103
+ // 等待当前正在执行的 poll 完成(含本轮所有事件处理)
104
+ if (inFlight) {
105
+ try {
106
+ await inFlight;
107
+ } catch {
108
+ // poll 内部已捕获错误并写日志,这里忽略
109
+ }
110
+ }
111
+ try {
112
+ await pg.end();
113
+ } catch (err: any) {
114
+ console.error(
115
+ "[OutboxPoller] PG client close error:",
116
+ err?.message ?? err,
117
+ );
118
+ }
119
+ try {
120
+ await temporalConn.close();
121
+ } catch (err: any) {
122
+ console.error(
123
+ "[OutboxPoller] Temporal connection close error:",
124
+ err?.message ?? err,
125
+ );
126
+ }
127
+ console.log("[OutboxPoller] Stopped, resources released");
128
+ },
129
+ };
130
+ }
131
+
132
+ /**
133
+ * 处理单条 outbox 事件(一个事务)。
134
+ *
135
+ * 流程:
136
+ * BEGIN
137
+ * SELECT FOR UPDATE SKIP LOCKED
138
+ * ↓
139
+ * matchAndTrigger(INSERT instance + workflow.start,workflowId 由 outbox.id 派生)
140
+ * ↓
141
+ * ┌─ 全部成功: UPDATE processed=TRUE → COMMIT
142
+ * ├─ 还能重试: UPDATE retry_count(保持 processed=FALSE)→ COMMIT
143
+ * └─ 终态失败: UPDATE processed=TRUE + last_error → COMMIT
144
+ */
145
+ async function processOneEvent(
146
+ pg: PgClient,
147
+ temporalClient: Client,
148
+ outboxId: number,
149
+ ): Promise<void> {
150
+ await pg.query("BEGIN");
151
+ let inTransaction = true;
152
+ try {
153
+ // 行级锁:SKIP LOCKED 保证多实例 poller 不会同时处理同一行
154
+ const { rows } = await pg.query(
155
+ `SELECT id, event_name, payload, retry_count
156
+ FROM t_workflow_event_outbox
157
+ WHERE id = $1 AND processed = FALSE
158
+ FOR UPDATE SKIP LOCKED`,
159
+ [outboxId],
160
+ );
161
+
162
+ if (rows.length === 0) {
163
+ // 已被另一实例处理或已 processed
164
+ await pg.query("ROLLBACK");
165
+ inTransaction = false;
166
+ return;
167
+ }
168
+
169
+ const row = rows[0];
170
+ try {
171
+ const matched = await matchAndTrigger(
172
+ pg,
173
+ temporalClient,
174
+ Number(row.id),
175
+ row.event_name,
176
+ row.payload,
177
+ );
178
+ if (matched === 0) {
179
+ console.warn(
180
+ `[OutboxPoller] No workflow matched event "${row.event_name}" (id=${row.id}). Event consumed without action.`,
181
+ );
182
+ }
183
+
184
+ // 全部 workflow.start 成功(含幂等成功),原子提交"已处理"
185
+ await pg.query(
186
+ `UPDATE t_workflow_event_outbox
187
+ SET processed = TRUE, processed_at = NOW()
188
+ WHERE id = $1`,
189
+ [row.id],
190
+ );
191
+ await pg.query("COMMIT");
192
+ inTransaction = false;
193
+ } catch (err: any) {
194
+ // workflow.start / DB 操作失败 —— 回滚后用独立事务记录 retry 状态
195
+ await pg.query("ROLLBACK");
196
+ inTransaction = false;
197
+
198
+ const nextRetry = (row.retry_count ?? 0) + 1;
199
+ const errMsg = String(err?.message ?? err);
200
+
201
+ if (nextRetry >= MAX_RETRIES) {
202
+ console.error(
203
+ `[OutboxPoller] Event ${row.id} (${row.event_name}) failed ${nextRetry} times. Marking as terminal failure: ${errMsg}`,
204
+ );
205
+ await pg.query(
206
+ `UPDATE t_workflow_event_outbox
207
+ SET processed = TRUE, processed_at = NOW(), retry_count = $1, last_error = $2
208
+ WHERE id = $3`,
209
+ [nextRetry, errMsg, row.id],
210
+ );
211
+ } else {
212
+ console.error(
213
+ `[OutboxPoller] Event ${row.id} (${row.event_name}) failed (retry ${nextRetry}/${MAX_RETRIES}): ${errMsg}`,
214
+ );
215
+ await pg.query(
216
+ `UPDATE t_workflow_event_outbox
217
+ SET retry_count = $1, last_error = $2
218
+ WHERE id = $3`,
219
+ [nextRetry, errMsg, row.id],
220
+ );
221
+ }
222
+ }
223
+ } catch (txErr: any) {
224
+ // 事务级异常(PG 连接断等)— 尝试回滚兜底
225
+ if (inTransaction) {
226
+ try {
227
+ await pg.query("ROLLBACK");
228
+ } catch {
229
+ /* ignore */
230
+ }
231
+ }
232
+ console.error(
233
+ `[OutboxPoller] Tx error for outbox ${outboxId}:`,
234
+ txErr?.message ?? txErr,
235
+ );
236
+ }
107
237
  }
108
238
 
109
239
  /**
110
240
  * 查找监听该 eventName 的 workflow 并启动执行。
111
241
  *
112
- * @returns 实际匹配并启动的 workflow 数量;0 表示无人订阅。
242
+ * **幂等性**:workflowId outboxId + workflow_id 派生为确定性值。
243
+ * 若先前 poll 已成功 start 但事务未 COMMIT 就崩溃,本次重试时
244
+ * Temporal 会抛 WorkflowExecutionAlreadyStartedError —— 视为幂等成功。
245
+ *
246
+ * @returns 实际匹配并启动(或确认已启动)的 workflow 数量;0 表示无人订阅。
113
247
  */
114
248
  async function matchAndTrigger(
115
249
  pg: PgClient,
116
250
  temporalClient: Client,
251
+ outboxId: number,
117
252
  eventName: string,
118
253
  payload: Record<string, any>,
119
254
  ): Promise<number> {
@@ -150,40 +285,61 @@ async function matchAndTrigger(
150
285
  if (!evaluateFilter(trigger.config.filterExpression, payload)) continue;
151
286
  }
152
287
 
153
- // 创建 WorkflowInstance
154
- const instance = await pg.query(
155
- `INSERT INTO t_workflow_instance (workflow_id, version_id, status, context, creator, created_at, updated_at)
156
- VALUES ($1, $2, 'PENDING', $3, 'event_trigger', NOW(), NOW())
157
- RETURNING id`,
158
- [row.workflow_id, row.version_id, JSON.stringify(payload)],
288
+ // 确定性 workflowId(用于幂等保护)
289
+ const deterministicWorkflowId = `wf-outbox-${outboxId}-w${row.workflow_id}`;
290
+
291
+ // 创建(或复用)WorkflowInstance —— (workflow_id, source_outbox_id) UNIQUE 做幂等 upsert。
292
+ // 崩溃恢复路径下 (workflow_id, outboxId) 相同 → 直接拿到上次的 instanceId,不会产生孤儿行。
293
+ // 一个 outbox 事件可被多个 workflow 订阅,每个 workflow 各自得到一条 instance。
294
+ const instanceUpsert = await pg.query(
295
+ `INSERT INTO t_workflow_instance
296
+ (workflow_id, version_id, status, context, creator, source_outbox_id, created_at, updated_at)
297
+ VALUES ($1, $2, 'PENDING', $3, 'event_trigger', $4, NOW(), NOW())
298
+ ON CONFLICT (workflow_id, source_outbox_id) DO UPDATE
299
+ SET updated_at = NOW()
300
+ RETURNING id, (xmax = 0) AS inserted`,
301
+ [row.workflow_id, row.version_id, JSON.stringify(payload), outboxId],
159
302
  );
160
- const instanceId = Number(instance.rows[0].id);
161
-
162
- // 启动 Temporal workflow
163
- const workflowId = `wf-${row.workflow_id}-${instanceId}`;
164
- const handle = await temporalClient.workflow.start("dslWorkflow", {
165
- taskQueue: config.temporal.taskQueue,
166
- workflowId,
167
- args: [
168
- {
169
- instanceId,
170
- workflowId: Number(row.workflow_id),
171
- versionId: Number(row.version_id),
172
- dsl,
173
- context: payload,
174
- },
175
- ],
176
- });
177
-
178
- // 回写 temporal_run_id
303
+ const instanceId = Number(instanceUpsert.rows[0].id);
304
+ const wasInserted = instanceUpsert.rows[0].inserted as boolean;
305
+
306
+ // 启动 Temporal workflow(幂等:同 workflowId 第二次启动会抛 AlreadyStartedError)
307
+ let temporalRunId = deterministicWorkflowId;
308
+ try {
309
+ const handle = await temporalClient.workflow.start("dslWorkflow", {
310
+ taskQueue: config.temporal.taskQueue,
311
+ workflowId: deterministicWorkflowId,
312
+ args: [
313
+ {
314
+ instanceId,
315
+ workflowId: Number(row.workflow_id),
316
+ versionId: Number(row.version_id),
317
+ dsl,
318
+ context: payload,
319
+ },
320
+ ],
321
+ });
322
+ temporalRunId = handle.workflowId;
323
+ console.log(
324
+ `[OutboxPoller] Triggered workflow ${row.workflow_id} for event "${eventName}", instance=${instanceId}${wasInserted ? "" : " (recovered)"}, wfId=${deterministicWorkflowId}`,
325
+ );
326
+ } catch (err: any) {
327
+ if (err instanceof WorkflowExecutionAlreadyStartedError) {
328
+ // 上一轮 start 成功但 outbox 提交前崩溃 —— 这次重放视为幂等成功
329
+ console.warn(
330
+ `[OutboxPoller] Workflow ${deterministicWorkflowId} already exists (recovery). outboxId=${outboxId}, instance=${instanceId}`,
331
+ );
332
+ } else {
333
+ throw err; // 其它错误冒泡,触发上层 ROLLBACK + retry
334
+ }
335
+ }
336
+
337
+ // 回写 temporal_run_id(即便走的是 recovery 分支也确保字段不为空)
179
338
  await pg.query(
180
339
  `UPDATE t_workflow_instance SET temporal_run_id = $1 WHERE id = $2`,
181
- [handle.workflowId, instanceId],
340
+ [temporalRunId, instanceId],
182
341
  );
183
342
 
184
- console.log(
185
- `[OutboxPoller] Triggered workflow ${row.workflow_id} for event "${eventName}", instance=${instanceId}`,
186
- );
187
343
  matchedCount++;
188
344
  }
189
345
 
@@ -0,0 +1,20 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import * as workflows from '../workflows/dsl-workflow';
3
+
4
+ /**
5
+ * Smoke test: confirm cronTriggerWorkflow is exported from dsl-workflow.ts so the
6
+ * Temporal SDK auto-registers it via the workflowsPath setting in worker/src/index.ts.
7
+ *
8
+ * We don't run a TestWorkflowEnvironment here — that requires booting a real Temporal
9
+ * Server. The actual schedule registration + workflow execution paths are covered by
10
+ * the server-side TemporalService unit tests.
11
+ */
12
+ describe('cronTriggerWorkflow export', () => {
13
+ it('is exported as a function', () => {
14
+ expect(typeof workflows.cronTriggerWorkflow).toBe('function');
15
+ });
16
+
17
+ it('dslWorkflow is also still exported', () => {
18
+ expect(typeof workflows.dslWorkflow).toBe('function');
19
+ });
20
+ });
@@ -41,10 +41,44 @@ import {
41
41
  } from '../dsl/helpers';
42
42
 
43
43
  // ─── Activity Proxies ─────────────────────────────────────────────────────────
44
-
45
- const acts = proxyActivities<typeof import('../activities')>({
44
+ //
45
+ // Activity 按"职责类别"分组配置 timeout / retry,避免一刀切。
46
+ // startToCloseTimeout 必须覆盖:业务上限(含 axios/vm 内部 timeout)+ Worker SIGTERM 后 cancel 响应窗口。
47
+ // 业务可在 dsl.config 里通过 `timeoutSec` 覆盖某次调用,这里的值是默认值/上限。
48
+ //
49
+ // 注意:单条 Activity 的最坏耗时 ≈ startToCloseTimeout × maximumAttempts。
50
+ // 若长于 K8s Pod 的 grace period(300s)会被 SIGKILL,把后续重试推给其它 worker。
51
+
52
+ // HTTP — axios 默认 timeout 30s,给 Activity 预留 cancel 响应余量
53
+ const httpActs = proxyActivities<
54
+ Pick<typeof import('../activities'), 'httpRequest'>
55
+ >({
46
56
  startToCloseTimeout: '60s',
47
- retry: { maximumAttempts: 3 },
57
+ retry: { maximumAttempts: 3, initialInterval: '1s', maximumInterval: '30s' },
58
+ });
59
+
60
+ // DB — 慢查询 / 大写入可能比较久;不重试写入(dbExecute 可能非幂等)
61
+ const dbActs = proxyActivities<
62
+ Pick<typeof import('../activities'), 'dbQuery' | 'dbExecute'>
63
+ >({
64
+ startToCloseTimeout: '120s',
65
+ retry: { maximumAttempts: 1 }, // 写入默认不重试,避免重复扣款类问题
66
+ });
67
+
68
+ // 通知 —— 邮件/IM 网关一般快;失败重试要积极
69
+ const notifyActs = proxyActivities<
70
+ Pick<typeof import('../activities'), 'sendNotification'>
71
+ >({
72
+ startToCloseTimeout: '30s',
73
+ retry: { maximumAttempts: 5, initialInterval: '2s', maximumInterval: '60s' },
74
+ });
75
+
76
+ // 自定义 JS —— vm 内部默认 5s timeout,外层留足边界 + 不重试(脚本可能非幂等)
77
+ const codeActs = proxyActivities<
78
+ Pick<typeof import('../activities'), 'codeExecute'>
79
+ >({
80
+ startToCloseTimeout: '15s',
81
+ retry: { maximumAttempts: 1 },
48
82
  });
49
83
 
50
84
  const reportActs = proxyActivities<typeof import('../activities')>({
@@ -123,9 +157,22 @@ async function executeGraph(
123
157
  const executed = new Set<string>();
124
158
  const queue = [...startNodeIds];
125
159
 
160
+ // 防御:DSL 是循环图(A→B→A)或不可达 predecessor 时,
161
+ // 当前 BFS 会把 nodeId 不停 push 回 queue 形成死循环。
162
+ // 给一个上限:每个节点最多被处理 nodeCount * 2 次(包含一次重排队)。
163
+ const MAX_ITERATIONS = Math.max(dsl.nodes.length * 10, 100);
164
+ let iterations = 0;
165
+
126
166
  while (queue.length > 0) {
127
167
  if (isCancelled()) throw new CancellationScope().cancel;
128
168
 
169
+ if (++iterations > MAX_ITERATIONS) {
170
+ throw new Error(
171
+ `executeGraph exceeded ${MAX_ITERATIONS} iterations — DSL likely contains a cycle ` +
172
+ `or unreachable predecessors. Pending queue: [${queue.join(', ')}], executed: [${[...executed].join(', ')}]`,
173
+ );
174
+ }
175
+
129
176
  const nodeId = queue.shift()!;
130
177
  if (executed.has(nodeId)) continue;
131
178
 
@@ -217,27 +264,27 @@ async function executeNode(
217
264
 
218
265
  // ─── Actions ────────────────────────────────────────────────────
219
266
  case 'http_request': {
220
- const result = await acts.httpRequest(config as any);
267
+ const result = await httpActs.httpRequest(config as any);
221
268
  return { output: result };
222
269
  }
223
270
 
224
271
  case 'db_query': {
225
- const result = await acts.dbQuery(config as any);
272
+ const result = await dbActs.dbQuery(config as any);
226
273
  return { output: result };
227
274
  }
228
275
 
229
276
  case 'db_execute': {
230
- const result = await acts.dbExecute(config as any);
277
+ const result = await dbActs.dbExecute(config as any);
231
278
  return { output: result };
232
279
  }
233
280
 
234
281
  case 'send_notification': {
235
- const result = await acts.sendNotification(config as any);
282
+ const result = await notifyActs.sendNotification(config as any);
236
283
  return { output: result };
237
284
  }
238
285
 
239
286
  case 'code': {
240
- const result = await acts.codeExecute({
287
+ const result = await codeActs.codeExecute({
241
288
  ...config,
242
289
  data: ctx,
243
290
  } as any);
@@ -409,3 +456,44 @@ async function executeNode(
409
456
 
410
457
  // ─── Helper Functions ─────────────────────────────────────────────────────────
411
458
  // Pure functions are defined in ../dsl/helpers.ts and imported above.
459
+
460
+ // ─── Cron Trigger Workflow ────────────────────────────────────────────────────
461
+ //
462
+ // Started by Temporal Schedules (registered via NestJS TemporalService.upsertCronSchedule).
463
+ // Creates a t_workflow_instance row with creator='cron', then delegates to dslWorkflow
464
+ // as a child workflow so it shares all the per-node logic.
465
+
466
+ const cronActs = proxyActivities<typeof import('../activities')>({
467
+ startToCloseTimeout: '15s',
468
+ retry: { maximumAttempts: 5 },
469
+ });
470
+
471
+ export interface CronTriggerInput {
472
+ workflowId: number;
473
+ versionId: number;
474
+ dsl: WorkflowDSL;
475
+ scheduleId: string;
476
+ }
477
+
478
+ export async function cronTriggerWorkflow(
479
+ input: CronTriggerInput,
480
+ ): Promise<WorkflowResult> {
481
+ const { instanceId } = await cronActs.createCronInstance({
482
+ workflowId: input.workflowId,
483
+ versionId: input.versionId,
484
+ scheduleId: input.scheduleId,
485
+ });
486
+
487
+ return await executeChild(dslWorkflow, {
488
+ workflowId: `wf-${input.workflowId}-${instanceId}`,
489
+ args: [
490
+ {
491
+ instanceId,
492
+ workflowId: input.workflowId,
493
+ versionId: input.versionId,
494
+ dsl: input.dsl,
495
+ context: { scheduledAt: new Date().toISOString(), scheduleId: input.scheduleId },
496
+ },
497
+ ],
498
+ });
499
+ }
@@ -0,0 +1,74 @@
1
+ # nginx.conf 核心配置
2
+ user nginx;
3
+ worker_processes auto;
4
+
5
+ events {
6
+ worker_connections 1024;
7
+ }
8
+
9
+ http {
10
+ include /etc/nginx/mime.types;
11
+ default_type application/octet-stream;
12
+ server_tokens off;
13
+
14
+ # --------------------------
15
+ # 日志格式定义
16
+ # --------------------------
17
+ log_format custom '$remote_addr - $remote_user [$time_local] "$request" '
18
+ '$status $body_bytes_sent "$http_referer" ';
19
+
20
+ # --------------------------
21
+ # 安全头部配置
22
+ # --------------------------
23
+ add_header X-Content-Type-Options "nosniff";
24
+ add_header X-Frame-Options "SAMEORIGIN";
25
+ add_header X-XSS-Protection "1; mode=block";
26
+ #add_header Content-Security-Policy "default-src 'self'";
27
+
28
+ # --------------------------
29
+ # GZIP 压缩配置
30
+ # --------------------------
31
+ gzip on; # 启用 GZIP 压缩
32
+ gzip_comp_level 6; # 压缩级别(1-9,6 是性能与压缩率的平衡点)
33
+ gzip_min_length 1024; # 最小压缩文件大小(小于此值不压缩)
34
+ gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript; # 压缩的 MIME 类型
35
+ gzip_vary on; # 兼容不支持 GZIP 的客户端
36
+ gzip_proxied any; # 对所有代理请求启用压缩
37
+
38
+ # --------------------------
39
+ # SSL 配置(若启用 HTTPS)
40
+ # --------------------------
41
+ ssl_protocols TLSv1.2 TLSv1.3;
42
+ ssl_ciphers 'ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256';
43
+ ssl_prefer_server_ciphers on;
44
+
45
+ server {
46
+ listen 80;
47
+ server_name _; # 接受所有域名(如果前面有反向代理,这里可以是默认值)
48
+ root /var/www/html;
49
+
50
+ autoindex off; # 关闭目录索引
51
+ index index.html;
52
+
53
+ location ~ \.(js|mjs)$ {
54
+ add_header Content-Type application/javascript;
55
+ }
56
+
57
+ # --------------------------
58
+ # SPA 路由处理 - 必须放在最后
59
+ # --------------------------
60
+ location / {
61
+ limit_except GET HEAD POST { # 仅允许 GET/HEAD/POST
62
+ deny all;
63
+ }
64
+ # 对于 SPA:先尝试文件,如果不存在直接回退到 index.html(不尝试目录,避免 403)
65
+ try_files $uri /index.html;
66
+ }
67
+
68
+ # --------------------------
69
+ # 日志格式(隐藏敏感信息)
70
+ # --------------------------
71
+ access_log /var/log/nginx/access.log custom;
72
+ error_log /var/log/nginx/error.log warn;
73
+ }
74
+ }
@@ -653,6 +653,8 @@ export const ElementInspector: React.FC = () => {
653
653
  const [freeInputMode, setFreeInputMode] = useState<
654
654
  'freeInput' | 'requiresInput'
655
655
  >('freeInput');
656
+ const [quickSelectVisible, setQuickSelectVisible] = useState(false);
657
+ const [quickInputVisible, setQuickInputVisible] = useState(false);
656
658
  const pendingActionRef = useRef<InspectorAction | null>(null);
657
659
  const pendingMenuInfoRef = useRef<InspectedElementInfo | null>(null);
658
660
 
@@ -1126,6 +1128,22 @@ export const ElementInspector: React.FC = () => {
1126
1128
  onOk={handleFieldConfirmOk}
1127
1129
  onCancel={handleFieldConfirmCancel}
1128
1130
  />
1131
+
1132
+ {/* Quick select modal */}
1133
+ <QuickSelectModal
1134
+ open={quickSelectVisible}
1135
+ action={pendingActionRef.current}
1136
+ onOk={handleQuickSelectOk}
1137
+ onCancel={handleQuickSelectCancel}
1138
+ />
1139
+
1140
+ {/* Quick input modal */}
1141
+ <QuickInputModal
1142
+ open={quickInputVisible}
1143
+ action={pendingActionRef.current}
1144
+ onOk={handleQuickInputOk}
1145
+ onCancel={handleQuickInputCancel}
1146
+ />
1129
1147
  </>
1130
1148
  );
1131
1149
  };
@@ -29,7 +29,7 @@ export function generatePrompt(options: PromptOptions): string {
29
29
 
30
30
  if (skill) {
31
31
  parts.push(
32
- `严格按照: ${skill} skill 描述的方法来完成代码的修改。如有不确定的地方,请先询问我,不要自己假设。`,
32
+ `严格按照: ${skill} skill(位于 .claude/skills/${skill}/SKILL.md)描述的方法来完成代码的修改。如有不确定的地方,请先询问我,不要自己假设。`,
33
33
  );
34
34
  parts.push('');
35
35
  }