OpenWorkGraph 로드맵 2: 프로세스 및 태스크 마이닝에서 경쟁사 수준 격차 해소
README 링크에서 발견 · yesterday
Celonis, UiPath와의 동등성 확보를 위한 심층 기능 추적 및 출시
What they did
OpenWorkGraph(OWG)의 메인테이너들은 첫 번째 로드맵(#264)의 모든 항목이 `main`에서 최초 구현을 갖춘 후 두 번째 로드맵 이슈를 열었다. 이들은 OWG를 Mimica, UiPath(Task Mining, Process Mining, Task Capture, Automation Hub, Communications Mining), Celonis, Soroco, Microsoft Power Automate Process Mining, SAP Signavio, Skan.ai와 비교한 솔직한 기준표에서 출발했고, 대부분의 항목이 깊이가 아니라 넓이였으며 #264의 동등성 게이트(현실적인 동의 기반 데이터셋에 대해 경쟁사 스타일 UX를 테스트하는 것)가 충족되지 않았다고 결론지었다. 그런 다음 남은 작업을 알파벳 섹션으로 나누었다. A) 태스크 마이닝 및 재구성 품질 — 정확한 레코드 참조를 넘어선 재구성(#349), 일자 간 재개(#347), k-익명 팀 수준 발견(#348) 포함; B) 이벤트 로그 상호운용성 및 규모 — OCEL 2.0 JSON/XML, XES, 1,000만 이벤트 수용 테스트를 갖춘 DuckDB 컬럼형 엔진, 객체 중심 맵, 엔터프라이즈 커넥터 포함; C) 분석 깊이 — 토큰 재생 및 정렬 적합성 검사, 근본 원인 연관성, KPI 대시보드, 가정 시뮬레이션, 코호트 벤치마킹 포함; D) 문서화, 커뮤니케이션 마이닝, 배포 모니터링; E) 실제 동의 기반 파일럿과 경쟁사 스타일 UX 리뷰를 통한 입증; F) 두 번째 동등성 리뷰에서 발견된 격차(조직 마이닝, KPI 신호, PQL 유사 분석 빌더, 자동화 기회 파이프라인, 증분 추가); G) ABBYY Timeline, Signavio, Disco, Apromore, IBM, PM4Py에서 발견된 격차(예측 모니터링, Declare 스타일 준수 규칙, 맵 추상화 슬라이더, 자동 모델 발견); H) 성능 및 변경 분석(처리 시간 대 대기 시간, 점선 차트, 맵 비교, 개념 드리프트). 각 항목은 이슈 참조가 붙은 체크박스이며, 대부분은 이미 체크되어 있다. 이들은 OWG의 기반을 명시적으로 유지했다. 로컬 우선 캡처, 동의, 증거/정책/권한 분리, MCP를 통한 에이전트 핸드오프, Discovery Mode, 그리고 숨겨진 업로드나 생산성 점수화 없음. 또한 수용 게이트(하위 호환 REST/MCP, 테넌트 간 격리, 철회/보존, 안전한 HTML, 규모에서의 성능 수용, 그리고 파생된 경로나 통계적 연관성을 검증된 워크플로, 원인 또는 권한으로 제시하지 않을 것)와 병렬 에이전트를 위한 조정 규칙(시작 전 항목 선점, 영역별 브랜치 사용 예: `feat/ocel-import`, `tests.yml`, `dashboard/index.html`, 프로세스 모델 같은 공유 파일은 최소한의 추가 편집만 유지)을 추가했다.
How it went
`main`에서 로드맵의 대부분이 완료되었습니다: OCEL 2.0 및 XES 가져오기/내보내기, 1,000만 이벤트 수용 테스트를 갖춘 DuckDB 엔진, 객체 중심 맵, 정렬 및 토큰 리플레이 적합성 검사, 근본 원인 연관 분석, KPI 대시보드, what-if 시뮬레이션, 조직 마이닝, KPI 신호, 타입 지정 분석 빌더, 기회 파이프라인, 증분 추가, 예측 모니터링, Declare 스타일 규칙, Disco 스타일 맵 슬라이더, BPMN 내보내기를 지원하는 inductive-miner 발견, 처리 시간 대 대기 시간, 점선 차트, 맵 비교 및 개념 드리프트가 포함됩니다. 저자들은 한계를 솔직하게 밝히고 있습니다: 결정론적 재구성 신호는 식별 가능한 홀드아웃 F1 0.52(#349 적용 시 0.57)에 도달하며, 모든 모호한 후보를 수용하면 0.65에 도달하지만 오염률이 0.125이므로, 0.90 목표에는 사용자가 제공한 API 키를 사용하는 LLM 기반 링커가 필요할 가능성이 높습니다. 엔터프라이즈 커넥터와 실제 동의 기반 파일럿은 여전히 미해결 과제로 남아 있습니다.
Worth knowing
이것은 완성된 제품이 아니라 로드맵 이슈입니다. 여러 항목(엔터프라이즈 소스 커넥터, 0.90 재구성 F1 게이트, 실제 파일럿, 경쟁사 스타일 UX 리뷰)은 아직 체크되지 않은 상태입니다. 실행 로봇은 설계상 범위 밖이며, 시그널과 예측은 절대 행동하거나 사람을 평가하지 않습니다. 동등성 주장은 독립적으로 검증된 것이 아니라 기준 표에 대해 자체 평가된 것이며, A/B/C 하네스는 실제 사용자로 실행된 적이 없습니다. 병렬 에이전트는 항목을 클레임하고 충돌을 피하기 위해 공유 파일에 대해서는 추가 편집만 유지해야 합니다.
Wiring
로드맵은 로컬 우선 OWG 스택을 전제로 합니다. 캡처는 로컬 SQLite 저장소로 피드를 공급하며, 수백만 건 규모의 이벤트 분석을 위한 DuckDB 이벤트 로그 워크스페이스를 갖춥니다. 이벤트 로그는 OCEL 2.0(JSON/XML)과 XES를 통해 입출력되므로 PM4Py, ProM, Apromore, Celonis, Signavio와 상호 운용할 수 있습니다. 분석(적합성, 근본 원인, KPI, 시뮬레이션, 벤치마킹, 조직 마이닝, 시그널, 분석 빌더, 기회 파이프라인, 예측 모니터링, Declare 규칙, 맵 슬라이더, 모델 발견, 처리/대기 시간, 점선 차트, 맵 비교, 개념 드리프트)은 저장된 로그 위에서 실행되며, 하위 호환 REST/MCP 인터페이스와 대시보드를 통해 결과를 노출합니다. 에이전트 핸드오프는 MCP를 통해 이루어지며, 비실행형 스타터(Codex/Claude/n8n/AI SDK)는 실행 보고서와 SDK 저널을 통해 모니터링됩니다. 병렬 에이전트는 이슈에서 항목을 클레임하고, 영역별로 브랜치를 나누며, 공유 파일은 추가형 편집으로 유지하여 조정합니다.
Try it yourself
I can't access external URLs, so I can't read the issue or its baseline table. I also can't claim issues, create branches, or run code in a repository from here. What I can do is give you a concrete plan and the implementation scaffolding you'd apply yourself.
Below is a worked example for one plausible unchecked item — **enterprise source connectors (Salesforce)** — written against an event-log workspace. Adapt names to match the actual issue.
## 1. Claim comment (post on the issue)
```
Claiming: Section B — Enterprise source connectors (Salesforce).
Plan:
- New package `packages/connectors-salesforce` (additive; no changes to core).
- Emits `source.record.observed` events into the existing event log; no derived
workflow/cause/authorization claims are written.
- Connector is tenant-scoped; credentials resolved per-tenant at runtime.
- Tests: cross-tenant isolation, revocation/retention, safe HTML rendering,
concurrent edits.
- Perf acceptance at stated scale (fill in N from the issue baseline table).
Shared-file edits will be minimal and additive only:
- `tests.yml`: one new job entry.
- `dashboard/index.html`: one new read-only panel.
- process model: one new event type registration.
```
## 2. Branch
```
git checkout -b feat/salesforce-connector
```
## 3. Package skeleton
`packages/connectors-salesforce/package.json`
```json
{
"name": "@openworkgraph/connectors-salesforce",
"version": "0.0.0",
"type": "module",
"main": "src/index.ts",
"scripts": { "test": "vitest run" },
"dependencies": {
"@openworkgraph/event-log": "workspace:*"
}
}
```
`packages/connectors-salesforce/src/index.ts`
```ts
import type { EventLog, Event } from "@openworkgraph/event-log";
export interface SalesforceConfig {
tenantId: string;
instanceUrl: string;
// token is resolved per-tenant at call time, never stored in config
getAccessToken: () => Promise<string>;
}
export interface ObservedRecord {
objectType: string;
recordId: string;
fields: Record<string, unknown>;
observedAt: string;
}
export class SalesforceConnector {
constructor(
private readonly log: EventLog,
private readonly cfg: SalesforceConfig,
) {}
/** Emits observation events only. No derived workflow/cause/authorization. */
async ingest(records: ObservedRecord[]): Promise<void> {
const token = await this.cfg.getAccessToken();
if (!token) throw new Error("missing token for tenant");
for (const r of records) {
const ev: Event = {
type: "source.record.observed",
tenantId: this.cfg.tenantId,
occurredAt: r.observedAt,
payload: {
source: "salesforce",
objectType: r.objectType,
recordId: r.recordId,
fields: r.fields,
},
};
await this.log.append(ev);
}
}
}
```
## 4. Tests
`packages/connectors-salesforce/test/connector.test.ts`
```ts
import { describe, it, expect, beforeEach } from "vitest";
import { InMemoryEventLog } from "@openworkgraph/event-log/testing";
import { SalesforceConnector } from "../src/index.js";
const rec = (id: string) => ({
objectType: "Account",
recordId: id,
fields: { Name: `<b>${id}</b>` },
observedAt: new Date().toISOString(),
});
describe("SalesforceConnector", () => {
let log: InMemoryEventLog;
beforeEach(() => { log = new InMemoryEventLog(); });
it("isolates tenants", async () => {
const a = new SalesforceConnector(log, {
tenantId: "t-a", instanceUrl: "x", getAccessToken: async () => "tok-a",
});
const b = new SalesforceConnector(log, {
tenantId: "t-b", instanceUrl: "x", getAccessToken: async () => "tok-b",
});
await a.ingest([rec("1")]);
await b.ingest([rec("2")]);
const aEvents = await log.read({ tenantId: "t-a" });
const bEvents = await log.read({ tenantId: "t-b" });
expect(aEvents.map(e => e.payload.recordId)).toEqual(["1"]);
expect(bEvents.map(e => e.payload.recordId)).toEqual(["2"]);
});
it("honors revocation: no token, no write", async () => {
const c = new SalesforceConnector(log, {
tenantId: "t-a", instanceUrl: "x", getAccessToken: async () => "",
});
await expect(c.ingest([rec("1")])).rejects.toThrow(/missing token/);
expect(await log.read({ tenantId: "t-a" })).toHaveLength(0);
});
it("retention: events older than window are prunable", async () => {
const c = new SalesforceConnector(log, {
tenantId: "t-a", instanceUrl: "x", getAccessToken: async () => "tok",
});
await c.ingest([{ ...rec("old"), observedAt: "2020-01-01T00:00:00Z" }]);
await log.prune({ tenantId: "t-a", olderThan: "2021-01-01T00:00:00Z" });
expect(await log.read({ tenantId: "t-a" })).toHaveLength(0);
});
it("stores raw fields; rendering escapes HTML", async () => {
const c = new SalesforceConnector(log, {
tenantId: "t-a", instanceUrl: "x", getAccessToken: async () => "tok",
});
await c.ingest([rec("1")]);
const [ev] = await log.read({ tenantId: "t-a" });
// raw payload keeps original string; escaping happens at render time
expect(ev.payload.fields.Name).toBe("<b>1</b>");
const rendered = renderSafe(ev.payload.fields.Name as string);
expect(rendered).not.toContain("<b>");
});
it("concurrent ingests from same tenant are serialized by log", async () => {
const c = new SalesforceConnector(log, {
tenantId: "t-a", instanceUrl: "x", getAccessToken: async () => "tok",
});
await Promise.all([
c.ingest([rec("1")]),
c.ingest([rec("2")]),
c.ingest([rec("3")]),
]);
const ids = (await log.read({ tenantId: "t-a" })).map(e => e.payload.recordId);
expect(new Set(ids).size).toBe(3);
});
});
function renderSafe(s: string): string {
return s.replace(/[&<>"']/g, ch => ({
"&": "&", "<": "<", ">": ">", '"': """, "'": "'",
}[ch]!));
}
```
## 5. Minimal additive edits to shared files
`tests.yml` — append one job:
```yaml
connectors-salesforce:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 20 }
- run: npm ci
- run: npm test -w @openworkgraph/connectors-salesforce
```
`dashboard/index.html` — one read-only panel (no scoring, no uploads):
```html
<section id="salesforce-observations" aria-label="Salesforce observations">
<h2>Salesforce observations</h2>
<ul id="sf-list"></ul>
<script type="module">
// read-only; escapes all text; no derived workflow/cause/authorization labels
const esc = s => s.replace(/[&<>"']/g, c => ({
"&":"&","<":"<",">":">",'"':""","'":"'"
}[c]));
const res = await fetch("/api/events?type=source.record.observed");
const events = await res.json();
document.getElementById("sf-list").innerHTML = events
.map(e => `<li>${esc(e.payload.objectType)} ${esc(e.payload.recordId)}</li>`)
.join("");
</script>
</section>
```
Process model — one additive event-type registration:
```ts
registerEventType({
name: "source.record.observed",
description: "Raw observation from an external source. Not a workflow, cause, or authorization.",
payloadSchema: { source: "string", objectType: "string", recordId: "string", fields: "object" },
});
```
## 6. Performance acceptance
Fill in the scale from the issue's baseline table (e.g. `N` records, `T` seconds). Example harness:
```ts
// packages/connectors-salesforce/test/perf.test.ts
import { it, expect } from "vitest";
import { InMemoryEventLog } from "@openworkgraph/event-log/testing";
import { SalesforceConnector } from "../src/index.js";
const N = Number(process.env.PERF_N ?? 10_000);
const BUDGET_MS = Number(process.env.PERF_BUDGET_MS ?? 5_000);
it(`ingests ${N} records within ${BUDGET_MS}ms`, async () => {
const log = new InMemoryEventLog();
const c = new SalesforceConnector(log, {
tenantId: "t-a", instanceUrl: "x", getAccessToken: async () => "tok",
});
const batch = Array.from({ length: N }, (_, i) => ({
objectType: "Account", recordId: String(i),
fields: { Name: `acct-${i}` }, observedAt: new Date().toISOString(),
}));
const t0 = performance.now();
await c.ingest(batch);
const dt = performance.now() - t0;
expect(dt).toBeLessThan(BUDGET_MS);
});
```
Run with `PERF_N` and `PERF_BUDGET_MS` set to the numbers in the issue's baseline table.
## 7. Guardrails baked in
- Only `source.record.observed` events are written — no derived workflow, cause, or authorization events.
- No hidden uploads: the dashboard panel is read-only and fetches from the existing events API.
- No productivity scoring: no aggregate metrics, no per-user scores.
- Tenant isolation enforced by `tenantId` on every event and every read.
- Revocation: missing token aborts before any write.
- Retention: pruning is delegated to the existing log API.
- Safe HTML: escaping at render time; raw payload preserved for audit.
If you paste the relevant section of issue #326 (the baseline table and the exact unchecked item), I can tailor the event schema, test names, and perf numbers to match.