@fieldwangai/agentflow 0.1.136 → 0.1.137

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.
@@ -156,6 +156,7 @@ import {
156
156
  ensurePrdWorkflowCollaboration,
157
157
  getPrdWorkflowCollaborationById,
158
158
  getPrdWorkflowCollaborationByShareToken,
159
+ getPrdWorkflowCollaborationByTapdId,
159
160
  getPrdWorkflowCollaborationForUser,
160
161
  ensurePrdWorkflowShareLink,
161
162
  listPrdWorkflowCollaborationsForUser,
@@ -164,6 +165,7 @@ import {
164
165
  prdWorkflowCollaborationSummary,
165
166
  removePrdWorkflowCollaborationMember,
166
167
  revokePrdWorkflowShareLink,
168
+ syncPrdWorkflowAuthority,
167
169
  } from "./prd-workflow-collaboration.mjs";
168
170
  import {
169
171
  createTeam,
@@ -184,7 +186,10 @@ import {
184
186
  mergeWorkflowGlobalState,
185
187
  normalizeWorkflowReference,
186
188
  normalizeWorkflowReport,
189
+ removeWorkflowGlobalStatePath,
190
+ workflowReportResourceKeys,
187
191
  workflowRuntimeRevision,
192
+ workflowSnapshotResourceVersions,
188
193
  } from "./workflow-report.mjs";
189
194
 
190
195
  const MIME = {
@@ -1446,11 +1451,31 @@ function skillhubInstallArgs(payload, { uninstall = false } = {}) {
1446
1451
  return args;
1447
1452
  }
1448
1453
 
1449
- function readBody(req) {
1454
+ function readBody(req, maxBytes = 5 * 1024 * 1024) {
1450
1455
  return new Promise((resolve, reject) => {
1451
1456
  const chunks = [];
1452
- req.on("data", (c) => chunks.push(c));
1453
- req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
1457
+ let total = 0;
1458
+ let exceeded = false;
1459
+ req.on("data", (chunk) => {
1460
+ if (exceeded) return;
1461
+ const value = Buffer.from(chunk);
1462
+ total += value.length;
1463
+ if (total > maxBytes) {
1464
+ exceeded = true;
1465
+ chunks.length = 0;
1466
+ return;
1467
+ }
1468
+ chunks.push(value);
1469
+ });
1470
+ req.on("end", () => {
1471
+ if (exceeded) {
1472
+ const error = new Error(`Request body exceeds ${maxBytes} bytes`);
1473
+ error.status = 413;
1474
+ reject(error);
1475
+ return;
1476
+ }
1477
+ resolve(Buffer.concat(chunks).toString("utf8"));
1478
+ });
1454
1479
  req.on("error", reject);
1455
1480
  });
1456
1481
  }
@@ -3315,6 +3340,21 @@ function findWorkspaceShareUser(username) {
3315
3340
  return null;
3316
3341
  }
3317
3342
 
3343
+ function workflowAuthorityIdentity(value) {
3344
+ if (typeof value === "string" || typeof value === "number") return String(value || "").trim();
3345
+ if (!value || typeof value !== "object" || Array.isArray(value)) return "";
3346
+ return String(value.username || value.userId || value.user_id || value.nick || value.name || "").trim();
3347
+ }
3348
+
3349
+ function workflowAuthorityIdentities(value) {
3350
+ const values = Array.isArray(value) ? value : value == null ? [] : [value];
3351
+ return [...new Set(values.flatMap((item) => {
3352
+ if (typeof item === "string") return item.split(/[;,,;]/).map((entry) => entry.trim()).filter(Boolean);
3353
+ const identity = workflowAuthorityIdentity(item);
3354
+ return identity ? [identity] : [];
3355
+ }))];
3356
+ }
3357
+
3318
3358
  function workspaceCollaborationSummaryWithUsers(record, userId) {
3319
3359
  const summary = workspaceCollaborationSummary(record, userId);
3320
3360
  if (!summary) return null;
@@ -7780,6 +7820,7 @@ const workspaceCollaborationSequences = new Map();
7780
7820
  const prdWorkflowSubscribers = new Map();
7781
7821
  const prdWorkflowIdempotency = new Map();
7782
7822
  const prdWorkflowActionLocks = new Map();
7823
+ const prdWorkflowWriteQueues = new Map();
7783
7824
  const PRD_WORKFLOW_IDEMPOTENCY_MAX = 1000;
7784
7825
  const PRD_WORKFLOW_RUNTIME_EVENTS_MAX = 1000;
7785
7826
  const WORKSPACE_SCHEDULES_FILENAME = "workspace-schedules.json";
@@ -7788,6 +7829,22 @@ const WORKSPACE_IMPLEMENTATION_REFERENCE_ENABLED = true;
7788
7829
  const WORKSPACE_IMPLEMENTATION_SUMMARY_ENABLED = false;
7789
7830
  const WORKSPACE_NODE_HISTORY_MAX_CHARS = 80000;
7790
7831
 
7832
+ async function prdWorkflowAcquireWriteLock(key) {
7833
+ const lockKey = String(key || "").trim();
7834
+ const previous = prdWorkflowWriteQueues.get(lockKey) || Promise.resolve();
7835
+ let releaseCurrent;
7836
+ const current = new Promise((resolve) => { releaseCurrent = resolve; });
7837
+ prdWorkflowWriteQueues.set(lockKey, current);
7838
+ await previous.catch(() => {});
7839
+ let released = false;
7840
+ return () => {
7841
+ if (released) return;
7842
+ released = true;
7843
+ releaseCurrent();
7844
+ if (prdWorkflowWriteQueues.get(lockKey) === current) prdWorkflowWriteQueues.delete(lockKey);
7845
+ };
7846
+ }
7847
+
7791
7848
  function resolvePrdWorkflowScope(workspaceRoot, params = {}, userCtx = {}, capability = "read") {
7792
7849
  const tapdId = String(params.tapdId || params.tapd_id || "").trim();
7793
7850
  const flowId = String(params.flowId || "").trim();
@@ -7812,6 +7869,10 @@ function resolvePrdWorkflowScope(workspaceRoot, params = {}, userCtx = {}, capab
7812
7869
  const memberCollaboration = tapdId
7813
7870
  ? getPrdWorkflowCollaborationForUser(tapdId, userCtx?.userId)
7814
7871
  : null;
7872
+ const existingCollaboration = tapdId ? getPrdWorkflowCollaborationByTapdId(tapdId) : null;
7873
+ if (!adminOwner && !linkCollaboration && existingCollaboration && !memberCollaboration) {
7874
+ return { error: "PRD Workflow collaboration permission denied", status: 403 };
7875
+ }
7815
7876
  const collaboration = adminOwner ? null : (linkCollaboration || memberCollaboration);
7816
7877
  const access = adminOwner
7817
7878
  ? { allowed: true, writable: false, role: "admin-viewer", via: "admin-review" }
@@ -7825,7 +7886,8 @@ function resolvePrdWorkflowScope(workspaceRoot, params = {}, userCtx = {}, capab
7825
7886
  return { error: "PRD Workflow collaboration edit permission denied", status: 403 };
7826
7887
  }
7827
7888
  const ownerId = String(adminOwner?.userId || collaboration?.ownerId || userCtx?.userId || "").trim();
7828
- const stateRoot = path.resolve(getAgentflowUserDataRoot(ownerId));
7889
+ const stateOwnerId = String(adminOwner?.userId || collaboration?.stateOwnerId || collaboration?.ownerId || userCtx?.userId || "").trim();
7890
+ const stateRoot = path.resolve(getAgentflowUserDataRoot(stateOwnerId));
7829
7891
  let executionRoot = path.resolve(workspaceRoot);
7830
7892
  if (flowId) {
7831
7893
  const projectScope = resolveWorkspaceScopeRoot(workspaceRoot, {
@@ -7847,6 +7909,7 @@ function resolvePrdWorkflowScope(workspaceRoot, params = {}, userCtx = {}, capab
7847
7909
  executionRoot,
7848
7910
  stateRoot,
7849
7911
  ownerId,
7912
+ stateOwnerId,
7850
7913
  collaboration,
7851
7914
  collaborationAccess: access,
7852
7915
  shareToken,
@@ -7863,7 +7926,7 @@ function prdWorkflowKey(userCtx = {}, flowSource = "user", flowId = "", tapdId =
7863
7926
  const collaboration = getPrdWorkflowCollaborationByShareToken(shareToken)
7864
7927
  || getPrdWorkflowCollaborationForUser(id, userCtx?.userId);
7865
7928
  const adminOwnerId = userCtx?.isAdmin === true ? String(userCtx?.adminOwnerId || "").trim() : "";
7866
- const actorScope = `user:${String(collaboration?.ownerId || adminOwnerId || userCtx?.userId || "")}`;
7929
+ const actorScope = `user:${String(collaboration?.stateOwnerId || collaboration?.ownerId || adminOwnerId || userCtx?.userId || "")}`;
7867
7930
  return [actorScope, id].join("\t");
7868
7931
  }
7869
7932
 
@@ -8075,6 +8138,11 @@ function prdWorkflowEventsPath(scopedRoot, tapdId) {
8075
8138
  return path.join(rootDir, ".workspace", "prd-flow", "workflow-state", `${prdWorkflowSafeStateId(tapdId)}.events.json`);
8076
8139
  }
8077
8140
 
8141
+ function prdWorkflowEventsArchivePath(scopedRoot, tapdId) {
8142
+ const rootDir = scopedRoot || process.cwd();
8143
+ return path.join(rootDir, ".workspace", "prd-flow", "workflow-state", `${prdWorkflowSafeStateId(tapdId)}.events.archive.jsonl`);
8144
+ }
8145
+
8078
8146
  function prdWorkflowAuditPath(scopedRoot, tapdId) {
8079
8147
  const rootDir = scopedRoot || process.cwd();
8080
8148
  return path.join(rootDir, ".workspace", "prd-flow", "workflow-state", `${prdWorkflowSafeStateId(tapdId)}.audit.jsonl`);
@@ -9712,10 +9780,20 @@ export function prdWorkflowReviewHtml(title, markdown, meta = {}) {
9712
9780
  }
9713
9781
 
9714
9782
  function prdWorkflowCreateReview(scopedRoot, tapdId, payload = {}, urlBase = "", ownerId = "") {
9715
- const content = String(payload.markdown || payload.content || payload.rawOutput || "").slice(0, 500000);
9783
+ const content = String(payload.markdown || payload.content || payload.rawOutput || "");
9716
9784
  if (!content.trim()) throw new Error("Missing review markdown");
9785
+ if (Buffer.byteLength(content, "utf-8") > 500000) {
9786
+ const error = new Error("Review markdown exceeds 500000 bytes");
9787
+ error.status = 413;
9788
+ throw error;
9789
+ }
9717
9790
  const title = String(payload.title || payload.label || "PRD Workflow Review").trim().slice(0, 160) || "PRD Workflow Review";
9718
9791
  const durability = String(payload.durability || (payload.durable === true || payload.permanent === true ? "durable" : "temporary")).trim().toLowerCase() || "temporary";
9792
+ if (!["temporary", "durable"].includes(durability)) {
9793
+ const error = new Error("durability must be temporary or durable");
9794
+ error.status = 400;
9795
+ throw error;
9796
+ }
9719
9797
  const reviewId = prdWorkflowReviewIdFromRequest(tapdId, payload, durability);
9720
9798
  const paths = prdWorkflowReviewPaths(scopedRoot, tapdId, reviewId);
9721
9799
  const ttlDaysRaw = Number(payload.ttlDays || payload.ttl_days || (durability === "temporary" ? 7 : 0));
@@ -9935,11 +10013,13 @@ function prdWorkflowReadClientStateWithFallback(root, scopedRoot, tapdId) {
9935
10013
 
9936
10014
  function prdWorkflowWriteClientObservation(scopedRoot, tapdId, meta, snapshot) {
9937
10015
  const state = prdWorkflowReadClientState(scopedRoot, tapdId);
9938
- const clientId = prdWorkflowSafeStateId(meta.clientId || "anonymous");
10016
+ const reportSource = String(meta.reportSource || meta.source || "legacy").trim().toLowerCase() || "legacy";
10017
+ const clientId = prdWorkflowSafeStateId(`${reportSource}:${meta.clientId || "anonymous"}`);
9939
10018
  const nextClients = {
9940
10019
  ...state.clients,
9941
10020
  [clientId]: {
9942
10021
  clientId: String(meta.clientId || clientId),
10022
+ source: reportSource,
9943
10023
  userId: String(meta.userId || ""),
9944
10024
  observedAt: String(meta.observedAt || ""),
9945
10025
  reportedAt: String(meta.reportedAt || new Date().toISOString()),
@@ -10172,6 +10252,7 @@ function prdWorkflowSnapshotMetaFromReport(payload = {}, rawSnapshot = {}, req =
10172
10252
  reportedAt,
10173
10253
  observedAt: String(payload.observedAt || payload.observed_at || rawSnapshot.observedAt || rawSnapshot.observed_at || sources.observedAt || sources.checkedAt || headerObservedAt || reportedAt),
10174
10254
  clientId: String(payload.clientId || payload.client_id || sources.clientId || headerClientId || userCtx?.userId || "anonymous").slice(0, 160),
10255
+ reportSource: String(payload.reportSource || payload.report_source || payload.source || "legacy").trim().toLowerCase().slice(0, 120) || "legacy",
10175
10256
  userId: String(userCtx?.userId || payload.userId || payload.user_id || "").slice(0, 160),
10176
10257
  baseRevision: String(payload.baseRevision || payload.base_revision || payload.expectedRevision || payload.expected_revision || rawSnapshot.baseRevision || sources.baseRevision || "").trim(),
10177
10258
  scope: String(payload.scope || rawSnapshot.scope || rawSnapshot.next?.scope || sources.scope || "client").trim().toLowerCase() || "client",
@@ -10218,7 +10299,7 @@ function prdWorkflowStoreClientObservation({
10218
10299
  stageKey: reportMeta.stageKey,
10219
10300
  };
10220
10301
  const existingClientState = prdWorkflowReadClientState(scopedRoot, tapdId);
10221
- const existingClientId = prdWorkflowSafeStateId(reportMeta.clientId || "anonymous");
10302
+ const existingClientId = prdWorkflowSafeStateId(`${reportMeta.reportSource || "legacy"}:${reportMeta.clientId || "anonymous"}`);
10222
10303
  const previousClientSnapshot = existingClientState.clients?.[existingClientId]?.snapshot || null;
10223
10304
  const stampedSnapshot = prdWorkflowStampCurrentActionEntryTimes(
10224
10305
  scopedRoot,
@@ -10379,6 +10460,7 @@ function prdWorkflowMaterializeSnapshot(root, scopedRoot, tapdId, userCtx = {},
10379
10460
  );
10380
10461
  const clientObservations = prdWorkflowClientObservationRows(root, scopedRoot, tapdId).map((item) => ({
10381
10462
  clientId: item.clientId,
10463
+ source: item.source || "legacy",
10382
10464
  userId: item.userId || "",
10383
10465
  phase: item.phase || "",
10384
10466
  pointer: item.pointer || "",
@@ -10571,13 +10653,22 @@ function prdWorkflowNormalizeStoredRuntimeEvent(tapdId, event = {}) {
10571
10653
  function prdWorkflowReadRuntimeEvents(scopedRoot, tapdId) {
10572
10654
  try {
10573
10655
  const p = prdWorkflowEventsPath(scopedRoot, tapdId);
10574
- if (!fs.existsSync(p)) return { version: 1, tapdId: String(tapdId || ""), events: [] };
10575
- const data = JSON.parse(fs.readFileSync(p, "utf-8"));
10576
- const events = Array.isArray(data?.events)
10577
- ? data.events
10578
- .map((item) => prdWorkflowNormalizeStoredRuntimeEvent(data?.tapdId || tapdId, item))
10579
- .filter((item) => item && typeof item === "object")
10656
+ const archivePath = prdWorkflowEventsArchivePath(scopedRoot, tapdId);
10657
+ const data = fs.existsSync(p) ? JSON.parse(fs.readFileSync(p, "utf-8")) : {};
10658
+ const archivedEvents = fs.existsSync(archivePath)
10659
+ ? fs.readFileSync(archivePath, "utf-8").split("\n").filter(Boolean).flatMap((line) => {
10660
+ try { return [JSON.parse(line)]; } catch { return []; }
10661
+ })
10580
10662
  : [];
10663
+ const normalizedEvents = [...archivedEvents, ...(Array.isArray(data?.events) ? data.events : [])]
10664
+ .map((item) => prdWorkflowNormalizeStoredRuntimeEvent(data?.tapdId || tapdId, item))
10665
+ .filter((item) => item && typeof item === "object");
10666
+ const eventsByKey = new Map();
10667
+ for (const event of normalizedEvents) {
10668
+ const key = `${prdWorkflowRuntimeEventProducer(event)}:${prdWorkflowRuntimeEventOperation(event)}:${String(event.id || "")}`;
10669
+ eventsByKey.set(key, event);
10670
+ }
10671
+ const events = [...eventsByKey.values()];
10581
10672
  return {
10582
10673
  version: 1,
10583
10674
  tapdId: String(data?.tapdId || tapdId || ""),
@@ -10592,11 +10683,17 @@ function prdWorkflowReadRuntimeEvents(scopedRoot, tapdId) {
10592
10683
  function prdWorkflowWriteRuntimeEvents(scopedRoot, tapdId, events) {
10593
10684
  const p = prdWorkflowEventsPath(scopedRoot, tapdId);
10594
10685
  fs.mkdirSync(path.dirname(p), { recursive: true });
10686
+ const allEvents = Array.isArray(events) ? events : [];
10687
+ const overflow = allEvents.slice(0, Math.max(0, allEvents.length - PRD_WORKFLOW_RUNTIME_EVENTS_MAX));
10688
+ const archivePath = prdWorkflowEventsArchivePath(scopedRoot, tapdId);
10689
+ const archiveTmp = `${archivePath}.${process.pid}.${Date.now()}.tmp`;
10690
+ fs.writeFileSync(archiveTmp, overflow.length ? `${overflow.map((event) => JSON.stringify(event)).join("\n")}\n` : "", "utf-8");
10691
+ fs.renameSync(archiveTmp, archivePath);
10595
10692
  const data = {
10596
10693
  version: 1,
10597
10694
  tapdId: String(tapdId || ""),
10598
10695
  updatedAt: new Date().toISOString(),
10599
- events: Array.isArray(events) ? events.slice(-PRD_WORKFLOW_RUNTIME_EVENTS_MAX) : [],
10696
+ events: allEvents.slice(-PRD_WORKFLOW_RUNTIME_EVENTS_MAX),
10600
10697
  };
10601
10698
  const tmp = `${p}.${process.pid}.${Date.now()}.tmp`;
10602
10699
  fs.writeFileSync(tmp, JSON.stringify(data, null, 2) + "\n", "utf-8");
@@ -10666,6 +10763,12 @@ function prdWorkflowRuntimeEventProducer(event = {}) {
10666
10763
  .slice(0, 120) || "agentflow";
10667
10764
  }
10668
10765
 
10766
+ function prdWorkflowRuntimeEventOperation(event = {}) {
10767
+ return String(event.operation || (event.type === "review-link" ? "artifact.publish" : "report"))
10768
+ .trim()
10769
+ .toLowerCase() || "report";
10770
+ }
10771
+
10669
10772
  function prdWorkflowRuntimeOwnedArtifacts(values, stage = "") {
10670
10773
  if (!Array.isArray(values)) return values;
10671
10774
  const ownsOnlyChangedArtifact = /^(?:issue-plan|implementation|bugfix|integration):/.test(String(stage || ""));
@@ -10687,16 +10790,21 @@ function prdWorkflowRuntimeEventId(event = {}) {
10687
10790
  const scope = String(event.scope || "").trim();
10688
10791
  const platform = String(event.platform || "").trim();
10689
10792
  const aggregateByStage = event.aggregateByStage !== false && event.aggregate_by_stage !== false;
10793
+ const operation = prdWorkflowRuntimeEventOperation(event);
10794
+ const stableActionKey = String(event?.actionModel?.key || "").trim();
10795
+ if (stableActionKey && aggregateByStage && String(event?.type || "") === "workflow-report") {
10796
+ return `stage_${prdWorkflowSafeStateId([prdWorkflowRuntimeEventProducer(event), operation, stableActionKey].join(":"))}`;
10797
+ }
10690
10798
  if ((stage || action) && aggregateByStage) {
10691
10799
  const issue = String(event.issueKey || event.issue_key || event.issue || "").trim();
10692
- const key = [prdWorkflowRuntimeEventProducer(event), scope, issue, platform, stage || action].filter(Boolean).join(":");
10800
+ const key = [prdWorkflowRuntimeEventProducer(event), operation, scope, issue, platform, stage || action].filter(Boolean).join(":");
10693
10801
  return `stage_${prdWorkflowSafeStateId(key)}`;
10694
10802
  }
10695
10803
  const existing = String(event.id || event.eventId || event.event_id || "").trim();
10696
10804
  if (existing) return existing.slice(0, 160);
10697
10805
  if (stage || action) {
10698
10806
  const issue = String(event.issueKey || event.issue_key || event.issue || "").trim();
10699
- const key = [prdWorkflowRuntimeEventProducer(event), scope, issue, platform, stage || action].filter(Boolean).join(":");
10807
+ const key = [prdWorkflowRuntimeEventProducer(event), operation, scope, issue, platform, stage || action].filter(Boolean).join(":");
10700
10808
  return `stage_${prdWorkflowSafeStateId(key)}`;
10701
10809
  }
10702
10810
  return `evt_${Date.now().toString(36)}_${crypto.randomBytes(4).toString("hex")}`;
@@ -10866,10 +10974,12 @@ function prdWorkflowAppendRuntimeEvent(scopedRoot, tapdId, event = {}) {
10866
10974
  const entry = prdWorkflowNormalizeRuntimeEvent(tapdId, event);
10867
10975
  const entryIdem = String(entry.idempotencyKey || "").trim();
10868
10976
  const entryProducer = prdWorkflowRuntimeEventProducer(entry);
10977
+ const entryOperation = prdWorkflowRuntimeEventOperation(entry);
10869
10978
  const entryDedupeKey = prdWorkflowRuntimeEventDedupeKey(entry);
10870
10979
  const index = current.events.findIndex((item) => {
10871
10980
  if (prdWorkflowRuntimeEventProducer(item) !== entryProducer) return false;
10872
10981
  if (String(item?.id || "") === entry.id) return true;
10982
+ if (prdWorkflowRuntimeEventOperation(item) !== entryOperation) return false;
10873
10983
  if (prdWorkflowRuntimeEventDedupeKey(item) === entryDedupeKey) return true;
10874
10984
  if (!entryIdem) return false;
10875
10985
  if (String(item?.idempotencyKey || "").trim() === entryIdem) return true;
@@ -10905,17 +11015,43 @@ function prdWorkflowAppendRuntimeEvent(scopedRoot, tapdId, event = {}) {
10905
11015
  incomingGlobalStatePatch,
10906
11016
  )
10907
11017
  : previousGlobalStatePatch;
11018
+ const incomingPatchPaths = Array.isArray(entry?.globalStateOwnerPaths) ? entry.globalStateOwnerPaths : [];
11019
+ const previousRemovePaths = Array.isArray(events[index]?.globalStateRemove || events[index]?.global_state_remove)
11020
+ ? (events[index].globalStateRemove || events[index].global_state_remove)
11021
+ : [];
11022
+ const incomingRemovePaths = Array.isArray(entry?.globalStateRemove || entry?.global_state_remove)
11023
+ ? (entry.globalStateRemove || entry.global_state_remove)
11024
+ : [];
11025
+ const overlapsPath = (left, right) => left === right || left.startsWith(`${right}.`) || right.startsWith(`${left}.`);
11026
+ const mergedGlobalStateRemove = [...new Set([
11027
+ ...previousRemovePaths.filter((removedPath) => !incomingPatchPaths.some((patchPath) => overlapsPath(String(removedPath), String(patchPath)))),
11028
+ ...incomingRemovePaths,
11029
+ ])];
11030
+ let normalizedGlobalStatePatch = mergedGlobalStatePatch;
11031
+ for (const removedPath of incomingRemovePaths) {
11032
+ normalizedGlobalStatePatch = removeWorkflowGlobalStatePath(normalizedGlobalStatePatch, removedPath);
11033
+ }
10908
11034
  const prevArtifact = prdWorkflowRuntimeEventArtifactSignature(events[index]);
10909
11035
  const nextArtifact = prdWorkflowRuntimeEventArtifactSignature(entry);
10910
11036
  artifactConflict = Boolean(prevArtifact && nextArtifact && prevArtifact !== nextArtifact &&
10911
11037
  prdWorkflowRuntimeEventShouldConflictOnArtifact(events[index]) &&
10912
11038
  prdWorkflowRuntimeEventShouldConflictOnArtifact(entry));
10913
- const idempotencyHistory = [
10914
- ...(events[index].idempotencyKey ? [events[index].idempotencyKey] : []),
10915
- ...(entry.idempotencyKey ? [entry.idempotencyKey] : []),
11039
+ const idempotencyHistory = [...new Set([
10916
11040
  ...(Array.isArray(events[index].idempotencyHistory) ? events[index].idempotencyHistory : []),
11041
+ ...(events[index].idempotencyKey ? [events[index].idempotencyKey] : []),
10917
11042
  ...(Array.isArray(entry.idempotencyHistory) ? entry.idempotencyHistory : []),
10918
- ];
11043
+ ...(entry.idempotencyKey ? [entry.idempotencyKey] : []),
11044
+ ])].slice(-50);
11045
+ const allIdempotencyFingerprints = {
11046
+ ...(events[index].idempotencyFingerprints || {}),
11047
+ ...(events[index].idempotencyKey && events[index].idempotencyFingerprint
11048
+ ? { [events[index].idempotencyKey]: events[index].idempotencyFingerprint }
11049
+ : {}),
11050
+ ...(entry.idempotencyFingerprints || {}),
11051
+ ...(entry.idempotencyKey && entry.idempotencyFingerprint
11052
+ ? { [entry.idempotencyKey]: entry.idempotencyFingerprint }
11053
+ : {}),
11054
+ };
10919
11055
  events[index] = {
10920
11056
  ...events[index],
10921
11057
  ...entry,
@@ -10924,19 +11060,28 @@ function prdWorkflowAppendRuntimeEvent(scopedRoot, tapdId, event = {}) {
10924
11060
  outputs: prdWorkflowMergeRuntimeEventArrays(events[index].outputs, entry.outputs),
10925
11061
  results: prdWorkflowMergeRuntimeEventArrays(events[index].results, entry.results),
10926
11062
  actionModel: mergeWorkflowGlobalState(events[index].actionModel, entry.actionModel),
10927
- globalStateRemove: prdWorkflowMergeRuntimeEventArrays(
10928
- events[index].globalStateRemove || events[index].global_state_remove,
10929
- entry.globalStateRemove || entry.global_state_remove,
11063
+ globalStateRemove: mergedGlobalStateRemove,
11064
+ extensionsPatch: entry.extensionsPatch
11065
+ ? mergeWorkflowGlobalState(events[index].extensionsPatch, entry.extensionsPatch)
11066
+ : events[index].extensionsPatch,
11067
+ globalStateOwnerPaths: prdWorkflowMergeRuntimeEventArrays(
11068
+ events[index].globalStateOwnerPaths,
11069
+ entry.globalStateOwnerPaths,
10930
11070
  ),
10931
11071
  createdAt: events[index].createdAt || entry.createdAt,
10932
11072
  startedAt: events[index].startedAt || entry.startedAt,
10933
- idempotencyHistory: [...new Set(idempotencyHistory)].slice(-50),
11073
+ idempotencyHistory,
11074
+ idempotencyFingerprints: Object.fromEntries(
11075
+ idempotencyHistory
11076
+ .filter((key) => allIdempotencyFingerprints[key])
11077
+ .map((key) => [key, allIdempotencyFingerprints[key]]),
11078
+ ),
10934
11079
  };
10935
11080
  if (mergedImplementationMetadata && typeof mergedImplementationMetadata === "object" && !Array.isArray(mergedImplementationMetadata)) {
10936
11081
  events[index].implementationMetadata = mergedImplementationMetadata;
10937
11082
  }
10938
- if (mergedGlobalStatePatch && typeof mergedGlobalStatePatch === "object" && !Array.isArray(mergedGlobalStatePatch)) {
10939
- events[index].globalStatePatch = mergedGlobalStatePatch;
11083
+ if (normalizedGlobalStatePatch && typeof normalizedGlobalStatePatch === "object" && !Array.isArray(normalizedGlobalStatePatch)) {
11084
+ events[index].globalStatePatch = normalizedGlobalStatePatch;
10940
11085
  }
10941
11086
  if (artifactConflict) {
10942
11087
  events[index] = {
@@ -10981,43 +11126,128 @@ function prdWorkflowAppendRuntimeEvent(scopedRoot, tapdId, event = {}) {
10981
11126
  }
10982
11127
  }
10983
11128
 
10984
- function prdWorkflowFindIdempotencyEvent(scopedRoot, tapdId, idempotencyKey, source = "", completedOnly = true) {
11129
+ function prdWorkflowFindIdempotencyEvent(scopedRoot, tapdId, idempotencyKey, source = "", completedOnly = true, operation = "") {
10985
11130
  const key = String(idempotencyKey || "").trim();
10986
11131
  if (!key) return null;
10987
11132
  const producer = String(source || "").trim().toLowerCase();
11133
+ const operationKey = String(operation || "").trim().toLowerCase();
10988
11134
  const events = prdWorkflowReadRuntimeEvents(scopedRoot, tapdId).events;
10989
11135
  return [...events].reverse().find((event) => (
10990
11136
  (!producer || prdWorkflowRuntimeEventProducer(event) === producer) &&
11137
+ (!operationKey || String(event?.operation || (event?.type === "review-link" ? "artifact.publish" : "report")).toLowerCase() === operationKey) &&
10991
11138
  (String(event?.idempotencyKey || "") === key || (Array.isArray(event?.idempotencyHistory) && event.idempotencyHistory.includes(key))) &&
10992
11139
  (!completedOnly || ["done", "success", "completed"].includes(String(event?.status || "").toLowerCase()))
10993
11140
  )) || null;
10994
11141
  }
10995
11142
 
10996
11143
  function prdWorkflowFindCompletedIdempotencyEvent(scopedRoot, tapdId, idempotencyKey, source = "") {
10997
- return prdWorkflowFindIdempotencyEvent(scopedRoot, tapdId, idempotencyKey, source, true);
11144
+ return prdWorkflowFindIdempotencyEvent(scopedRoot, tapdId, idempotencyKey, source, false, "report");
11145
+ }
11146
+
11147
+ function prdWorkflowIdempotencyFingerprint(event = {}, idempotencyKey = "") {
11148
+ const key = String(idempotencyKey || "").trim();
11149
+ return String(
11150
+ event?.idempotencyFingerprints?.[key]
11151
+ || (String(event?.idempotencyKey || "").trim() === key ? event?.idempotencyFingerprint : "")
11152
+ || "",
11153
+ );
11154
+ }
11155
+
11156
+ function prdWorkflowResourceVersionConflicts(expectedVersions = {}, currentVersions = {}) {
11157
+ const conflicts = [];
11158
+ for (const [resourceKey, expectedVersion] of Object.entries(expectedVersions || {})) {
11159
+ const currentVersion = String(currentVersions?.[resourceKey] || "absent");
11160
+ const expected = String(expectedVersion || "absent");
11161
+ if (expected === currentVersion) continue;
11162
+ conflicts.push({ resourceKey, expectedVersion: expected, currentVersion });
11163
+ }
11164
+ return conflicts;
11165
+ }
11166
+
11167
+ function prdWorkflowGlobalPathOwners(snapshot = {}) {
11168
+ const owners = new Map();
11169
+ for (const event of Array.isArray(snapshot.runtimeEvents) ? snapshot.runtimeEvents : []) {
11170
+ const source = prdWorkflowRuntimeEventProducer(event);
11171
+ for (const path of Array.isArray(event?.globalStateOwnerPaths) ? event.globalStateOwnerPaths : []) {
11172
+ const normalized = String(path || "").trim();
11173
+ if (normalized) owners.set(normalized, source);
11174
+ }
11175
+ }
11176
+ return owners;
11177
+ }
11178
+
11179
+ function prdWorkflowGlobalOwnershipConflicts(report, currentSnapshot = {}) {
11180
+ const source = prdWorkflowRuntimeEventProducer(report?.event || {});
11181
+ const owners = prdWorkflowGlobalPathOwners(currentSnapshot);
11182
+ const conflicts = [];
11183
+ for (const path of Array.isArray(report?.event?.globalStateOwnerPaths) ? report.event.globalStateOwnerPaths : []) {
11184
+ const normalizedPath = String(path || "");
11185
+ const match = [...owners.entries()].find(([ownedPath, owner]) => (
11186
+ owner !== source && (
11187
+ ownedPath === normalizedPath ||
11188
+ ownedPath.startsWith(`${normalizedPath}.`) ||
11189
+ normalizedPath.startsWith(`${ownedPath}.`)
11190
+ )
11191
+ ));
11192
+ if (match) conflicts.push({ path: normalizedPath, owner: match[1], source });
11193
+ }
11194
+ return conflicts;
11195
+ }
11196
+
11197
+ function prdWorkflowMergeProducerTimeline(report, currentSnapshot = {}) {
11198
+ if (!report?.projections || !Array.isArray(report.projections.timeline)) return report;
11199
+ const source = prdWorkflowRuntimeEventProducer(report.event);
11200
+ const current = Array.isArray(currentSnapshot?.projections?.timeline) ? currentSnapshot.projections.timeline : [];
11201
+ const incoming = report.projections.timeline;
11202
+ const foreignCurrent = current.filter((item) => prdWorkflowRuntimeEventProducer(item) !== source);
11203
+ const ownIncoming = incoming.filter((item) => prdWorkflowRuntimeEventProducer(item) === source);
11204
+ const foreignIncoming = incoming.filter((item) => prdWorkflowRuntimeEventProducer(item) !== source);
11205
+ const foreignByKey = new Map(foreignCurrent.map((item) => [String(item?.key || `${item?.source}:${item?.kind}:${item?.id}`), item]));
11206
+ for (const item of foreignIncoming) {
11207
+ const key = String(item?.key || `${item?.source}:${item?.kind}:${item?.id}`);
11208
+ const existing = foreignByKey.get(key);
11209
+ const existingVersion = existing
11210
+ ? Object.values(workflowSnapshotResourceVersions({ projections: { timeline: [existing] } }))[0]
11211
+ : "";
11212
+ const incomingVersion = Object.values(workflowSnapshotResourceVersions({ projections: { timeline: [item] } }))[0] || "";
11213
+ if (!existing || existingVersion !== incomingVersion) {
11214
+ return { error: `projections.timeline may not modify entries owned by source ${item?.source || "unknown"}` };
11215
+ }
11216
+ }
11217
+ const timeline = [...foreignCurrent, ...ownIncoming];
11218
+ return {
11219
+ ...report,
11220
+ projections: { ...report.projections, timeline },
11221
+ event: { ...report.event, projections: { ...report.event.projections, timeline } },
11222
+ };
10998
11223
  }
10999
11224
 
11000
11225
  function prdWorkflowRuntimeEventDedupeKey(event = {}, index = 0) {
11001
11226
  const producer = prdWorkflowRuntimeEventProducer(event);
11227
+ const operation = prdWorkflowRuntimeEventOperation(event);
11002
11228
  const stage = prdWorkflowRuntimeEventCanonicalStage(event);
11003
11229
  const aggregateByStage = event.aggregateByStage !== false && event.aggregate_by_stage !== false;
11230
+ const stableActionKey = String(event?.actionModel?.key || "").trim();
11231
+ if (stableActionKey && aggregateByStage && String(event?.type || "") === "workflow-report") {
11232
+ return `producer:${producer}:operation:${operation}:action:${stableActionKey}`;
11233
+ }
11004
11234
  if (stage) {
11005
11235
  const issue = event?.issueKey || event?.issue_key || event?.issue;
11006
11236
  const platform = event?.platform;
11007
11237
  if (aggregateByStage || issue || platform) {
11008
- return ["producer", producer, "stage", event?.scope, issue, platform, stage]
11238
+ return ["producer", producer, "operation", operation, "stage", event?.scope, issue, platform, stage]
11009
11239
  .map((value) => String(value || "").trim())
11010
11240
  .join(":");
11011
11241
  }
11012
11242
  }
11013
11243
  const id = String(event?.id || event?.eventId || event?.event_id || "").trim();
11014
- if (id) return `producer:${producer}:id:${id}`;
11244
+ if (id) return `producer:${producer}:operation:${operation}:id:${id}`;
11015
11245
  if (stage) {
11016
- return ["producer", producer, "stage", event?.scope, event?.issueKey || event?.issue_key || event?.issue, event?.platform, stage]
11246
+ return ["producer", producer, "operation", operation, "stage", event?.scope, event?.issueKey || event?.issue_key || event?.issue, event?.platform, stage]
11017
11247
  .map((value) => String(value || "").trim())
11018
11248
  .join(":");
11019
11249
  }
11020
- return `producer:${producer}:idx:${index}`;
11250
+ return `producer:${producer}:operation:${operation}:idx:${index}`;
11021
11251
  }
11022
11252
 
11023
11253
  function prdWorkflowMergeRuntimeEventList(snapshotEvents = [], runtimeEvents = []) {
@@ -11294,7 +11524,7 @@ function prdWorkflowMergeRuntimeEvents(scopedRoot, tapdId, snapshot) {
11294
11524
  for (const key of ["issues", "issueGroups", "issue_groups", "epics", "epicGroups", "epic_groups", "aiDocs", "ai_docs"]) {
11295
11525
  if (Object.prototype.hasOwnProperty.call(prdFlowExtension, key)) prdFlowExtensionView[key] = prdFlowExtension[key];
11296
11526
  }
11297
- return {
11527
+ const materialized = {
11298
11528
  ...snapshot,
11299
11529
  ...prdFlowExtensionView,
11300
11530
  workflow: globalState.workflow,
@@ -11311,6 +11541,8 @@ function prdWorkflowMergeRuntimeEvents(scopedRoot, tapdId, snapshot) {
11311
11541
  runtimeEventsUpdatedAt: runtime.updatedAt || "",
11312
11542
  },
11313
11543
  };
11544
+ materialized.resourceVersions = workflowSnapshotResourceVersions(materialized);
11545
+ return materialized;
11314
11546
  }
11315
11547
 
11316
11548
  function prdWorkflowMockSnapshot(scopedRoot, tapdId = "mock-prd") {
@@ -12714,6 +12946,95 @@ export function startUiServer({
12714
12946
  json(res, 200, { token: getSessionTokenFromRequest(req) || "" });
12715
12947
  return;
12716
12948
  }
12949
+ if (req.method === "POST" && url.pathname === "/api/workflows/access/sync") {
12950
+ if (!authUser?.userId) {
12951
+ json(res, 401, { error: "Authentication required" });
12952
+ return;
12953
+ }
12954
+ let payload;
12955
+ try {
12956
+ payload = JSON.parse(await readBody(req, 256 * 1024));
12957
+ } catch (error) {
12958
+ json(res, error?.status === 413 ? 413 : 400, { error: error?.status === 413 ? error.message : "Invalid JSON body" });
12959
+ return;
12960
+ }
12961
+ try {
12962
+ const workflow = normalizeWorkflowReference(payload);
12963
+ if (workflow.error) {
12964
+ json(res, 400, { error: workflow.error });
12965
+ return;
12966
+ }
12967
+ if (workflow.namespace !== "tapd") {
12968
+ json(res, 400, { error: `Unsupported Workflow authority namespace: ${workflow.namespace}` });
12969
+ return;
12970
+ }
12971
+ const authorityPayload = payload?.authority && typeof payload.authority === "object" && !Array.isArray(payload.authority)
12972
+ ? payload.authority
12973
+ : {};
12974
+ const authorityType = String(authorityPayload.type || payload.authorityType || "tapd").trim().toLowerCase();
12975
+ const ownerIdentity = workflowAuthorityIdentity(authorityPayload.owner ?? payload.owner);
12976
+ const participantIdentities = workflowAuthorityIdentities(authorityPayload.participants ?? payload.participants);
12977
+ if (!ownerIdentity) {
12978
+ json(res, 400, { error: "authority.owner is required" });
12979
+ return;
12980
+ }
12981
+ const ownerUser = findWorkspaceShareUser(ownerIdentity);
12982
+ if (!ownerUser) {
12983
+ json(res, 422, {
12984
+ error: "TAPD owner has not registered or logged in to AgentFlow",
12985
+ owner: ownerIdentity,
12986
+ });
12987
+ return;
12988
+ }
12989
+ const resolvedParticipants = [];
12990
+ const unresolvedParticipants = [];
12991
+ for (const identity of participantIdentities) {
12992
+ const user = findWorkspaceShareUser(identity);
12993
+ if (user) resolvedParticipants.push(user);
12994
+ else unresolvedParticipants.push(identity);
12995
+ }
12996
+ const result = syncPrdWorkflowAuthority({
12997
+ tapdId: workflow.id,
12998
+ userId: userCtx.userId,
12999
+ isAdmin: userCtx.isAdmin === true,
13000
+ authority: authorityType,
13001
+ ownerUserId: ownerUser.userId,
13002
+ ownerIdentity,
13003
+ participantUserIds: resolvedParticipants.map((user) => user.userId),
13004
+ participantIdentities,
13005
+ unresolvedParticipants,
13006
+ observedAt: authorityPayload.observedAt || authorityPayload.observed_at || payload.observedAt || payload.observed_at,
13007
+ revision: authorityPayload.revision || payload.revision,
13008
+ });
13009
+ if (result.error) {
13010
+ json(res, result.status || 400, { error: result.error });
13011
+ return;
13012
+ }
13013
+ const collaboration = prdWorkflowCollaborationSummaryWithUsers(result.record, userCtx.userId);
13014
+ prdWorkflowBroadcast(prdWorkflowKey(userCtx, "", "", workflow.id), {
13015
+ type: "authority.synced",
13016
+ tapdId: workflow.id,
13017
+ ownerId: result.record.ownerId,
13018
+ });
13019
+ json(res, 200, {
13020
+ ok: true,
13021
+ workflow,
13022
+ created: result.created === true,
13023
+ ownerChanged: result.ownerChanged === true,
13024
+ collaboration,
13025
+ matchedParticipants: resolvedParticipants.map((user) => ({
13026
+ userId: user.userId,
13027
+ username: user.username,
13028
+ role: "viewer",
13029
+ source: "tapd",
13030
+ })),
13031
+ unresolvedParticipants,
13032
+ });
13033
+ } catch (error) {
13034
+ json(res, 500, { error: (error && error.message) || String(error) });
13035
+ }
13036
+ return;
13037
+ }
12717
13038
  if (req.method === "GET" && url.pathname === "/api/prd-workflows") {
12718
13039
  if (!authUser?.userId) {
12719
13040
  json(res, 401, { error: "Unauthorized" });
@@ -12737,7 +13058,7 @@ export function startUiServer({
12737
13058
  records = listPrdWorkflowCollaborationsForUser(userCtx.userId);
12738
13059
  }
12739
13060
  const workflows = records.map((record) => {
12740
- const stateRoot = path.resolve(getAgentflowUserDataRoot(record.ownerId));
13061
+ const stateRoot = path.resolve(getAgentflowUserDataRoot(record.stateOwnerId || record.ownerId));
12741
13062
  const tapdId = String(record.tapdId || "").trim();
12742
13063
  const project = prdWorkflowReadProjectState(stateRoot, tapdId);
12743
13064
  const latestClient = prdWorkflowLatestClientSnapshot(stateRoot, stateRoot, tapdId);
@@ -12930,7 +13251,12 @@ export function startUiServer({
12930
13251
  json(res, 200, {
12931
13252
  ok: true,
12932
13253
  collaboration: prdWorkflowCollaborationSummaryWithUsers(record, userCtx.userId),
12933
- member: { userId: targetUser.userId, username: targetUser.username, role: "editor" },
13254
+ member: {
13255
+ userId: targetUser.userId,
13256
+ username: targetUser.username,
13257
+ role: payload?.role === "viewer" ? "viewer" : "reporter",
13258
+ source: "explicit",
13259
+ },
12934
13260
  });
12935
13261
  } catch (error) {
12936
13262
  json(res, 400, { error: (error && error.message) || String(error) });
@@ -13159,7 +13485,7 @@ export function startUiServer({
13159
13485
  stageKey: reportMeta.stageKey,
13160
13486
  };
13161
13487
  const existingClientState = prdWorkflowReadClientState(scopedRoot, tapdId);
13162
- const existingClientId = prdWorkflowSafeStateId(reportMeta.clientId || "anonymous");
13488
+ const existingClientId = prdWorkflowSafeStateId(`${reportMeta.reportSource || "legacy"}:${reportMeta.clientId || "anonymous"}`);
13163
13489
  const previousClientSnapshot = existingClientState.clients?.[existingClientId]?.snapshot || null;
13164
13490
  const stampedSnapshot = prdWorkflowStampCurrentActionEntryTimes(
13165
13491
  scopedRoot,
@@ -13834,13 +14160,14 @@ export function startUiServer({
13834
14160
  }
13835
14161
  let payload;
13836
14162
  try {
13837
- payload = JSON.parse(await readBody(req));
13838
- } catch {
13839
- json(res, 400, { error: "Invalid JSON body" });
14163
+ payload = JSON.parse(await readBody(req, 1024 * 1024));
14164
+ } catch (error) {
14165
+ json(res, error?.status === 413 ? 413 : 400, { error: error?.status === 413 ? error.message : "Invalid JSON body" });
13840
14166
  return;
13841
14167
  }
14168
+ let releaseWorkflowWriteLock = null;
13842
14169
  try {
13843
- const report = normalizeWorkflowReport(payload);
14170
+ let report = normalizeWorkflowReport(payload);
13844
14171
  if (report.error) {
13845
14172
  json(res, 400, { error: report.error });
13846
14173
  return;
@@ -13873,6 +14200,7 @@ export function startUiServer({
13873
14200
  }
13874
14201
  const scopedRoot = workflowScope.stateRoot;
13875
14202
  prdWorkflowMigrateLegacyState(workflowScope.executionRoot, scopedRoot, tapdId);
14203
+ releaseWorkflowWriteLock = await prdWorkflowAcquireWriteLock(`${scopedRoot}\t${tapdId}`);
13876
14204
  const currentSnapshot = prdWorkflowMaterializeSnapshot(
13877
14205
  workflowScope.executionRoot,
13878
14206
  scopedRoot,
@@ -13880,10 +14208,7 @@ export function startUiServer({
13880
14208
  userCtx,
13881
14209
  { flowSource, flowId },
13882
14210
  );
13883
- const acceptedRevisions = new Set([
13884
- String(currentSnapshot.runtimeRevision || "").trim(),
13885
- String(currentSnapshot.revision || "").trim(),
13886
- ].filter(Boolean));
14211
+ const currentRuntimeRevision = String(currentSnapshot.runtimeRevision || "").trim();
13887
14212
  if (report.idempotencyKey) {
13888
14213
  const existing = prdWorkflowFindCompletedIdempotencyEvent(
13889
14214
  scopedRoot,
@@ -13892,6 +14217,19 @@ export function startUiServer({
13892
14217
  report.event.source,
13893
14218
  );
13894
14219
  if (existing) {
14220
+ const existingFingerprint = prdWorkflowIdempotencyFingerprint(existing, report.idempotencyKey);
14221
+ if (existingFingerprint && existingFingerprint !== report.event.idempotencyFingerprint) {
14222
+ json(res, 409, {
14223
+ error: "Idempotency key was already used for a different Workflow report",
14224
+ conflict: {
14225
+ type: "workflow-idempotency-conflict",
14226
+ idempotencyKey: report.idempotencyKey,
14227
+ workflow: report.workflow,
14228
+ },
14229
+ snapshot: currentSnapshot,
14230
+ });
14231
+ return;
14232
+ }
13895
14233
  json(res, 200, {
13896
14234
  ok: true,
13897
14235
  alreadyApplied: true,
@@ -13902,19 +14240,70 @@ export function startUiServer({
13902
14240
  return;
13903
14241
  }
13904
14242
  }
13905
- if (report.expectedRevision && acceptedRevisions.size && !acceptedRevisions.has(report.expectedRevision)) {
14243
+ const ownershipConflicts = prdWorkflowGlobalOwnershipConflicts(report, currentSnapshot);
14244
+ if (ownershipConflicts.length) {
14245
+ json(res, 409, {
14246
+ error: "Workflow globalState paths are owned by another report source",
14247
+ conflict: {
14248
+ type: "workflow-resource-ownership-conflict",
14249
+ conflicts: ownershipConflicts,
14250
+ workflow: report.workflow,
14251
+ },
14252
+ snapshot: currentSnapshot,
14253
+ });
14254
+ return;
14255
+ }
14256
+ const resourceKeys = workflowReportResourceKeys(report, currentSnapshot);
14257
+ const missingExpectedVersionKeys = Object.keys(report.expectedVersions).length
14258
+ ? resourceKeys.filter((key) => !Object.prototype.hasOwnProperty.call(report.expectedVersions, key))
14259
+ : [];
14260
+ if (missingExpectedVersionKeys.length) {
14261
+ json(res, 400, {
14262
+ error: "expectedVersions must include every resource key touched by this report",
14263
+ missingExpectedVersionKeys,
14264
+ resourceKeys,
14265
+ });
14266
+ return;
14267
+ }
14268
+ const expectedTouchedVersions = Object.fromEntries(
14269
+ resourceKeys
14270
+ .filter((key) => Object.prototype.hasOwnProperty.call(report.expectedVersions, key))
14271
+ .map((key) => [key, report.expectedVersions[key]]),
14272
+ );
14273
+ const resourceConflicts = prdWorkflowResourceVersionConflicts(
14274
+ expectedTouchedVersions,
14275
+ currentSnapshot.resourceVersions || {},
14276
+ );
14277
+ if (resourceConflicts.length) {
14278
+ json(res, 409, {
14279
+ error: "Workflow resources changed; refresh the conflicting keys before reporting",
14280
+ conflict: {
14281
+ type: "workflow-resource-conflict",
14282
+ conflicts: resourceConflicts,
14283
+ workflow: report.workflow,
14284
+ },
14285
+ snapshot: currentSnapshot,
14286
+ });
14287
+ return;
14288
+ }
14289
+ if (!Object.keys(report.expectedVersions).length && report.expectedRevision && currentRuntimeRevision && report.expectedRevision !== currentRuntimeRevision) {
13906
14290
  json(res, 409, {
13907
14291
  error: "Workflow state changed; refresh before reporting",
13908
14292
  conflict: {
13909
14293
  type: "workflow-revision-conflict",
13910
14294
  expectedRevision: report.expectedRevision,
13911
- currentRevision: currentSnapshot.runtimeRevision || currentSnapshot.revision || "",
14295
+ currentRevision: currentRuntimeRevision,
13912
14296
  workflow: report.workflow,
13913
14297
  },
13914
14298
  snapshot: currentSnapshot,
13915
14299
  });
13916
14300
  return;
13917
14301
  }
14302
+ report = prdWorkflowMergeProducerTimeline(report, currentSnapshot);
14303
+ if (report.error) {
14304
+ json(res, 400, { error: report.error });
14305
+ return;
14306
+ }
13918
14307
  let observation = null;
13919
14308
  if (report.observation) {
13920
14309
  const observationPayload = {
@@ -13923,6 +14312,7 @@ export function startUiServer({
13923
14312
  clientId: report.observation.clientId || payload.clientId || payload.source || "workflow-reporter",
13924
14313
  observedAt: report.observation.observedAt || payload.observedAt || "",
13925
14314
  scope: report.observation.scope || payload.scope || "client",
14315
+ reportSource: report.event.source,
13926
14316
  };
13927
14317
  observation = prdWorkflowStoreClientObservation({
13928
14318
  scopedRoot,
@@ -13962,6 +14352,7 @@ export function startUiServer({
13962
14352
  json(res, 200, {
13963
14353
  ok: true,
13964
14354
  report,
14355
+ resourceKeys,
13965
14356
  event,
13966
14357
  observation: observation ? {
13967
14358
  accepted: true,
@@ -13973,6 +14364,8 @@ export function startUiServer({
13973
14364
  });
13974
14365
  } catch (e) {
13975
14366
  json(res, 500, { error: (e && e.message) || String(e) });
14367
+ } finally {
14368
+ releaseWorkflowWriteLock?.();
13976
14369
  }
13977
14370
  return;
13978
14371
  }
@@ -14056,11 +14449,12 @@ export function startUiServer({
14056
14449
  }
14057
14450
  let payload;
14058
14451
  try {
14059
- payload = JSON.parse(await readBody(req));
14060
- } catch {
14061
- json(res, 400, { error: "Invalid JSON body" });
14452
+ payload = JSON.parse(await readBody(req, 600000));
14453
+ } catch (error) {
14454
+ json(res, error?.status === 413 ? 413 : 400, { error: error?.status === 413 ? error.message : "Invalid JSON body" });
14062
14455
  return;
14063
14456
  }
14457
+ let releaseWorkflowWriteLock = null;
14064
14458
  try {
14065
14459
  const workflow = normalizeWorkflowReference(payload);
14066
14460
  if (workflow.error) {
@@ -14088,14 +14482,85 @@ export function startUiServer({
14088
14482
  }
14089
14483
  const scopedRoot = workflowScope.stateRoot;
14090
14484
  prdWorkflowMigrateLegacyState(workflowScope.executionRoot, scopedRoot, tapdId);
14091
- const producer = String(payload.source || "agentflow-cli").trim().toLowerCase() || "agentflow-cli";
14485
+ const producer = String(payload.source || (legacyReviewEndpoint ? "prd-flow" : "")).trim().toLowerCase();
14486
+ if (!producer) {
14487
+ json(res, 400, { error: "Workflow artifact publish requires source" });
14488
+ return;
14489
+ }
14092
14490
  if (!/^[a-z][a-z0-9._-]{0,119}$/.test(producer)) {
14093
14491
  json(res, 400, { error: "Invalid workflow report source" });
14094
14492
  return;
14095
14493
  }
14494
+ const fieldLimits = [
14495
+ [payload.title || payload.label, 160, "title"],
14496
+ [payload.stage || payload.stageKey || payload.stage_key, 240, "stage"],
14497
+ [payload.issueKey || payload.issue_key || payload.issue, 240, "issueKey"],
14498
+ [payload.platform, 80, "platform"],
14499
+ [payload.artifactLabel, 500, "artifactLabel"],
14500
+ [payload.reviewId || payload.review_id, 500, "reviewId"],
14501
+ ];
14502
+ const oversizedField = fieldLimits.find(([value, max]) => String(value || "").trim().length > max);
14503
+ if (oversizedField) {
14504
+ json(res, 400, { error: `${oversizedField[2]} exceeds ${oversizedField[1]} characters` });
14505
+ return;
14506
+ }
14507
+ const markdown = String(payload.markdown || payload.content || payload.rawOutput || "");
14508
+ if (!markdown.trim()) {
14509
+ json(res, 400, { error: "Missing review markdown" });
14510
+ return;
14511
+ }
14512
+ if (Buffer.byteLength(markdown, "utf-8") > 500000) {
14513
+ json(res, 413, { error: "Review markdown exceeds 500000 bytes" });
14514
+ return;
14515
+ }
14516
+ const requestedDurability = String(
14517
+ payload.durability || (payload.durable === true || payload.permanent === true ? "durable" : "temporary"),
14518
+ ).trim().toLowerCase() || "temporary";
14519
+ if (!["temporary", "durable"].includes(requestedDurability)) {
14520
+ json(res, 400, { error: "durability must be temporary or durable" });
14521
+ return;
14522
+ }
14523
+ const ttlInput = payload.ttlDays ?? payload.ttl_days;
14524
+ if (requestedDurability === "temporary" && ttlInput != null) {
14525
+ const ttlDays = Number(ttlInput);
14526
+ if (!Number.isInteger(ttlDays) || ttlDays < 1 || ttlDays > 30) {
14527
+ json(res, 400, { error: "ttlDays must be an integer between 1 and 30" });
14528
+ return;
14529
+ }
14530
+ }
14531
+ const explicitExpiresAt = String(payload.expiresAt || payload.expires_at || "").trim();
14532
+ if (explicitExpiresAt && (!Number.isFinite(Date.parse(explicitExpiresAt)) || Date.parse(explicitExpiresAt) <= Date.now())) {
14533
+ json(res, 400, { error: "expiresAt must be a valid future date" });
14534
+ return;
14535
+ }
14096
14536
  const idempotencyKey = String(
14097
14537
  payload.idempotencyKey || payload.idempotency_key || "",
14098
14538
  ).trim();
14539
+ if (idempotencyKey.length > 500) {
14540
+ json(res, 400, { error: "idempotencyKey exceeds 500 characters" });
14541
+ return;
14542
+ }
14543
+ if (String(payload.artifactKey || payload.artifact_key || "").trim().length > 500) {
14544
+ json(res, 400, { error: "artifactKey exceeds 500 characters" });
14545
+ return;
14546
+ }
14547
+ const artifactKey = prdWorkflowReviewArtifactKey(tapdId, payload);
14548
+ const idempotencyFingerprint = prdWorkflowRevisionHash({
14549
+ operation: "artifact.publish",
14550
+ workflow,
14551
+ producer,
14552
+ title: String(payload.title || payload.label || "").trim(),
14553
+ markdown,
14554
+ stage: String(payload.stage || payload.stageKey || payload.stage_key || "").trim(),
14555
+ issueKey: String(payload.issueKey || payload.issue_key || payload.issue || "").trim(),
14556
+ platform: String(payload.platform || "").trim(),
14557
+ artifactKey,
14558
+ artifactLabel: String(payload.artifactLabel || "").trim(),
14559
+ durability: requestedDurability,
14560
+ ttlDays: ttlInput ?? null,
14561
+ expiresAt: explicitExpiresAt,
14562
+ });
14563
+ releaseWorkflowWriteLock = await prdWorkflowAcquireWriteLock(`${scopedRoot}\t${tapdId}`);
14099
14564
  const currentSnapshot = prdWorkflowMaterializeSnapshot(
14100
14565
  workflowScope.executionRoot,
14101
14566
  scopedRoot,
@@ -14104,10 +14569,11 @@ export function startUiServer({
14104
14569
  { flowSource, flowId },
14105
14570
  );
14106
14571
  const expectedRevision = String(payload.expectedRevision || payload.expected_revision || "").trim();
14107
- const acceptedRevisions = new Set([
14108
- String(currentSnapshot.runtimeRevision || "").trim(),
14109
- String(currentSnapshot.revision || "").trim(),
14110
- ].filter(Boolean));
14572
+ if (expectedRevision.length > 500) {
14573
+ json(res, 400, { error: "expectedRevision exceeds 500 characters" });
14574
+ return;
14575
+ }
14576
+ const currentRuntimeRevision = String(currentSnapshot.runtimeRevision || "").trim();
14111
14577
  if (idempotencyKey) {
14112
14578
  const existing = prdWorkflowFindIdempotencyEvent(
14113
14579
  scopedRoot,
@@ -14115,8 +14581,18 @@ export function startUiServer({
14115
14581
  idempotencyKey,
14116
14582
  producer,
14117
14583
  false,
14584
+ "artifact.publish",
14118
14585
  );
14119
14586
  if (existing) {
14587
+ const existingFingerprint = prdWorkflowIdempotencyFingerprint(existing, idempotencyKey);
14588
+ if (existingFingerprint && existingFingerprint !== idempotencyFingerprint) {
14589
+ json(res, 409, {
14590
+ error: "Idempotency key was already used for different Artifact content",
14591
+ conflict: { type: "workflow-idempotency-conflict", idempotencyKey, workflow },
14592
+ snapshot: currentSnapshot,
14593
+ });
14594
+ return;
14595
+ }
14120
14596
  const artifact = Array.isArray(existing.artifacts) ? existing.artifacts[0] : null;
14121
14597
  json(res, 200, {
14122
14598
  ok: true,
@@ -14137,13 +14613,57 @@ export function startUiServer({
14137
14613
  return;
14138
14614
  }
14139
14615
  }
14140
- if (expectedRevision && acceptedRevisions.size && !acceptedRevisions.has(expectedRevision)) {
14616
+ const resourceKey = `artifact:${producer}:${artifactKey}`;
14617
+ const hasExpectedVersionsField = Object.prototype.hasOwnProperty.call(payload, "expectedVersions")
14618
+ || Object.prototype.hasOwnProperty.call(payload, "expected_versions");
14619
+ const rawExpectedVersionsInput = Object.prototype.hasOwnProperty.call(payload, "expectedVersions")
14620
+ ? payload.expectedVersions
14621
+ : payload.expected_versions;
14622
+ if (hasExpectedVersionsField && (!rawExpectedVersionsInput || typeof rawExpectedVersionsInput !== "object" || Array.isArray(rawExpectedVersionsInput))) {
14623
+ json(res, 400, { error: "expectedVersions must be an object" });
14624
+ return;
14625
+ }
14626
+ const rawExpectedVersions = hasExpectedVersionsField ? rawExpectedVersionsInput : {};
14627
+ const invalidExpectedVersionEntry = Object.entries(rawExpectedVersions).find(([key, value]) => (
14628
+ !String(key || "").trim() || String(key).length > 800 || /[\0\r\n]/.test(String(key)) ||
14629
+ String(value == null || value === "" ? "absent" : value).trim().length > 160
14630
+ ));
14631
+ if (invalidExpectedVersionEntry) {
14632
+ json(res, 400, { error: "expectedVersions contains an invalid resource key or version" });
14633
+ return;
14634
+ }
14635
+ if (Object.keys(rawExpectedVersions).length && !Object.prototype.hasOwnProperty.call(rawExpectedVersions, resourceKey)) {
14636
+ json(res, 400, {
14637
+ error: "expectedVersions must include the Artifact resource key touched by this publish",
14638
+ missingExpectedVersionKeys: [resourceKey],
14639
+ resourceKeys: [resourceKey],
14640
+ });
14641
+ return;
14642
+ }
14643
+ const expectedArtifactVersion = Object.prototype.hasOwnProperty.call(rawExpectedVersions, resourceKey)
14644
+ ? String(rawExpectedVersions[resourceKey] || "absent")
14645
+ : null;
14646
+ const resourceConflicts = expectedArtifactVersion == null
14647
+ ? []
14648
+ : prdWorkflowResourceVersionConflicts(
14649
+ { [resourceKey]: expectedArtifactVersion },
14650
+ currentSnapshot.resourceVersions || {},
14651
+ );
14652
+ if (resourceConflicts.length) {
14653
+ json(res, 409, {
14654
+ error: "Workflow artifact changed; refresh before publishing",
14655
+ conflict: { type: "workflow-resource-conflict", conflicts: resourceConflicts, workflow },
14656
+ snapshot: currentSnapshot,
14657
+ });
14658
+ return;
14659
+ }
14660
+ if (!Object.keys(rawExpectedVersions).length && expectedRevision && currentRuntimeRevision && expectedRevision !== currentRuntimeRevision) {
14141
14661
  json(res, 409, {
14142
14662
  error: "Workflow state changed; refresh before publishing",
14143
14663
  conflict: {
14144
14664
  type: "workflow-revision-conflict",
14145
14665
  expectedRevision,
14146
- currentRevision: currentSnapshot.runtimeRevision || currentSnapshot.revision || "",
14666
+ currentRevision: currentRuntimeRevision,
14147
14667
  workflow,
14148
14668
  },
14149
14669
  snapshot: currentSnapshot,
@@ -14171,7 +14691,6 @@ export function startUiServer({
14171
14691
  const shortUrl = shortLink?.shortUrl || "";
14172
14692
  const displayUrl = shortUrl || reviewUrl;
14173
14693
  const durability = review.durability || "temporary";
14174
- const artifactKey = prdWorkflowReviewArtifactKey(tapdId, payload);
14175
14694
  const reviewStageKey = prdWorkflowRuntimeEventCanonicalStage(payload)
14176
14695
  || payload.stageKey
14177
14696
  || payload.stage_key
@@ -14206,6 +14725,7 @@ export function startUiServer({
14206
14725
  const event = prdWorkflowAppendRuntimeEvent(scopedRoot, tapdId, {
14207
14726
  id: `review-link:${artifactKey}`,
14208
14727
  type: "review-link",
14728
+ operation: "artifact.publish",
14209
14729
  source: producer,
14210
14730
  auxiliary: true,
14211
14731
  aggregateByStage: false,
@@ -14224,6 +14744,8 @@ export function startUiServer({
14224
14744
  ...(reviewMrIid ? { mrIid: reviewMrIid } : {}),
14225
14745
  ...(reviewCommitSha ? { commitSha: reviewCommitSha } : {}),
14226
14746
  idempotencyKey,
14747
+ idempotencyFingerprint,
14748
+ idempotencyFingerprints: idempotencyKey ? { [idempotencyKey]: idempotencyFingerprint } : {},
14227
14749
  durability,
14228
14750
  sourceArtifact: reviewSource,
14229
14751
  expiresAt: review.expiresAt || "",
@@ -14263,6 +14785,7 @@ export function startUiServer({
14263
14785
  ok: true,
14264
14786
  workflow,
14265
14787
  artifact,
14788
+ resourceKeys: [resourceKey],
14266
14789
  review: {
14267
14790
  ...review,
14268
14791
  url: reviewUrl,
@@ -14279,7 +14802,10 @@ export function startUiServer({
14279
14802
  } : {}),
14280
14803
  });
14281
14804
  } catch (e) {
14282
- json(res, 500, { error: (e && e.message) || String(e) });
14805
+ const status = Number(e?.status);
14806
+ json(res, status >= 400 && status < 500 ? status : 500, { error: (e && e.message) || String(e) });
14807
+ } finally {
14808
+ releaseWorkflowWriteLock?.();
14283
14809
  }
14284
14810
  return;
14285
14811
  }