@things-factory/figure-service 10.1.79 → 10.1.82
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.
- package/dist-server/service/figure/figure-format.js +4 -3
- package/dist-server/service/figure/figure-format.js.map +1 -1
- package/dist-server/service/figure/figure-mutation.d.ts +5 -3
- package/dist-server/service/figure/figure-mutation.js +43 -41
- package/dist-server/service/figure/figure-mutation.js.map +1 -1
- package/dist-server/service/figure/figure-proposal-record.d.ts +16 -0
- package/dist-server/service/figure/figure-proposal-record.js +51 -0
- package/dist-server/service/figure/figure-proposal-record.js.map +1 -0
- package/dist-server/service/figure/figure-proposal.d.ts +14 -0
- package/dist-server/service/figure/figure-proposal.js +39 -0
- package/dist-server/service/figure/figure-proposal.js.map +1 -0
- package/dist-server/service/figure/figure-propose-steps.js +12 -0
- package/dist-server/service/figure/figure-propose-steps.js.map +1 -1
- package/dist-server/service/figure/figure-query.d.ts +6 -1
- package/dist-server/service/figure/figure-query.js +33 -0
- package/dist-server/service/figure/figure-query.js.map +1 -1
- package/dist-server/service/figure/figure-rename.d.ts +2 -2
- package/dist-server/service/figure/figure-rename.js +8 -20
- package/dist-server/service/figure/figure-rename.js.map +1 -1
- package/dist-server/service/figure/figure-replaced-draft.d.ts +33 -0
- package/dist-server/service/figure/figure-replaced-draft.js +114 -0
- package/dist-server/service/figure/figure-replaced-draft.js.map +1 -0
- package/dist-server/service/figure/figure-revert.d.ts +36 -0
- package/dist-server/service/figure/figure-revert.js +155 -0
- package/dist-server/service/figure/figure-revert.js.map +1 -0
- package/dist-server/service/figure/figure-tools.js +7 -0
- package/dist-server/service/figure/figure-tools.js.map +1 -1
- package/dist-server/service/figure/figure-type.d.ts +14 -0
- package/dist-server/service/figure/figure-type.js +44 -1
- package/dist-server/service/figure/figure-type.js.map +1 -1
- package/dist-server/service/figure/figure-write.d.ts +7 -0
- package/dist-server/service/figure/figure-write.js +12 -2
- package/dist-server/service/figure/figure-write.js.map +1 -1
- package/dist-server/service/figure/index.d.ts +3 -1
- package/dist-server/service/figure/index.js +3 -1
- package/dist-server/service/figure/index.js.map +1 -1
- package/dist-server/service/index.d.ts +1 -1
- package/dist-server/tsconfig.tsbuildinfo +1 -1
- package/package.json +4 -4
- package/server/service/figure/figure-format.ts +4 -3
- package/server/service/figure/figure-mutation.ts +48 -42
- package/server/service/figure/figure-proposal-db.test.ts +108 -0
- package/server/service/figure/figure-proposal-record.ts +38 -0
- package/server/service/figure/figure-proposal.ts +39 -0
- package/server/service/figure/figure-propose-clips.test.ts +73 -0
- package/server/service/figure/figure-propose-steps.ts +12 -0
- package/server/service/figure/figure-query.ts +22 -1
- package/server/service/figure/figure-rename.test.ts +13 -2
- package/server/service/figure/figure-rename.ts +13 -1
- package/server/service/figure/figure-replaced-draft.ts +90 -0
- package/server/service/figure/figure-revert-db.test.ts +159 -0
- package/server/service/figure/figure-revert-race.pg.test.ts +168 -0
- package/server/service/figure/figure-revert.ts +181 -0
- package/server/service/figure/figure-tools.ts +7 -0
- package/server/service/figure/figure-type.ts +30 -0
- package/server/service/figure/figure-write.test.ts +15 -0
- package/server/service/figure/figure-write.ts +12 -2
- package/server/service/figure/index.ts +3 -1
- package/translations/en.json +3 -3
- package/translations/ko.json +3 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@things-factory/figure-service",
|
|
3
|
-
"version": "10.1.
|
|
3
|
+
"version": "10.1.82",
|
|
4
4
|
"publishConfig": {
|
|
5
5
|
"access": "public",
|
|
6
6
|
"@things-factory:registry": "https://registry.npmjs.org"
|
|
@@ -25,9 +25,9 @@
|
|
|
25
25
|
"test:templates": "node --test scripts/templates.test.mts"
|
|
26
26
|
},
|
|
27
27
|
"dependencies": {
|
|
28
|
-
"@hatiolab/figure-model": "^0.1.
|
|
28
|
+
"@hatiolab/figure-model": "^0.1.94",
|
|
29
29
|
"@operato/typeorm-history": "^10.0.0",
|
|
30
|
-
"@things-factory/ai-client-base": "^10.1.
|
|
30
|
+
"@things-factory/ai-client-base": "^10.1.81",
|
|
31
31
|
"@things-factory/auth-base": "^10.1.79",
|
|
32
32
|
"@things-factory/env": "^10.1.20",
|
|
33
33
|
"@things-factory/shell": "^10.1.79",
|
|
@@ -35,5 +35,5 @@
|
|
|
35
35
|
"graphql-upload": "^15.0.2",
|
|
36
36
|
"koa-router": "^7.4.0"
|
|
37
37
|
},
|
|
38
|
-
"gitHead": "
|
|
38
|
+
"gitHead": "263d0247111fbffb02d7122864df0b61bda99461"
|
|
39
39
|
}
|
|
@@ -267,9 +267,10 @@ export function inspectSource(
|
|
|
267
267
|
const states = inspection.coverage.states
|
|
268
268
|
findings.push({
|
|
269
269
|
code: 'state-range-sampled',
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
270
|
+
/* Sizes as well as motion: the instance size range is swept for every figure (figure-model, designer 2026-09-27). */
|
|
271
|
+
message: say('figure.gate.state-range-sampled.message', { states }, `The whole range of sizes and motion could not be proven, so ${states} combinations of size and pose were checked.`),
|
|
272
|
+
why: say('figure.gate.state-range-sampled.why', {}, 'Between the combinations that were checked, a part may still go past the declared space.'),
|
|
273
|
+
how: say('figure.gate.state-range-sampled.how', {}, 'To cover the whole range, re-author what could not be proven.'),
|
|
273
274
|
/*
|
|
274
275
|
Which motions stopped the proof, by the name a person gave them: the kernel reports each operation it could
|
|
275
276
|
not prove (`j2-shoulder.chain: compose@1 turns …`), and the screen showed that English list as it was. The
|
|
@@ -6,7 +6,10 @@ import { getRepository } from '@things-factory/shell'
|
|
|
6
6
|
|
|
7
7
|
import { Figure } from './figure.js'
|
|
8
8
|
import { FigureHistory } from './figure-history.js'
|
|
9
|
-
import { FigurePatch, FigureSaveResult, NewFigure } from './figure-type.js'
|
|
9
|
+
import { FigurePatch, FigureRevertResult, FigureSaveResult, NewFigure } from './figure-type.js'
|
|
10
|
+
import { FigureReplacedDraft } from './figure-replaced-draft.js'
|
|
11
|
+
import { revertKeepingDraft, undoRevert } from './figure-revert.js'
|
|
12
|
+
import { recordProposals } from './figure-proposal.js'
|
|
10
13
|
import { FigureProposal, ProposeRequest } from './figure-propose-type.js'
|
|
11
14
|
import { inspectSource } from './figure-format.js'
|
|
12
15
|
import { renameDraftType, renameFacts } from './figure-rename.js'
|
|
@@ -27,6 +30,25 @@ export class FigureMutation {
|
|
|
27
30
|
@Arg('creationId', { nullable: true }) creationId?: string,
|
|
28
31
|
@Arg('thumbnail', { nullable: true }) thumbnail?: string,
|
|
29
32
|
@Arg('type', { nullable: true, description: 'The type name a new figure is created under. Ignored for an existing one.' })
|
|
33
|
+
type?: string,
|
|
34
|
+
@Arg('proposalKeys', type => [String], {
|
|
35
|
+
nullable: true,
|
|
36
|
+
description: "Keys of the AI proposals whose steps this source holds: recorded with the save, in its transaction."
|
|
37
|
+
})
|
|
38
|
+
proposalKeys?: string[]
|
|
39
|
+
): Promise<Figure> {
|
|
40
|
+
const saved = await this.writeSource(context, source, id, expectedSource, creationId, thumbnail, type)
|
|
41
|
+
await recordProposals(context.state.tx, context.state.domain, saved.id!, proposalKeys)
|
|
42
|
+
return saved
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
private async writeSource(
|
|
46
|
+
context: ResolverContext,
|
|
47
|
+
source: string,
|
|
48
|
+
id?: string,
|
|
49
|
+
expectedSource?: string,
|
|
50
|
+
creationId?: string,
|
|
51
|
+
thumbnail?: string,
|
|
30
52
|
type?: string
|
|
31
53
|
): Promise<Figure> {
|
|
32
54
|
const { domain, user, tx } = context.state
|
|
@@ -336,53 +358,37 @@ export class FigureMutation {
|
|
|
336
358
|
*/
|
|
337
359
|
@Directive('@transaction')
|
|
338
360
|
@Directive('@privilege(category: "figure", privilege: "mutation", domainOwnerGranted: true)')
|
|
339
|
-
@Mutation(returns =>
|
|
361
|
+
@Mutation(returns => FigureRevertResult, {
|
|
362
|
+
description:
|
|
363
|
+
'Reverts a figure to one of its released versions, keeping the draft it writes over. It comes back as a draft. ' +
|
|
364
|
+
'The same requestId again answers with the same revert.'
|
|
365
|
+
})
|
|
340
366
|
async revertFigureVersion(
|
|
341
367
|
@Arg('id') id: string,
|
|
342
368
|
@Arg('version') version: number,
|
|
369
|
+
@Arg('requestId', { description: 'A UUID for this revert: a retry sends the same one.' }) requestId: string,
|
|
370
|
+
@Arg('expectedSource', { description: "The figure's source as the reverter saw it; the revert goes on only while it holds." }) expectedSource: string,
|
|
343
371
|
@Ctx() context: ResolverContext
|
|
344
|
-
): Promise<
|
|
372
|
+
): Promise<FigureRevertResult> {
|
|
373
|
+
/* Kept and written over in this transaction, or neither (ADR-0097 §5.6). */
|
|
345
374
|
const { domain, user, tx } = context.state
|
|
346
|
-
const
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
const figure = await repository.findOne({
|
|
350
|
-
where: { domain: { id: In([domain.id, domain.parentId].filter(Boolean)) }, id }
|
|
351
|
-
})
|
|
352
|
-
if (!figure) {
|
|
353
|
-
throw new Error(`Figure ${id} 를 찾을 수 없다`)
|
|
354
|
-
}
|
|
355
|
-
|
|
356
|
-
const history = await historyRepository.findOne({
|
|
357
|
-
where: { domain: { id: In([domain.id, domain.parentId].filter(Boolean)) }, originalId: id, version }
|
|
358
|
-
})
|
|
359
|
-
if (!history) {
|
|
360
|
-
throw new Error(`Figure '${figure.type}' 에 ${version} 판이 없다`)
|
|
361
|
-
}
|
|
362
|
-
|
|
363
|
-
/* 형식 검사도 다시 받는다 — 옛 판이 오늘의 형식을 어길 수 있고, 그것을 조용히 들이지 않는다. */
|
|
364
|
-
const { source, derived, violations } = parseSource(history.source)
|
|
365
|
-
|
|
366
|
-
const saved = await repository.save({
|
|
367
|
-
/*
|
|
368
|
-
`domain` 을 명시로 넣는다 — 읽어 온 엔티티에는 관계가 안 실려 있다.
|
|
369
|
-
|
|
370
|
-
판본 스냅샷은 넘겨받은 엔티티를 베끼므로, 여기서 빠지면 판본의 도메인이 비고 그 판은
|
|
371
|
-
도메인으로 거르는 조회에 **한 줄도 안 걸린다**(실제로 그랬다). board 의 저장들도 같은
|
|
372
|
-
자리에서 domain 을 함께 넣는다.
|
|
373
|
-
*/
|
|
374
|
-
domain,
|
|
375
|
-
...figure,
|
|
376
|
-
source,
|
|
377
|
-
...derived,
|
|
378
|
-
properties: history.properties,
|
|
379
|
-
thumbnail: history.thumbnail,
|
|
380
|
-
thumbnailUpdatedAt: history.thumbnail === figure.thumbnail ? figure.thumbnailUpdatedAt : new Date(),
|
|
381
|
-
state: 'draft',
|
|
382
|
-
updater: user
|
|
383
|
-
})
|
|
375
|
+
const { figure, violations, kept } = await revertKeepingDraft(tx, domain, user, { id, version, requestId, expectedSource })
|
|
376
|
+
return { figure, violations, kept }
|
|
377
|
+
}
|
|
384
378
|
|
|
385
|
-
|
|
379
|
+
@Directive('@transaction')
|
|
380
|
+
@Directive('@privilege(category: "figure", privilege: "mutation", domainOwnerGranted: true)')
|
|
381
|
+
@Mutation(returns => FigureRevertResult, {
|
|
382
|
+
description: "Brings back a draft a revert kept -- only while the figure still holds what that revert wrote. The same requestId again answers with the same undo."
|
|
383
|
+
})
|
|
384
|
+
async undoFigureRevert(
|
|
385
|
+
@Arg('keptId') keptId: string,
|
|
386
|
+
@Arg('requestId', { description: 'A UUID for this undo: a retry sends the same one.' }) requestId: string,
|
|
387
|
+
@Ctx() context: ResolverContext
|
|
388
|
+
): Promise<FigureRevertResult> {
|
|
389
|
+
const { domain, user, tx } = context.state
|
|
390
|
+
const { figure, kept } = await undoRevert(tx, domain, user, { keptId, requestId })
|
|
391
|
+
return { figure, violations: [], kept: kept as FigureReplacedDraft }
|
|
386
392
|
}
|
|
387
393
|
|
|
388
394
|
@Directive('@transaction')
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What became of an AI proposal, on sqlite through the save door itself (ADR-0097 §5.6 item 5; AI lane, 2026-09-27).
|
|
3
|
+
* A save records the proposals its source holds, in its transaction; the same proposal taken twice never makes two
|
|
4
|
+
* figures, because a new figure made from it is created under its key.
|
|
5
|
+
*/
|
|
6
|
+
import type { DataSource } from 'typeorm'
|
|
7
|
+
|
|
8
|
+
const { DataSource: TypeOrmDataSource } = require('typeorm')
|
|
9
|
+
const shell = require('@things-factory/shell')
|
|
10
|
+
const authBase = require('@things-factory/auth-base')
|
|
11
|
+
const { createFigureAsset, addFigureBox, serializeFigureAsset } = require('@hatiolab/figure-model')
|
|
12
|
+
|
|
13
|
+
let dataSource: DataSource | null = null
|
|
14
|
+
let Figure: any
|
|
15
|
+
let FigureHistory: any
|
|
16
|
+
let FigureProposalRecord: any
|
|
17
|
+
let FigureMutation: any
|
|
18
|
+
let FigureQuery: any
|
|
19
|
+
|
|
20
|
+
function loadCompiled(modulePath: string): any {
|
|
21
|
+
const { existsSync } = require('fs')
|
|
22
|
+
const { join } = require('path')
|
|
23
|
+
const resolved = join(__dirname, '..', '..', '..', 'dist-server', `${modulePath}.js`)
|
|
24
|
+
if (!existsSync(resolved))
|
|
25
|
+
throw new Error(`figure-service 산출물이 없다: ${resolved}\n먼저 빌드할 것:\n yarn workspace @things-factory/figure-service build:server`)
|
|
26
|
+
return require(resolved)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
beforeAll(async () => {
|
|
30
|
+
Figure = loadCompiled('service/figure/figure').Figure
|
|
31
|
+
FigureHistory = loadCompiled('service/figure/figure-history').FigureHistory
|
|
32
|
+
FigureProposalRecord = loadCompiled('service/figure/figure-proposal-record').FigureProposalRecord
|
|
33
|
+
FigureMutation = loadCompiled('service/figure/figure-mutation').FigureMutation
|
|
34
|
+
FigureQuery = loadCompiled('service/figure/figure-query').FigureQuery
|
|
35
|
+
dataSource = new TypeOrmDataSource({
|
|
36
|
+
type: 'sqlite',
|
|
37
|
+
database: ':memory:',
|
|
38
|
+
synchronize: true,
|
|
39
|
+
dropSchema: true,
|
|
40
|
+
namingStrategy: new shell.NamingStrategy(),
|
|
41
|
+
entities: [...shell.entities, ...authBase.entities, Figure, FigureHistory, FigureProposalRecord],
|
|
42
|
+
logging: false
|
|
43
|
+
})
|
|
44
|
+
await dataSource.initialize()
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
afterAll(async () => {
|
|
48
|
+
await dataSource?.destroy()
|
|
49
|
+
dataSource = null
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
let DOMAIN: { id: string }
|
|
53
|
+
beforeEach(async () => {
|
|
54
|
+
await dataSource!.synchronize(true)
|
|
55
|
+
DOMAIN = { id: (await dataSource!.getRepository(shell.Domain).save({ name: 'proposal-probe', subdomain: 'proposal-probe' })).id }
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
const text = (name: string, height = 100) =>
|
|
59
|
+
serializeFigureAsset(addFigureBox(createFigureAsset(name), { id: 'body', size: { x: 100, y: height, z: 100 }, position: { x: 0, y: height / 2, z: 0 } }))
|
|
60
|
+
const uuid = () => require('crypto').randomUUID()
|
|
61
|
+
/* Through the door, in a transaction of its own as `@transaction` gives it. */
|
|
62
|
+
const save = (source: string, { id, expectedSource, creationId, proposalKeys }: { id?: string; expectedSource?: string; creationId?: string; proposalKeys?: string[] }) =>
|
|
63
|
+
dataSource!.transaction(tx =>
|
|
64
|
+
new FigureMutation().saveFigure(source, { state: { domain: DOMAIN, user: undefined, tx } }, id, expectedSource, creationId, undefined, undefined, proposalKeys)
|
|
65
|
+
)
|
|
66
|
+
const outcome = (proposalKey: string) => new FigureQuery().figureProposalOutcome(proposalKey, { state: { domain: DOMAIN, tx: dataSource!.manager } })
|
|
67
|
+
const records = () => dataSource!.getRepository(FigureProposalRecord).count()
|
|
68
|
+
|
|
69
|
+
describe('a save records the proposals its source holds', () => {
|
|
70
|
+
it('recorded with the save, once per figure however often it is saved again', async () => {
|
|
71
|
+
const key = uuid()
|
|
72
|
+
const first = await save(text('A'), { creationId: uuid(), proposalKeys: [key] })
|
|
73
|
+
await save(text('A', 200), { id: first.id, expectedSource: first.source, proposalKeys: [key] })
|
|
74
|
+
const found = await outcome(key)
|
|
75
|
+
expect(found.map((f: any) => f.figureId)).toEqual([first.id])
|
|
76
|
+
expect(found[0].name).toBe('A')
|
|
77
|
+
expect(await records()).toBe(1)
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('a save refused records nothing -- the record is in its transaction', async () => {
|
|
81
|
+
const figure = await save(text('A'), { creationId: uuid() })
|
|
82
|
+
const key = uuid()
|
|
83
|
+
await expect(save(text('B'), { id: figure.id, expectedSource: 'stale', proposalKeys: [key] })).rejects.toMatchObject({
|
|
84
|
+
extensions: { code: 'FIGURE_SOURCE_CONFLICT' }
|
|
85
|
+
})
|
|
86
|
+
expect(await outcome(key)).toEqual([])
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it('a key not of the tool’s making is not recorded; a proposal never saved has no outcome', async () => {
|
|
90
|
+
await save(text('A'), { creationId: uuid(), proposalKeys: ['not-a-key'] })
|
|
91
|
+
expect(await records()).toBe(0)
|
|
92
|
+
expect(await outcome(uuid())).toEqual([])
|
|
93
|
+
})
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
describe('the same new-figure proposal taken twice makes one figure', () => {
|
|
97
|
+
it('created under the proposal key: the second take meets the first figure, never a second one', async () => {
|
|
98
|
+
const key = uuid()
|
|
99
|
+
const once = await save(text('From AI'), { creationId: key, proposalKeys: [key] })
|
|
100
|
+
/* Taken again after a reload, the same steps: the same row answers. */
|
|
101
|
+
const again = await save(text('From AI'), { creationId: key, proposalKeys: [key] })
|
|
102
|
+
expect(again.id).toBe(once.id)
|
|
103
|
+
/* Taken again and changed before saving: refused, not a second figure. */
|
|
104
|
+
await expect(save(text('From AI, changed', 300), { creationId: key, proposalKeys: [key] })).rejects.toThrow(/이미 다른 내용으로 저장되었습니다/)
|
|
105
|
+
expect(await dataSource!.getRepository(Figure).count()).toBe(1)
|
|
106
|
+
expect((await outcome(key)).map((f: any) => f.figureId)).toEqual([key])
|
|
107
|
+
})
|
|
108
|
+
})
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { CreateDateColumn, Column, Entity, Index, ManyToOne, PrimaryGeneratedColumn, RelationId } from 'typeorm'
|
|
2
|
+
import { Field, ID, ObjectType } from 'type-graphql'
|
|
3
|
+
|
|
4
|
+
import { Domain } from '@things-factory/shell'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* An AI proposal saved into a figure (ADR-0097 §5.6 item 5; AI lane, 2026-09-27).
|
|
8
|
+
*
|
|
9
|
+
* Written by the save that holds the proposal's step, in the same transaction: a proposal applied and then undone, or
|
|
10
|
+
* never saved, has none. So "what became of it" is answered from the server -- after a reload too -- and never from
|
|
11
|
+
* the dock's memory. A proposal saved into the same figure again is one record.
|
|
12
|
+
*/
|
|
13
|
+
@Entity()
|
|
14
|
+
@Index('ix_figure_proposal_record_0', (record: FigureProposalRecord) => [record.domain, record.proposalKey, record.figureId], { unique: true })
|
|
15
|
+
@ObjectType({ description: 'An AI proposal saved into a figure.' })
|
|
16
|
+
export class FigureProposalRecord {
|
|
17
|
+
@PrimaryGeneratedColumn('uuid')
|
|
18
|
+
@Field(type => ID, { description: 'Unique identifier of the record.' })
|
|
19
|
+
readonly id?: string
|
|
20
|
+
|
|
21
|
+
@ManyToOne(type => Domain)
|
|
22
|
+
domain?: Domain
|
|
23
|
+
|
|
24
|
+
@RelationId((record: FigureProposalRecord) => record.domain)
|
|
25
|
+
domainId?: string
|
|
26
|
+
|
|
27
|
+
@Column()
|
|
28
|
+
@Field({ description: 'The key the proposal was given when it was made.' })
|
|
29
|
+
proposalKey?: string
|
|
30
|
+
|
|
31
|
+
@Column()
|
|
32
|
+
@Field({ description: 'The figure it was saved into.' })
|
|
33
|
+
figureId?: string
|
|
34
|
+
|
|
35
|
+
@CreateDateColumn()
|
|
36
|
+
@Field({ nullable: true, description: 'When the save that held it was made.' })
|
|
37
|
+
recordedAt?: Date
|
|
38
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { EntityManager } from 'typeorm'
|
|
2
|
+
import { In } from 'typeorm'
|
|
3
|
+
|
|
4
|
+
import { Figure } from './figure.js'
|
|
5
|
+
import { FigureProposalRecord } from './figure-proposal-record.js'
|
|
6
|
+
|
|
7
|
+
/*
|
|
8
|
+
* What became of an AI proposal (ADR-0097 §5.6 item 5; AI lane, 2026-09-27): the saves that held its step. The key is the
|
|
9
|
+
* one `proposeFigureCandidate` gave it; a new figure made from it is created under that key, so the same proposal
|
|
10
|
+
* taken twice meets the first figure, never a second one.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const KEY = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
|
|
14
|
+
|
|
15
|
+
/** Records, in the save's transaction, the proposals its source holds. Keys not of the tool's making are not recorded. */
|
|
16
|
+
export async function recordProposals(tx: EntityManager, domain: { id: string }, figureId: string, keys?: readonly string[]) {
|
|
17
|
+
const wanted = [...new Set((keys ?? []).filter(key => KEY.test(key)))]
|
|
18
|
+
if (!wanted.length) return
|
|
19
|
+
const records = tx.getRepository(FigureProposalRecord)
|
|
20
|
+
const already = new Set(
|
|
21
|
+
(await records.find({ where: { domain: { id: domain.id }, figureId, proposalKey: In(wanted) } })).map(record => record.proposalKey)
|
|
22
|
+
)
|
|
23
|
+
const fresh = wanted.filter(key => !already.has(key))
|
|
24
|
+
if (fresh.length) await records.insert(fresh.map(proposalKey => ({ domain: { id: domain.id }, figureId, proposalKey })))
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** The figures a proposal was saved into, with their names as they are now. None: not saved -- or not known here. */
|
|
28
|
+
export async function proposalOutcomeOf(tx: EntityManager, domain: { id: string }, proposalKey: string) {
|
|
29
|
+
const records = await tx.getRepository(FigureProposalRecord).find({ where: { domain: { id: domain.id }, proposalKey }, order: { recordedAt: 'ASC' } })
|
|
30
|
+
if (!records.length) return []
|
|
31
|
+
const figures = await tx.getRepository(Figure).find({ where: { domain: { id: domain.id }, id: In(records.map(r => r.figureId!)) } })
|
|
32
|
+
const byId = new Map(figures.map(f => [f.id, f]))
|
|
33
|
+
return records.map(record => ({
|
|
34
|
+
figureId: record.figureId!,
|
|
35
|
+
recordedAt: record.recordedAt,
|
|
36
|
+
type: byId.get(record.figureId!)?.type,
|
|
37
|
+
name: byId.get(record.figureId!)?.name
|
|
38
|
+
}))
|
|
39
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* figure-propose-steps and clips (designer 2026-09-27): an AI answer that would stop a clip is refused whole, and the
|
|
3
|
+
* model is told it may not -- never how to agree. An answer that stops none keeps every clip. Consent the model writes
|
|
4
|
+
* into its answer is read as nothing.
|
|
5
|
+
*/
|
|
6
|
+
import { addFigureBox, applyFigureAuthoring, createFigureAsset, editFigurePart } from '@hatiolab/figure-model'
|
|
7
|
+
import type { FigureAsset } from '@hatiolab/figure-model'
|
|
8
|
+
import { setDefaultAIClient } from '@things-factory/ai-client-base'
|
|
9
|
+
import type { AIClient } from '@things-factory/ai-client-base'
|
|
10
|
+
|
|
11
|
+
import { ProposeFigureFailure, judgeFigureAnswer, proposeFigureSteps } from './figure-propose-steps'
|
|
12
|
+
|
|
13
|
+
function stubClient(answers: unknown[]): AIClient & { calls: number; sent: any[][] } {
|
|
14
|
+
const client = {
|
|
15
|
+
id: 'stub',
|
|
16
|
+
calls: 0,
|
|
17
|
+
sent: [] as any[][],
|
|
18
|
+
async chat() {
|
|
19
|
+
return ''
|
|
20
|
+
},
|
|
21
|
+
async generateJSON(messages: any[]) {
|
|
22
|
+
client.sent.push(JSON.parse(JSON.stringify(messages)))
|
|
23
|
+
return answers[Math.min(client.calls++, answers.length - 1)]
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return client as unknown as AIClient & { calls: number; sent: any[][] }
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
afterEach(() => setDefaultAIClient(undefined))
|
|
30
|
+
|
|
31
|
+
function door(): FigureAsset {
|
|
32
|
+
let a = createFigureAsset('Door')
|
|
33
|
+
a = addFigureBox(a, { id: 'frame', size: { x: 1000, y: 100, z: 200 }, position: { x: 0, y: 50, z: 0 } })
|
|
34
|
+
a = addFigureBox(a, { id: 'leaf', size: { x: 900, y: 1800, z: 40 }, position: { x: 0, y: 1000, z: 0 } })
|
|
35
|
+
a = addFigureBox(a, { id: 'sign', size: { x: 200, y: 100, z: 10 }, position: { x: 0, y: 2200, z: 200 } })
|
|
36
|
+
a = applyFigureAuthoring(a, { kind: 'attach', part: 'leaf', face: 'bottom', to: { part: 'frame', face: 'top' } })
|
|
37
|
+
const j = a.joints!.find(k => k.body1.part === 'leaf')!
|
|
38
|
+
a = applyFigureAuthoring(a, { kind: 'set-joint', joint: { ...j, type: 'revolute', axis: 'Y', state: { id: 'open' } } })
|
|
39
|
+
return editFigurePart(a, { kind: 'set-driver', state: 'open', clip: 'swing', duration: 2, keys: [{ seconds: 0, value: 0 }, { seconds: 2, value: 80 }] })
|
|
40
|
+
}
|
|
41
|
+
const lossy = { steps: [{ kind: 'rename', name: 'Door by AI' }, { kind: 'remove-part', target: 'leaf' }] }
|
|
42
|
+
|
|
43
|
+
describe('an AI answer and the clips of the draft it revises', () => {
|
|
44
|
+
it('an answer that would stop a clip is refused whole, and the model is told it may not -- not how to agree', () => {
|
|
45
|
+
const base = door()
|
|
46
|
+
const judged = judgeFigureAnswer(base, lossy, true)
|
|
47
|
+
expect(judged.source).toBeUndefined()
|
|
48
|
+
expect(judged.reasons.join('\n')).toMatch(/CLIPS_DROPPED: .*swing.*may never remove a clip; only the person can/)
|
|
49
|
+
expect(judged.reasons.join('\n')).not.toMatch(/dropClips/)
|
|
50
|
+
expect(base.drivers?.map(d => d.clip)).toEqual(['swing'])
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('asked three times and three times lossy, nothing comes back: no half-made figure to save', async () => {
|
|
54
|
+
const client = stubClient([lossy])
|
|
55
|
+
setDefaultAIClient(client)
|
|
56
|
+
await expect(proposeFigureSteps({ prompt: '문을 없애 줘', base: door() })).rejects.toBeInstanceOf(ProposeFigureFailure)
|
|
57
|
+
expect(client.calls).toBe(3)
|
|
58
|
+
expect(JSON.stringify(client.sent)).not.toMatch(/dropClips/)
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('consent the model writes into its answer is read as nothing', () => {
|
|
62
|
+
const judged = judgeFigureAnswer(door(), { ...lossy, dropClips: ['swing/open'], steps: lossy.steps.map(s => ({ ...s, dropClips: ['swing/open'] })) }, true)
|
|
63
|
+
expect(judged.source).toBeUndefined()
|
|
64
|
+
expect(judged.reasons.join('\n')).toMatch(/^CLIPS_DROPPED/)
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('an answer that stops no clip keeps every clip, a rename of the part it hangs from included', async () => {
|
|
68
|
+
const base = door()
|
|
69
|
+
setDefaultAIClient(stubClient([{ steps: [{ kind: 'rename', name: 'Door 2' }, { kind: 'nudge-part', part: 'sign', by: { x: 50 } }, { kind: 'rename-part', target: 'frame', name: 'jamb' }] }]))
|
|
70
|
+
const made = await proposeFigureSteps({ prompt: '이름을 바꿔 줘', base })
|
|
71
|
+
expect(made.source.drivers).toEqual(base.drivers)
|
|
72
|
+
})
|
|
73
|
+
})
|
|
@@ -114,8 +114,20 @@ export function judgeFigureAnswer(base: FigureAsset, answer: unknown, revising:
|
|
|
114
114
|
|
|
115
115
|
let source: FigureAsset
|
|
116
116
|
try {
|
|
117
|
+
/* Never with consent: only the person may let a clip go (designer 2026-09-27). */
|
|
117
118
|
source = applyFigureProposal(base, steps as FigureProposalStep[])
|
|
118
119
|
} catch (e) {
|
|
120
|
+
/*
|
|
121
|
+
The kernel's refusal tells a person how to agree; the model is told it may not, and what to keep instead -- it
|
|
122
|
+
must not learn to write a consent (designer 2026-09-27).
|
|
123
|
+
*/
|
|
124
|
+
const clips = (e as { code?: string; clips?: Array<{ clip: string; state: string }> }).code === 'CLIPS_DROPPED' ? (e as { clips: Array<{ clip: string; state: string }> }).clips : undefined
|
|
125
|
+
if (clips)
|
|
126
|
+
return {
|
|
127
|
+
reasons: [
|
|
128
|
+
`CLIPS_DROPPED: these steps would stop the clips ${clips.map(c => `${c.clip} (plays ${c.state})`).join(', ')}. A proposal may never remove a clip; only the person can. Keep the parts and the motions those clips play as they are.`
|
|
129
|
+
]
|
|
130
|
+
}
|
|
119
131
|
return { reasons: [(e as Error).message] }
|
|
120
132
|
}
|
|
121
133
|
|
|
@@ -14,7 +14,10 @@ import {
|
|
|
14
14
|
import { Figure } from './figure.js'
|
|
15
15
|
import { FigureHistory } from './figure-history.js'
|
|
16
16
|
import { consumablePayloadOf, inspectSource } from './figure-format.js'
|
|
17
|
-
import { FigureInspection, FigureList } from './figure-type.js'
|
|
17
|
+
import { FigureInspection, FigureList, FigureProposalOutcome } from './figure-type.js'
|
|
18
|
+
import { FigureReplacedDraft } from './figure-replaced-draft.js'
|
|
19
|
+
import { replacedDraftsOf } from './figure-revert.js'
|
|
20
|
+
import { proposalOutcomeOf } from './figure-proposal.js'
|
|
18
21
|
|
|
19
22
|
@Resolver(Figure)
|
|
20
23
|
@UseMiddleware(SpeakRefusals)
|
|
@@ -88,6 +91,24 @@ export class FigureQuery {
|
|
|
88
91
|
* 도형의 현재 정본은 저작 중일 수 있다(초안). 배치된 것이 보는 것은 **마지막으로 발행된 판**이고,
|
|
89
92
|
* 그것을 이 자리가 답한다.
|
|
90
93
|
*/
|
|
94
|
+
/** What became of an AI proposal: the figures a save put it into (ADR-0097 §5.6 item 5). */
|
|
95
|
+
@Directive('@privilege(category: "figure", privilege: "query", domainOwnerGranted: true)')
|
|
96
|
+
@Query(returns => [FigureProposalOutcome], {
|
|
97
|
+
description: 'The figures an AI proposal was saved into, oldest first. None: it was not saved, or not in this domain.'
|
|
98
|
+
})
|
|
99
|
+
async figureProposalOutcome(@Arg('proposalKey') proposalKey: string, @Ctx() context: ResolverContext): Promise<FigureProposalOutcome[]> {
|
|
100
|
+
const { domain, tx } = context.state
|
|
101
|
+
return await proposalOutcomeOf(getRepository(Figure, tx).manager, domain, proposalKey)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** The drafts reverts kept for a figure, newest first -- apart from its released versions (ADR-0097 §5.6). */
|
|
105
|
+
@Directive('@privilege(category: "figure", privilege: "query", domainOwnerGranted: true)')
|
|
106
|
+
@Query(returns => [FigureReplacedDraft], { description: 'Drafts kept when a revert wrote over them, newest first. Not released versions.' })
|
|
107
|
+
async figureReplacedDrafts(@Arg('id') id: string, @Ctx() context: ResolverContext): Promise<FigureReplacedDraft[]> {
|
|
108
|
+
const { domain, tx } = context.state
|
|
109
|
+
return await replacedDraftsOf(getRepository(FigureReplacedDraft, tx).manager, domain, id)
|
|
110
|
+
}
|
|
111
|
+
|
|
91
112
|
@Directive('@privilege(category: "figure", privilege: "query", domainOwnerGranted: true)')
|
|
92
113
|
@Query(returns => FigureHistory, { nullable: true, description: 'The most recently released version of a figure.' })
|
|
93
114
|
async figureReleased(@Arg('id') id: string, @Ctx() context: ResolverContext): Promise<FigureHistory> {
|
|
@@ -33,9 +33,10 @@ describe('초안의 type 고치기', () => {
|
|
|
33
33
|
expect(() => parseFigureAsset(result.source)).not.toThrow()
|
|
34
34
|
})
|
|
35
35
|
|
|
36
|
-
it('앞뒤 공백을 뗀다.
|
|
36
|
+
it('앞뒤 공백을 뗀다. 모양은 모델러가 묻는 규칙 그대로 — 대문자로 적지 않으면 거절한다(Q-D3, 2026-09-27)', () => {
|
|
37
37
|
expect(renameDraftType(draft(), ' ROBOT_ARM ', never).type).toBe('ROBOT_ARM')
|
|
38
|
-
|
|
38
|
+
/* It was kept as written; the modeller always sent it upper-cased, and every stored type already is (56/56). */
|
|
39
|
+
expect(() => renameDraftType(draft(), 'Robot_Arm', never)).toThrow(expect.objectContaining({ code: 'malformed-type' }))
|
|
39
40
|
})
|
|
40
41
|
|
|
41
42
|
it('★ 한 번이라도 발행됐으면 거절한다 — 판 번호로', () => {
|
|
@@ -104,3 +105,13 @@ describe('문이 이 규칙을 지난다', () => {
|
|
|
104
105
|
expect(/class FigurePatch \{[\s\S]{0,400}type\?: string/.test(type)).toBe(true)
|
|
105
106
|
})
|
|
106
107
|
})
|
|
108
|
+
|
|
109
|
+
describe('a type being set keeps the shape the modeller asks for (Q-D3, designer 2026-09-27)', () => {
|
|
110
|
+
const free = { version: 0, hasHistory: false, takenByAnother: false }
|
|
111
|
+
it.each(['lower_case', 'X1', 'A'.repeat(33), '1ABC', 'AB-CD', 'AB__CD', '한글'])('refuses %s as malformed', wanted => {
|
|
112
|
+
expect(() => renameDraftType({ type: 'OLD', source: '{}' }, wanted, free)).toThrow(expect.objectContaining({ code: 'malformed-type' }))
|
|
113
|
+
})
|
|
114
|
+
it.each(['FIG_K3F9A2', 'CONVEYOR', 'LIFT_TABLE_2'])('takes %s', wanted => {
|
|
115
|
+
expect(renameDraftType({ type: 'OLD', source: '{}' }, wanted, free).type).toBe(wanted)
|
|
116
|
+
})
|
|
117
|
+
})
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { TYPE_LENGTH, TYPE_PATTERN } from '@hatiolab/figure-model'
|
|
1
2
|
/**
|
|
2
3
|
* 초안의 타입을 고치는 규칙 — ADR-0063 결정 4.
|
|
3
4
|
*
|
|
@@ -32,7 +33,7 @@ export interface RenameFacts {
|
|
|
32
33
|
|
|
33
34
|
export class FigureRenameRefused extends Error {
|
|
34
35
|
constructor(
|
|
35
|
-
readonly code: 'empty-type' | 'already-released' | 'type-taken',
|
|
36
|
+
readonly code: 'empty-type' | 'malformed-type' | 'already-released' | 'type-taken',
|
|
36
37
|
message: string
|
|
37
38
|
) {
|
|
38
39
|
super(message)
|
|
@@ -57,6 +58,17 @@ export function renameDraftType(
|
|
|
57
58
|
throw new FigureRenameRefused('empty-type', 'type 이 비어 있다 — 도면에 적히는 이름이라 비울 수 없다')
|
|
58
59
|
}
|
|
59
60
|
|
|
61
|
+
/*
|
|
62
|
+
The shape the modeller has always asked for, now asked here too (designer 2026-09-27, Q-D3): only a type being set
|
|
63
|
+
is held to it -- every type in the development data already keeps it (56 of 56, 2026-09-27), and none is renamed.
|
|
64
|
+
*/
|
|
65
|
+
if (!TYPE_PATTERN.test(next) || next.length < TYPE_LENGTH.min || next.length > TYPE_LENGTH.max) {
|
|
66
|
+
throw new FigureRenameRefused(
|
|
67
|
+
'malformed-type',
|
|
68
|
+
`type 「${next}」 는 대문자로 시작하고 대문자·숫자·밑줄만 쓰며 ${TYPE_LENGTH.min}~${TYPE_LENGTH.max}자여야 한다`
|
|
69
|
+
)
|
|
70
|
+
}
|
|
71
|
+
|
|
60
72
|
if ((facts.version ?? 0) > 0 || facts.hasHistory) {
|
|
61
73
|
throw new FigureRenameRefused(
|
|
62
74
|
'already-released',
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { Column, CreateDateColumn, Entity, Index, ManyToOne, PrimaryGeneratedColumn, RelationId } from 'typeorm'
|
|
2
|
+
import { Field, ID, Int, ObjectType } from 'type-graphql'
|
|
3
|
+
|
|
4
|
+
import { User } from '@things-factory/auth-base'
|
|
5
|
+
import { Domain, longTextColumn } from '@things-factory/shell'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* A figure's draft as it stood before a revert wrote over it -- kept, so what was saved just before a revert can be
|
|
9
|
+
* brought back, after a reload too (ADR-0097 §5.6, designer 2026-09-27; table chosen with the chief architect).
|
|
10
|
+
*
|
|
11
|
+
* Not a released version: it has no version number of its own, is not listed with the releases, and never changes
|
|
12
|
+
* what a board uses. Kept in the same transaction as the revert that replaces the draft -- kept, or neither happens.
|
|
13
|
+
* `requestId` makes a retried revert (its answer lost) the same revert, not a second one.
|
|
14
|
+
*
|
|
15
|
+
* An undo of a revert is a revert too (`kind: 'undo'`): it keeps what it replaces the same way.
|
|
16
|
+
*/
|
|
17
|
+
@Entity()
|
|
18
|
+
@Index('ix_figure_replaced_draft_1', (kept: FigureReplacedDraft) => [kept.domain, kept.requestId], { unique: true })
|
|
19
|
+
@Index('ix_figure_replaced_draft_2', (kept: FigureReplacedDraft) => [kept.domain, kept.figureId])
|
|
20
|
+
@ObjectType({ description: 'A figure draft kept when a revert wrote over it. Not a released version.' })
|
|
21
|
+
export class FigureReplacedDraft {
|
|
22
|
+
@PrimaryGeneratedColumn('uuid')
|
|
23
|
+
@Field(type => ID, { description: 'Unique identifier of the kept draft.' })
|
|
24
|
+
readonly id?: string
|
|
25
|
+
|
|
26
|
+
@ManyToOne(type => Domain)
|
|
27
|
+
domain?: Domain
|
|
28
|
+
|
|
29
|
+
@RelationId((kept: FigureReplacedDraft) => kept.domain)
|
|
30
|
+
domainId?: string
|
|
31
|
+
|
|
32
|
+
@Column()
|
|
33
|
+
@Field({ description: 'The figure whose draft this was.' })
|
|
34
|
+
figureId?: string
|
|
35
|
+
|
|
36
|
+
@Column({ default: 'revert' })
|
|
37
|
+
@Field({ description: "What replaced it: 'revert' (a released version written over it) or 'undo' (an undo of a revert)." })
|
|
38
|
+
kind?: 'revert' | 'undo'
|
|
39
|
+
|
|
40
|
+
@Column({ nullable: true, ...longTextColumn() })
|
|
41
|
+
@Field({ nullable: true, description: 'The figure asset JSON the draft held.' })
|
|
42
|
+
source?: string
|
|
43
|
+
|
|
44
|
+
@Column({ nullable: true, ...longTextColumn() })
|
|
45
|
+
properties?: string
|
|
46
|
+
|
|
47
|
+
@Column({ nullable: true, ...longTextColumn() })
|
|
48
|
+
thumbnail?: string
|
|
49
|
+
|
|
50
|
+
@Column({ nullable: true })
|
|
51
|
+
@Field({ nullable: true, description: 'The state the figure was in.' })
|
|
52
|
+
state?: string
|
|
53
|
+
|
|
54
|
+
@Column({ nullable: true })
|
|
55
|
+
@Field(type => Int, { nullable: true, description: 'The released version number the figure had then.' })
|
|
56
|
+
version?: number
|
|
57
|
+
|
|
58
|
+
@Column({ nullable: true })
|
|
59
|
+
@Field(type => Int, { nullable: true, description: 'The released version the revert wrote over it; none for an undo.' })
|
|
60
|
+
revertedToVersion?: number
|
|
61
|
+
|
|
62
|
+
/* What replaced it, whole: an undo proceeds only while the figure still holds exactly this (compare-and-swap). */
|
|
63
|
+
@Column({ nullable: true, ...longTextColumn() })
|
|
64
|
+
writtenSource?: string
|
|
65
|
+
|
|
66
|
+
/* Its content hash, for finding and retrying without reading the whole text (chief architect, 2026-09-27). */
|
|
67
|
+
@Column()
|
|
68
|
+
writtenSourceHash?: string
|
|
69
|
+
|
|
70
|
+
@Column()
|
|
71
|
+
requestId?: string
|
|
72
|
+
|
|
73
|
+
@Column({ nullable: true })
|
|
74
|
+
@Field({ nullable: true, description: 'When this draft was brought back by an undo.' })
|
|
75
|
+
restoredAt?: Date
|
|
76
|
+
|
|
77
|
+
@Column({ nullable: true })
|
|
78
|
+
restoreRequestId?: string
|
|
79
|
+
|
|
80
|
+
@CreateDateColumn()
|
|
81
|
+
@Field({ nullable: true, description: 'When it was kept -- when the revert replaced it.' })
|
|
82
|
+
createdAt?: Date
|
|
83
|
+
|
|
84
|
+
@ManyToOne(type => User, { nullable: true })
|
|
85
|
+
@Field(type => User, { nullable: true, description: 'Who reverted.' })
|
|
86
|
+
creator?: User
|
|
87
|
+
|
|
88
|
+
@RelationId((kept: FigureReplacedDraft) => kept.creator)
|
|
89
|
+
creatorId?: string
|
|
90
|
+
}
|